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 { ...@@ -71,6 +71,9 @@ export default {
this.error = false; this.error = false;
this.errorMessages = []; this.errorMessages = [];
}, },
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_contact_id=${value}`;
},
}, },
fields: [ fields: [
{ key: 'firstName', sortable: true }, { key: 'firstName', sortable: true },
...@@ -142,7 +145,7 @@ export default { ...@@ -142,7 +145,7 @@ export default {
data-testid="issues-link" data-testid="issues-link"
icon="issues" icon="issues"
:aria-label="$options.i18n.issuesButtonLabel" :aria-label="$options.i18n.issuesButtonLabel"
:href="`${groupIssuesPath}?scope=all&state=opened&crm_contact_id=${data.value}`" :href="getIssuesPath(groupIssuesPath, data.value)"
/> />
</template> </template>
</gl-table> </gl-table>
......
<script> <script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
export default { export default {
components: { components: {
GlAlert,
GlButton,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
}, },
inject: ['groupFullPath'], directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['groupFullPath', 'groupIssuesPath'],
data() { data() {
return { organizations: [] }; return {
error: false,
organizations: [],
};
}, },
apollo: { apollo: {
organizations: { organizations: {
...@@ -26,12 +34,8 @@ export default { ...@@ -26,12 +34,8 @@ export default {
update(data) { update(data) {
return this.extractOrganizations(data); return this.extractOrganizations(data);
}, },
error(error) { error() {
createFlash({ this.error = true;
message: __('Something went wrong. Please try again.'),
error,
captureError: true,
});
}, },
}, },
}, },
...@@ -45,20 +49,38 @@ export default { ...@@ -45,20 +49,38 @@ export default {
const organizations = data?.group?.organizations?.nodes || []; const organizations = data?.group?.organizations?.nodes || [];
return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); 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: [ fields: [
{ key: 'name', sortable: true }, { key: 'name', sortable: true },
{ key: 'defaultRate', sortable: true }, { key: 'defaultRate', sortable: true },
{ key: 'description', sortable: true }, { key: 'description', sortable: true },
{
key: 'id',
label: __('Issues'),
formatter: (id) => {
return getIdFromGraphQLId(id);
},
},
], ],
i18n: { i18n: {
emptyText: s__('Crm|No organizations found'), emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'),
errorText: __('Something went wrong. Please try again.'),
}, },
}; };
</script> </script>
<template> <template>
<div> <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-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table <gl-table
v-else v-else
...@@ -66,6 +88,16 @@ export default { ...@@ -66,6 +88,16 @@ export default {
:fields="$options.fields" :fields="$options.fields"
:empty-text="$options.i18n.emptyText" :empty-text="$options.i18n.emptyText"
show-empty 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> </div>
</template> </template>
...@@ -16,10 +16,12 @@ export default () => { ...@@ -16,10 +16,12 @@ export default () => {
return false; return false;
} }
const { groupFullPath, groupIssuesPath } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
provide: { groupFullPath: el.dataset.groupFullPath }, provide: { groupFullPath, groupIssuesPath },
render(createElement) { render(createElement) {
return createElement(CrmOrganizationsRoot); return createElement(CrmOrganizationsRoot);
}, },
......
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
# attempt_group_search_optimizations: boolean # attempt_group_search_optimizations: boolean
# attempt_project_search_optimizations: boolean # attempt_project_search_optimizations: boolean
# crm_contact_id: integer # crm_contact_id: integer
# crm_organization_id: integer
# #
class IssuableFinder class IssuableFinder
prepend FinderWithCrossProjectAccess prepend FinderWithCrossProjectAccess
...@@ -61,6 +62,7 @@ class IssuableFinder ...@@ -61,6 +62,7 @@ class IssuableFinder
author_id author_id
author_username author_username
crm_contact_id crm_contact_id
crm_organization_id
label_name label_name
milestone_title milestone_title
release_tag release_tag
...@@ -141,7 +143,8 @@ class IssuableFinder ...@@ -141,7 +143,8 @@ class IssuableFinder
items = by_release(items) items = by_release(items)
items = by_label(items) items = by_label(items)
items = by_my_reaction_emoji(items) items = by_my_reaction_emoji(items)
by_crm_contact(items) items = by_crm_contact(items)
by_crm_organization(items)
end end
def should_filter_negated_args? def should_filter_negated_args?
...@@ -470,6 +473,10 @@ class IssuableFinder ...@@ -470,6 +473,10 @@ class IssuableFinder
Issuables::CrmContactFilter.new(params: original_params).filter(items) Issuables::CrmContactFilter.new(params: original_params).filter(items)
end end
def by_crm_organization(items)
Issuables::CrmOrganizationFilter.new(params: original_params).filter(items)
end
def or_filters_enabled? def or_filters_enabled?
strong_memoize(:or_filters_enabled) do strong_memoize(:or_filters_enabled) do
Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml) 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') - breadcrumb_title _('Customer Relations Organizations')
- page_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 ...@@ -920,7 +920,7 @@ RSpec.describe IssuesFinder do
let(:params) { { crm_contact_id: contact1.id } } 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_issue1, contact: contact1)
create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1) create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2) create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
...@@ -929,6 +929,26 @@ RSpec.describe IssuesFinder do ...@@ -929,6 +929,26 @@ RSpec.describe IssuesFinder do
end end
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 context 'when the user is unauthorized' do
let(:search_user) { nil } let(:search_user) { nil }
......
import { GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import OrganizationsRoot from '~/crm/components/organizations_root.vue'; import OrganizationsRoot from '~/crm/components/organizations_root.vue';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data'; import { getGroupOrganizationsQueryResponse } from './mock_data';
jest.mock('~/flash');
describe('Customer relations organizations root app', () => { describe('Customer relations organizations root app', () => {
Vue.use(VueApollo); Vue.use(VueApollo);
let wrapper; let wrapper;
...@@ -18,6 +15,8 @@ describe('Customer relations organizations root app', () => { ...@@ -18,6 +15,8 @@ describe('Customer relations organizations root app', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); 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 successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
const mountComponent = ({ const mountComponent = ({
...@@ -26,7 +25,7 @@ describe('Customer relations organizations root app', () => { ...@@ -26,7 +25,7 @@ describe('Customer relations organizations root app', () => {
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, { wrapper = mountFunction(OrganizationsRoot, {
provide: { groupFullPath: 'flightjs' }, provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
}); });
}; };
...@@ -46,7 +45,7 @@ describe('Customer relations organizations root app', () => { ...@@ -46,7 +45,7 @@ describe('Customer relations organizations root app', () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenCalled(); expect(findError().exists()).toBe(true);
}); });
it('renders correct results', async () => { it('renders correct results', async () => {
...@@ -56,5 +55,11 @@ describe('Customer relations organizations root app', () => { ...@@ -56,5 +55,11 @@ describe('Customer relations organizations root app', () => {
expect(findRowByName(/Test Inc/i)).toHaveLength(1); expect(findRowByName(/Test Inc/i)).toHaveLength(1);
expect(findRowByName(/VIP/i)).toHaveLength(1); expect(findRowByName(/VIP/i)).toHaveLength(1);
expect(findRowByName(/120/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