Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
c657078ecb
commit
b29d7709c1
13 changed files with 165 additions and 180 deletions
|
@ -1,15 +1,20 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
|
||||
import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
|
||||
import createFlash from '~/flash';
|
||||
import { s__, __ } from '~/locale';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
GlTable,
|
||||
},
|
||||
inject: ['groupFullPath'],
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
inject: ['groupFullPath', 'groupIssuesPath'],
|
||||
data() {
|
||||
return { contacts: [] };
|
||||
},
|
||||
|
@ -59,9 +64,17 @@ export default {
|
|||
},
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
label: __('Issues'),
|
||||
formatter: (id) => {
|
||||
return getIdFromGraphQLId(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
i18n: {
|
||||
emptyText: s__('Crm|No contacts found'),
|
||||
issuesButtonLabel: __('View issues'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -75,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="`${groupIssuesPath}?scope=all&state=opened&crm_contact_id=${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(CrmContactsRoot);
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -172,6 +172,13 @@ export default {
|
|||
showDropdown() {
|
||||
this.$refs.dropdown.show();
|
||||
},
|
||||
clearSearch() {
|
||||
if (!this.allowMultiselect || this.isStandalone) {
|
||||
return;
|
||||
}
|
||||
this.searchKey = '';
|
||||
this.setFocus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -210,6 +217,7 @@ export default {
|
|||
:attr-workspace-path="attrWorkspacePath"
|
||||
:label-create-type="labelCreateType"
|
||||
@hideCreateView="toggleDropdownContent"
|
||||
@input="clearSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
|
|
@ -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)
|
||||
|
|
20
app/finders/issuables/crm_contact_filter.rb
Normal file
20
app/finders/issuables/crm_contact_filter.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) } }
|
||||
|
|
|
@ -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.
|
||||
|
|
45
spec/finders/issuables/crm_contact_filter_spec.rb
Normal file
45
spec/finders/issuables/crm_contact_filter_spec.rb
Normal file
|
@ -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
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: `
|
||||
<div>Hello, I am a header</div>
|
||||
`,
|
||||
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', () => {
|
||||
|
|
Loading…
Reference in a new issue