Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-12-03 06:10:53 +00:00
parent c657078ecb
commit b29d7709c1
13 changed files with 165 additions and 180 deletions

View file

@ -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>

View file

@ -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);
},

View file

@ -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');

View file

@ -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>

View file

@ -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)

View 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

View file

@ -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
}
}
}
}
}

View file

@ -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) } }

View file

@ -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.

View 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

View file

@ -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 }

View file

@ -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');
});
});

View file

@ -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', () => {