Commit 8ccdb8ea authored by Patrick Bair's avatar Patrick Bair

Merge branch '2256-search-issues-by-crm-organization' into 'master'

Add ability to search issues by crm organization

See merge request gitlab-org/gitlab!75865
parents 1d9f89bf ff17e0f3
......@@ -71,6 +71,9 @@ export default {
this.error = false;
this.errorMessages = [];
},
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_contact_id=${value}`;
},
},
fields: [
{ key: 'firstName', sortable: true },
......@@ -142,7 +145,7 @@ export default {
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
:href="`${groupIssuesPath}?scope=all&state=opened&crm_contact_id=${data.value}`"
:href="getIssuesPath(groupIssuesPath, data.value)"
/>
</template>
</gl-table>
......
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import createFlash from '~/flash';
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
export default {
components: {
GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
},
inject: ['groupFullPath'],
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['groupFullPath', 'groupIssuesPath'],
data() {
return { organizations: [] };
return {
error: false,
organizations: [],
};
},
apollo: {
organizations: {
......@@ -26,12 +34,8 @@ export default {
update(data) {
return this.extractOrganizations(data);
},
error(error) {
createFlash({
message: __('Something went wrong. Please try again.'),
error,
captureError: true,
});
error() {
this.error = true;
},
},
},
......@@ -45,20 +49,38 @@ export default {
const organizations = data?.group?.organizations?.nodes || [];
return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
},
dismissError() {
this.error = false;
},
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
},
},
fields: [
{ key: 'name', sortable: true },
{ key: 'defaultRate', sortable: true },
{ key: 'description', sortable: true },
{
key: 'id',
label: __('Issues'),
formatter: (id) => {
return getIdFromGraphQLId(id);
},
},
],
i18n: {
emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'),
errorText: __('Something went wrong. Please try again.'),
},
};
</script>
<template>
<div>
<gl-alert v-if="error" variant="danger" class="gl-my-6" @dismiss="dismissError">
<div>{{ $options.i18n.errorText }}</div>
</gl-alert>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
......@@ -66,6 +88,16 @@ export default {
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
show-empty
>
<template #cell(id)="data">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
:href="getIssuesPath(groupIssuesPath, data.value)"
/>
</template>
</gl-table>
</div>
</template>
......@@ -16,10 +16,12 @@ export default () => {
return false;
}
const { groupFullPath, groupIssuesPath } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: { groupFullPath: el.dataset.groupFullPath },
provide: { groupFullPath, groupIssuesPath },
render(createElement) {
return createElement(CrmOrganizationsRoot);
},
......
......@@ -36,6 +36,7 @@
# attempt_group_search_optimizations: boolean
# attempt_project_search_optimizations: boolean
# crm_contact_id: integer
# crm_organization_id: integer
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
......@@ -61,6 +62,7 @@ class IssuableFinder
author_id
author_username
crm_contact_id
crm_organization_id
label_name
milestone_title
release_tag
......@@ -141,7 +143,8 @@ class IssuableFinder
items = by_release(items)
items = by_label(items)
items = by_my_reaction_emoji(items)
by_crm_contact(items)
items = by_crm_contact(items)
by_crm_organization(items)
end
def should_filter_negated_args?
......@@ -470,6 +473,10 @@ class IssuableFinder
Issuables::CrmContactFilter.new(params: original_params).filter(items)
end
def by_crm_organization(items)
Issuables::CrmOrganizationFilter.new(params: original_params).filter(items)
end
def or_filters_enabled?
strong_memoize(:or_filters_enabled) do
Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml)
......
# frozen_string_literal: true
module Issuables
class CrmOrganizationFilter < BaseFilter
def filter(issuables)
by_crm_organization(issuables)
end
# rubocop: disable CodeReuse/ActiveRecord
def by_crm_organization(issuables)
return issuables if params[:crm_organization_id].blank?
condition = CustomerRelations::IssueContact
.joins(:contact)
.where(contact: { organization_id: params[:crm_organization_id] })
.where(Arel.sql("issue_id = issues.id"))
issuables.where(condition.arel.exists)
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
- breadcrumb_title _('Customer Relations Organizations')
- page_title _('Customer Relations Organizations')
#js-crm-organizations-app{ data: { group_full_path: @group.full_path } }
#js-crm-organizations-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group) } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issuables::CrmOrganizationFilter do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:organization1) { create(:organization, group: group) }
let_it_be(:organization2) { create(:organization, group: group) }
let_it_be(:contact1) { create(:contact, group: group, organization: organization1) }
let_it_be(:contact2) { create(:contact, group: group, organization: organization1) }
let_it_be(:contact3) { create(:contact, group: group, organization: organization2) }
let_it_be(:contact1_issue) { create(:issue, project: project) }
let_it_be(:contact2_issue) { create(:issue, project: project) }
let_it_be(:contact3_issue) { create(:issue, project: project) }
let_it_be(:issues) { Issue.where(id: [contact1_issue.id, contact2_issue.id, contact3_issue.id]) }
before_all do
create(:issue_customer_relations_contact, issue: contact1_issue, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue, contact: contact2)
create(:issue_customer_relations_contact, issue: contact3_issue, contact: contact3)
end
describe 'when an organization has issues' do
it 'returns all organization1 issues' do
params = { crm_organization_id: organization1.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact1_issue, contact2_issue)
end
it 'returns all organization2 issues' do
params = { crm_organization_id: organization2.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact3_issue)
end
end
describe 'when an organization has no issues' do
it 'returns no issues' do
organization3 = create(:organization, group: group)
params = { crm_organization_id: organization3.id }
expect(described_class.new(params: params).filter(issues)).to be_empty
end
end
end
......@@ -920,7 +920,7 @@ RSpec.describe IssuesFinder do
let(:params) { { crm_contact_id: contact1.id } }
it 'returns issues with that label' do
it 'returns for that contact' do
create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
......@@ -929,6 +929,26 @@ RSpec.describe IssuesFinder do
end
end
context 'filtering by crm organization' do
let_it_be(:organization) { create(:organization, group: group) }
let_it_be(:contact1) { create(:contact, group: group, organization: organization) }
let_it_be(:contact2) { create(:contact, group: group, organization: organization) }
let_it_be(:contact1_issue1) { create(:issue, project: project1) }
let_it_be(:contact1_issue2) { create(:issue, project: project1) }
let_it_be(:contact2_issue1) { create(:issue, project: project1) }
let(:params) { { crm_organization_id: organization.id } }
it 'returns for that contact' do
create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
expect(issues).to contain_exactly(contact1_issue1, contact1_issue2, contact2_issue1)
end
end
context 'when the user is unauthorized' do
let(:search_user) { nil }
......
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import OrganizationsRoot from '~/crm/components/organizations_root.vue';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data';
jest.mock('~/flash');
describe('Customer relations organizations root app', () => {
Vue.use(VueApollo);
let wrapper;
......@@ -18,6 +15,8 @@ describe('Customer relations organizations root app', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
const mountComponent = ({
......@@ -26,7 +25,7 @@ describe('Customer relations organizations root app', () => {
} = {}) => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, {
provide: { groupFullPath: 'flightjs' },
provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo,
});
};
......@@ -46,7 +45,7 @@ describe('Customer relations organizations root app', () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
expect(findError().exists()).toBe(true);
});
it('renders correct results', async () => {
......@@ -56,5 +55,11 @@ describe('Customer relations organizations root app', () => {
expect(findRowByName(/Test Inc/i)).toHaveLength(1);
expect(findRowByName(/VIP/i)).toHaveLength(1);
expect(findRowByName(/120/i)).toHaveLength(1);
const issueLink = findIssuesLinks().at(0);
expect(issueLink.exists()).toBe(true);
expect(issueLink.attributes('href')).toBe(
'/issues?scope=all&state=opened&crm_organization_id=2',
);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment