diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue
index 83c02f7d5fe..97220a3409d 100644
--- a/app/assets/javascripts/crm/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/components/contacts_root.vue
@@ -1,15 +1,20 @@
@@ -75,6 +88,16 @@ export default {
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
show-empty
- />
+ >
+
+
+
+
diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js
index 6438953596e..b0edd0107b6 100644
--- a/app/assets/javascripts/crm/contacts_bundle.js
+++ b/app/assets/javascripts/crm/contacts_bundle.js
@@ -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(CrmContactsRoot);
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 9f2870d8d69..01e8303f513 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -121,9 +121,6 @@ export default {
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
- createFlash({
- message: __('Something went wrong. Please try again.'),
- });
}
eventHub.$emit('MRWidgetRebaseSuccess');
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index f7485de0342..bf7fcc10b80 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -172,6 +172,13 @@ export default {
showDropdown() {
this.$refs.dropdown.show();
},
+ clearSearch() {
+ if (!this.allowMultiselect || this.isStandalone) {
+ return;
+ }
+ this.searchKey = '';
+ this.setFocus();
+ },
},
};
@@ -210,6 +217,7 @@ export default {
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
@hideCreateView="toggleDropdownContent"
+ @input="clearSearch"
/>
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 7b0cd17a761..dfced6d85a6 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -35,6 +35,7 @@
# updated_before: datetime
# attempt_group_search_optimizations: boolean
# attempt_project_search_optimizations: boolean
+# crm_contact_id: integer
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -59,6 +60,7 @@ class IssuableFinder
assignee_username
author_id
author_username
+ crm_contact_id
label_name
milestone_title
release_tag
@@ -138,7 +140,8 @@ class IssuableFinder
items = by_milestone(items)
items = by_release(items)
items = by_label(items)
- by_my_reaction_emoji(items)
+ items = by_my_reaction_emoji(items)
+ by_crm_contact(items)
end
def should_filter_negated_args?
@@ -463,6 +466,10 @@ class IssuableFinder
params[:non_archived].present? ? items.non_archived : items
end
+ def by_crm_contact(items)
+ Issuables::CrmContactFilter.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)
diff --git a/app/finders/issuables/crm_contact_filter.rb b/app/finders/issuables/crm_contact_filter.rb
new file mode 100644
index 00000000000..bea5f7d2bfa
--- /dev/null
+++ b/app/finders/issuables/crm_contact_filter.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Issuables
+ class CrmContactFilter < BaseFilter
+ def filter(issuables)
+ by_crm_contact(issuables)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_crm_contact(issuables)
+ return issuables if params[:crm_contact_id].blank?
+
+ condition = CustomerRelations::IssueContact
+ .where(contact_id: params[:crm_contact_id])
+ .where(Arel.sql("issue_id = issues.id"))
+ issuables.where(condition.arel.exists)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql
deleted file mode 100644
index c50a73aafb2..00000000000
--- a/app/graphql/queries/epic/epic_children.query.graphql
+++ /dev/null
@@ -1,160 +0,0 @@
-fragment PageInfo on PageInfo {
- hasNextPage
- hasPreviousPage
- startCursor
- endCursor
-}
-
-fragment RelatedTreeBaseEpic on Epic {
- id
- iid
- title
- webPath
- relativePosition
- userPermissions {
- __typename
- adminEpic
- createEpic
- }
- descendantWeightSum {
- closedIssues
- openedIssues
- }
- descendantCounts {
- __typename
- openedEpics
- closedEpics
- openedIssues
- closedIssues
- }
- healthStatus {
- __typename
- issuesAtRisk
- issuesOnTrack
- issuesNeedingAttention
- }
-}
-
-fragment EpicNode on Epic {
- ...RelatedTreeBaseEpic
- state
- reference(full: true)
- relationPath
- createdAt
- closedAt
- confidential
- hasChildren
- hasIssues
- labels {
- __typename
- nodes {
- __typename
- id
- color
- description
- textColor
- title
- }
- }
- group {
- __typename
- id
- fullPath
- }
-}
-
-query childItems(
- $fullPath: ID!
- $iid: ID
- $pageSize: Int = 100
- $epicEndCursor: String = ""
- $issueEndCursor: String = ""
-) {
- group(fullPath: $fullPath) {
- __typename
- id
- path
- fullPath
- epic(iid: $iid) {
- __typename
- ...RelatedTreeBaseEpic
- children(first: $pageSize, after: $epicEndCursor) {
- __typename
- edges {
- __typename
- # We have an id in deeply nested fragment
- # eslint-disable-next-line @graphql-eslint/require-id-when-available
- node {
- __typename
- ...EpicNode
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
- }
- issues(first: $pageSize, after: $issueEndCursor) {
- __typename
- edges {
- __typename
- node {
- __typename
- id
- iid
- epicIssueId
- title
- blocked
- closedAt
- state
- createdAt
- confidential
- dueDate
- weight
- webPath
- reference(full: true)
- relationPath
- relativePosition
- assignees {
- __typename
- edges {
- __typename
- node {
- __typename
- id
- webUrl
- name
- username
- avatarUrl
- }
- }
- }
- milestone {
- __typename
- id
- title
- startDate
- dueDate
- }
- healthStatus
- labels {
- __typename
- nodes {
- __typename
- id
- color
- description
- textColor
- title
- }
- }
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
- }
- }
- }
-}
diff --git a/app/views/groups/crm/contacts.html.haml b/app/views/groups/crm/contacts.html.haml
index c452a969d17..7d0ee5b64b1 100644
--- a/app/views/groups/crm/contacts.html.haml
+++ b/app/views/groups/crm/contacts.html.haml
@@ -1,4 +1,4 @@
- breadcrumb_title _('Customer Relations Contacts')
- page_title _('Customer Relations Contacts')
-#js-crm-contacts-app{ data: { group_full_path: @group.full_path } }
+#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group) } }
diff --git a/doc/administration/raketasks/storage.md b/doc/administration/raketasks/storage.md
index 017565e1b39..912cf260a03 100644
--- a/doc/administration/raketasks/storage.md
+++ b/doc/administration/raketasks/storage.md
@@ -13,7 +13,7 @@ uses to organize the Git data.
## List projects and attachments
-The following Rake tasks will list the projects and attachments that are
+The following Rake tasks lists the projects and attachments that are
available on legacy and hashed storage.
### On legacy storage
@@ -82,8 +82,8 @@ GitLab 14.0 eliminates support for legacy storage. If you're on GitLab
The option to choose between hashed and legacy storage in the admin area has
been disabled.
-This task must be run on any machine that has Rails/Sidekiq configured and will
-schedule all your existing projects and attachments associated with it to be
+This task must be run on any machine that has Rails/Sidekiq configured, and the task
+schedules all your existing projects and attachments associated with it to be
migrated to the **Hashed** storage type:
- **Omnibus installation**
@@ -112,7 +112,7 @@ To monitor the progress in GitLab:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Monitoring > Background Jobs**.
1. Watch how long the `hashed_storage:hashed_storage_project_migrate` queue
- will take to finish. After it reaches zero, you can confirm every project
+ takes to finish. After it reaches zero, you can confirm every project
has been migrated by running the commands above.
If you find it necessary, you can run the previous migration script again to schedule missing projects.
@@ -160,12 +160,12 @@ sudo gitlab-rake gitlab:storage:rollback_to_legacy ID_FROM=50 ID_TO=100
```
You can monitor the progress in the **Admin Area > Monitoring > Background Jobs** page.
-On the **Queues** tab, you can watch the `hashed_storage:hashed_storage_project_rollback` queue to see how long the process will take to finish.
+On the **Queues** tab, you can watch the `hashed_storage:hashed_storage_project_rollback` queue to see how long the process takes to finish.
After it reaches zero, you can confirm every project has been rolled back by running the commands above.
If some projects weren't rolled back, you can run this rollback script again to schedule further rollbacks.
Any error or warning is logged in Sidekiq's log file.
-If you have a Geo setup, the rollback will not be reflected automatically
+If you have a Geo setup, the rollback is not reflected automatically
on the **secondary** node. You may need to wait for a backfill operation to kick-in and remove
the remaining repositories from the special `@hashed/` folder manually.
diff --git a/spec/finders/issuables/crm_contact_filter_spec.rb b/spec/finders/issuables/crm_contact_filter_spec.rb
new file mode 100644
index 00000000000..d6eccab39ad
--- /dev/null
+++ b/spec/finders/issuables/crm_contact_filter_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issuables::CrmContactFilter do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ let_it_be(:contact1) { create(:contact, group: group) }
+ let_it_be(:contact2) { create(:contact, group: group) }
+
+ let_it_be(:contact1_issue1) { create(:issue, project: project) }
+ let_it_be(:contact1_issue2) { create(:issue, project: project) }
+ let_it_be(:contact2_issue1) { create(:issue, project: project) }
+ let_it_be(:issues) { Issue.where(id: [contact1_issue1.id, contact1_issue2.id, contact2_issue1.id]) }
+
+ before_all 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)
+ end
+
+ describe 'when a contact has issues' do
+ it 'returns all contact1 issues' do
+ params = { crm_contact_id: contact1.id }
+
+ expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact1_issue1, contact1_issue2)
+ end
+
+ it 'returns all contact2 issues' do
+ params = { crm_contact_id: contact2.id }
+
+ expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact2_issue1)
+ end
+ end
+
+ describe 'when a contact has no issues' do
+ it 'returns no issues' do
+ contact3 = create(:contact, group: group)
+ params = { crm_contact_id: contact3.id }
+
+ expect(described_class.new(params: params).filter(issues)).to be_empty
+ end
+ end
+end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 9f12308013e..680d70e1d66 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -910,6 +910,25 @@ RSpec.describe IssuesFinder do
end
end
+ context 'filtering by crm contact' do
+ let_it_be(:contact1) { create(:contact, group: group) }
+ let_it_be(:contact2) { create(:contact, group: group) }
+
+ 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_contact_id: contact1.id } }
+
+ it 'returns issues with that label' 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)
+ end
+ end
+
context 'when the user is unauthorized' do
let(:search_user) { nil }
diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
index 79b85969eb4..fec1e924da3 100644
--- a/spec/frontend/crm/contacts_root_spec.js
+++ b/spec/frontend/crm/contacts_root_spec.js
@@ -18,6 +18,7 @@ describe('Customer relations contacts root app', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
+ const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
const mountComponent = ({
@@ -26,7 +27,7 @@ describe('Customer relations contacts root app', () => {
} = {}) => {
fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]);
wrapper = mountFunction(ContactsRoot, {
- provide: { groupFullPath: 'flightjs' },
+ provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo,
});
};
@@ -56,5 +57,9 @@ describe('Customer relations contacts root app', () => {
expect(findRowByName(/Marty/i)).toHaveLength(1);
expect(findRowByName(/George/i)).toHaveLength(1);
expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1);
+
+ const issueLink = findIssuesLinks().at(0);
+ expect(issueLink.exists()).toBe(true);
+ expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16');
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
index 8bcef347c96..7b0acaa7ead 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -4,12 +4,12 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
-import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue';
import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue';
import { mockLabels } from './mock_data';
const showDropdown = jest.fn();
+const focusInput = jest.fn();
const GlDropdownStub = {
template: `
@@ -25,6 +25,15 @@ const GlDropdownStub = {
},
};
+const DropdownHeaderStub = {
+ template: `
+ Hello, I am a header
+ `,
+ methods: {
+ focusInput,
+ },
+};
+
describe('DropdownContent', () => {
let wrapper;
@@ -52,6 +61,7 @@ describe('DropdownContent', () => {
},
stubs: {
GlDropdown: GlDropdownStub,
+ DropdownHeader: DropdownHeaderStub,
},
});
};
@@ -62,7 +72,7 @@ describe('DropdownContent', () => {
const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView);
- const findDropdownHeader = () => wrapper.findComponent(DropdownHeader);
+ const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub);
const findDropdownFooter = () => wrapper.findComponent(DropdownFooter);
const findDropdown = () => wrapper.findComponent(GlDropdownStub);
@@ -135,11 +145,20 @@ describe('DropdownContent', () => {
it('sets searchKey for labels view on input event from header', async () => {
createComponent();
- expect(wrapper.vm.searchKey).toEqual('');
+ expect(findLabelsView().props('searchKey')).toBe('');
findDropdownHeader().vm.$emit('input', '123');
await nextTick();
- expect(findLabelsView().props('searchKey')).toEqual('123');
+ expect(findLabelsView().props('searchKey')).toBe('123');
+ });
+
+ it('clears and focuses search input on selecting a label', () => {
+ createComponent();
+ findDropdownHeader().vm.$emit('input', '123');
+ findLabelsView().vm.$emit('input', []);
+
+ expect(findLabelsView().props('searchKey')).toBe('');
+ expect(focusInput).toHaveBeenCalled();
});
describe('Create view', () => {