Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-08-01 15:09:51 +00:00
parent 1e73f4d9e2
commit e3eb4d55ef
68 changed files with 1816 additions and 263 deletions

View File

@ -289,10 +289,6 @@
- "tooling/docs/**/*"
- "lib/tasks/gitlab/docs/compile_deprecations.rake"
.bundler-patterns: &bundler-patterns
- '{Gemfile.lock,*/Gemfile.lock,*/*/Gemfile.lock}'
- '{Gemfile.next.lock,*/Gemfile.next.lock,*/*/Gemfile.next.lock}'
.nodejs-patterns: &nodejs-patterns
- '{package.json,*/package.json,*/*/package.json}'
- '{yarn.lock,*/yarn.lock,*/*/yarn.lock}'
@ -463,12 +459,6 @@
.frontend-predictive-patterns: &frontend-predictive-patterns
- "{,ee/,jh/}{app/assets/javascripts,spec/frontend}/**/*"
# Frontend view patterns + .qa-patterns
.frontend-qa-patterns: &frontend-qa-patterns
- "{,ee/,jh/}{app/assets,app/components,app/helpers,app/presenters,app/views}/**/*"
# QA changes
- "{,jh/}qa/**/*"
# Code patterns + .ci-patterns
.code-patterns: &code-patterns
- ".{eslintrc.yml,eslintignore,gitattributes,nvmrc,prettierrc,stylelintrc,yamllint}"
@ -679,13 +669,6 @@
- "{,jh/}Gemfile.next{,.lock}"
- "{,ee/,jh/}config/**/*.rb"
.core-frontend-patterns: &core-frontend-patterns
- "{package.json,yarn.lock}"
- "babel.config.js"
- "jest.config.{base,integration,unit}.js"
- "config/helpers/**/*.js"
- "vendor/assets/javascripts/**/*"
.feature-flag-development-config-patterns: &feature-flag-development-config-patterns
- "{,ee/,jh/}config/feature_flags/**/*.yml"

View File

@ -2150,7 +2150,6 @@ Layout/LineLength:
- 'ee/spec/workers/ci/minutes/update_project_and_namespace_usage_worker_spec.rb'
- 'ee/spec/workers/ci/upstream_projects_subscriptions_cleanup_worker_spec.rb'
- 'ee/spec/workers/compliance_management/merge_requests/compliance_violations_worker_spec.rb'
- 'ee/spec/workers/concerns/elastic/indexing_control_spec.rb'
- 'ee/spec/workers/elastic/migration_worker_spec.rb'
- 'ee/spec/workers/geo/destroy_worker_spec.rb'
- 'ee/spec/workers/geo/prune_event_log_worker_spec.rb'

View File

@ -73,7 +73,6 @@ RSpec/ExpectInHook:
- 'ee/spec/tasks/gitlab/license_rake_spec.rb'
- 'ee/spec/tasks/gitlab/spdx_rake_spec.rb'
- 'ee/spec/workers/analytics/cycle_analytics/consistency_worker_spec.rb'
- 'ee/spec/workers/concerns/elastic/indexing_control_spec.rb'
- 'ee/spec/workers/elastic_index_bulk_cron_worker_spec.rb'
- 'ee/spec/workers/elastic_indexing_control_worker_spec.rb'
- 'ee/spec/workers/geo/secondary/registry_consistency_worker_spec.rb'

View File

@ -3287,7 +3287,6 @@ RSpec/NamedSubject:
- 'spec/services/projects/operations/update_service_spec.rb'
- 'spec/services/projects/overwrite_project_service_spec.rb'
- 'spec/services/projects/prometheus/alerts/notify_service_spec.rb'
- 'spec/services/projects/prometheus/metrics/destroy_service_spec.rb'
- 'spec/services/projects/readme_renderer_service_spec.rb'
- 'spec/services/projects/transfer_service_spec.rb'
- 'spec/services/projects/unlink_fork_service_spec.rb'

View File

@ -0,0 +1,27 @@
import BroadcastMessagesBase from '~/admin/broadcast_messages/components/base.vue';
import { generateMockMessages } from '../../../../../../spec/frontend/admin/broadcast_messages/mock_data';
export default {
title: 'admin/broadcast_messages/base',
component: BroadcastMessagesBase,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { BroadcastMessagesBase },
template: '<broadcast-messages-base v-bind="$props" />',
});
export const Default = Template.bind({});
Default.args = {
page: 1,
messagesCount: 5,
messages: generateMockMessages(5),
};
export const Empty = Template.bind({});
Empty.args = {
page: 1,
messagesCount: 0,
messages: [],
};

View File

@ -16,6 +16,7 @@ import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutatio
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import { getQueryHeaders } from '../graph/utils';
import { POLL_INTERVAL } from '../graph/constants';
import { MERGE_TRAIN_EVENT_TYPE } from './constants';
import HeaderActions from './components/header_actions.vue';
import HeaderBadges from './components/header_badges.vue';
import getPipelineQuery from './graphql/queries/get_pipeline_header_data.query.graphql';
@ -40,6 +41,8 @@ export default {
TimeAgoTooltip,
PipelineAccountVerificationAlert: () =>
import('ee_component/vue_shared/components/pipeline_account_verification_alert.vue'),
HeaderMergeTrainsLink: () =>
import('ee_component/ci/pipeline_details/header/components/header_merge_trains_link.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@ -219,6 +222,9 @@ export default {
refText() {
return this.pipeline?.refText;
},
isMergeTrainPipeline() {
return this.pipeline.mergeRequestEventType === MERGE_TRAIN_EVENT_TYPE;
},
},
methods: {
reportFailure(errorType, errorMessages = []) {
@ -406,6 +412,9 @@ export default {
</span>
</div>
</div>
<div v-if="isMergeTrainPipeline" class="gl-mt-2">
<header-merge-trains-link />
</div>
</div>
<header-actions

View File

@ -18,6 +18,9 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
pipelinesPath,
identityVerificationPath,
identityVerificationRequired,
mergeTrainsAvailable,
canReadMergeTrain,
mergeTrainsPath,
} = el.dataset;
// eslint-disable-next-line no-new
@ -34,6 +37,9 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
pipelineIid,
identityVerificationPath,
identityVerificationRequired: parseBoolean(identityVerificationRequired),
mergeTrainsAvailable: parseBoolean(mergeTrainsAvailable),
canReadMergeTrain: parseBoolean(canReadMergeTrain),
mergeTrainsPath,
},
render(createElement) {
return createElement(PipelineHeader);

View File

@ -2,31 +2,10 @@ import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
import { VARIANT_WARNING } from '~/alert';
import { __ } from '~/locale';
import { ALERT_EVENT } from '../constants';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serializer/table';
let alertShown = false;
const onUpdate = debounce((editor) => {
if (alertShown) return;
editor.state.doc.descendants((node) => {
if (node.type.name === 'table' && node.attrs.isMarkdown && shouldRenderHTMLTable(node)) {
editor.emit('alert', {
message: __(
'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.',
),
variant: VARIANT_WARNING,
});
alertShown = true;
return false;
}
return true;
});
}, 1000);
export default Table.extend({
addAttributes() {
return {
@ -37,7 +16,24 @@ export default Table.extend({
};
},
onUpdate({ editor }) {
onUpdate(editor);
},
onUpdate: debounce(function onUpdate({ editor }) {
if (this.options.alertShown) return;
editor.state.doc.descendants((node) => {
if (node.type.name === 'table' && node.attrs.isMarkdown && shouldRenderHTMLTable(node)) {
this.options.eventHub.$emit(ALERT_EVENT, {
message: __(
'Tables containing block elements (like multiple paragraphs, lists or blockquotes) are not supported in Markdown and will be converted to HTML.',
),
variant: VARIANT_WARNING,
});
this.options.alertShown = true;
return false;
}
return true;
});
}, 1000),
});

View File

@ -11,6 +11,7 @@ import {
UNAVAILABLE_ADMIN_FEATURE_TEXT,
} from '~/packages_and_registries/settings/project/constants';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
import SettingsSection from '~/vue_shared/components/settings/settings_section.vue';
import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
@ -20,6 +21,7 @@ export default {
GlSprintf,
GlLink,
ContainerExpirationPolicyForm,
SettingsSection,
},
inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
i18n: {
@ -77,17 +79,18 @@ export default {
</script>
<template>
<div data-testid="container-expiration-policy-project-settings">
<h3 data-testid="title" class="gl-heading-3 gl-mt-3!">
{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}
</h3>
<p data-testid="description">
<settings-section
:heading="$options.i18n.CONTAINER_CLEANUP_POLICY_TITLE"
data-testid="container-expiration-policy-project-settings"
class="!gl-pt-5"
>
<template #description>
<gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION">
<template #link="{ content }">
<gl-link :href="helpPagePath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</template>
<container-expiration-policy-form
v-if="isEnabled"
v-model="workingCopy"
@ -113,5 +116,5 @@ export default {
<gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
</gl-alert>
</template>
</div>
</settings-section>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlAlert, GlCard, GlButton, GlSprintf } from '@gitlab/ui';
import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { objectToQuery, visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import {
@ -19,6 +19,7 @@ import {
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
} from '~/packages_and_registries/settings/project/constants';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql';
import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update';
import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
@ -31,13 +32,13 @@ import ExpirationToggle from './expiration_toggle.vue';
export default {
components: {
GlAlert,
GlCard,
GlButton,
GlSprintf,
ExpirationDropdown,
ExpirationInput,
ExpirationToggle,
ExpirationRunText,
CrudComponent,
},
mixins: [Tracking.mixin()],
inject: ['projectPath', 'projectSettingsPath'],
@ -202,7 +203,7 @@ export default {
@input="onModelChange($event, 'enabled')"
/>
<div class="gl-display-flex gl-mt-7">
<div class="gl-flex gl-mt-5">
<expiration-dropdown
:value="prefilledForm.cadence"
:disabled="isFieldDisabled"
@ -219,86 +220,74 @@ export default {
class="gl-mb-0!"
/>
</div>
<gl-alert class="gl-mt-7" :dismissible="false">
<gl-alert class="gl-mt-5" :dismissible="false">
<gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_REGEX_NOTE">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</gl-alert>
<gl-card class="gl-mt-4">
<template #header>
{{ $options.i18n.KEEP_HEADER_TEXT }}
</template>
<template #default>
<div>
<p>
<gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
:value="prefilledForm.keepN"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.keepN"
:label="$options.i18n.KEEP_N_LABEL"
name="keep-n"
data-testid="keep-n-dropdown"
@input="onModelChange($event, 'keepN')"
/>
<expiration-input
v-model="prefilledForm.nameRegexKeep"
:error="apiErrors.nameRegexKeep"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_KEEP_LABEL"
:description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION"
name="keep-regex"
data-testid="keep-regex-input"
@input="onModelChange($event, 'nameRegexKeep')"
@validation="setLocalErrors($event, 'nameRegexKeep')"
/>
</div>
</template>
</gl-card>
<gl-card class="gl-mt-7">
<template #header>
{{ $options.i18n.REMOVE_HEADER_TEXT }}
</template>
<template #default>
<div>
<p>
<gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
:value="prefilledForm.olderThan"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.olderThan"
:label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
name="older-than"
data-testid="older-than-dropdown"
@input="onModelChange($event, 'olderThan')"
/>
<expiration-input
v-model="prefilledForm.nameRegex"
:error="apiErrors.nameRegex"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_LABEL"
:description="$options.i18n.NAME_REGEX_DESCRIPTION"
name="remove-regex"
data-testid="remove-regex-input"
@input="onModelChange($event, 'nameRegex')"
@validation="setLocalErrors($event, 'nameRegex')"
/>
</div>
</template>
</gl-card>
<div class="gl-mt-7 gl-display-flex gl-align-items-center">
<crud-component :title="$options.i18n.KEEP_HEADER_TEXT" class="gl-mt-5">
<p>
<gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
:value="prefilledForm.keepN"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.keepN"
:label="$options.i18n.KEEP_N_LABEL"
name="keep-n"
data-testid="keep-n-dropdown"
@input="onModelChange($event, 'keepN')"
/>
<expiration-input
v-model="prefilledForm.nameRegexKeep"
:error="apiErrors.nameRegexKeep"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_KEEP_LABEL"
:description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION"
name="keep-regex"
data-testid="keep-regex-input"
@input="onModelChange($event, 'nameRegexKeep')"
@validation="setLocalErrors($event, 'nameRegexKeep')"
/>
</crud-component>
<crud-component :title="$options.i18n.REMOVE_HEADER_TEXT" class="gl-mt-5">
<div>
<p>
<gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
:value="prefilledForm.olderThan"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.olderThan"
:label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
name="older-than"
data-testid="older-than-dropdown"
@input="onModelChange($event, 'olderThan')"
/>
<expiration-input
v-model="prefilledForm.nameRegex"
:error="apiErrors.nameRegex"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_LABEL"
:description="$options.i18n.NAME_REGEX_DESCRIPTION"
name="remove-regex"
data-testid="remove-regex-input"
@input="onModelChange($event, 'nameRegex')"
@validation="setLocalErrors($event, 'nameRegex')"
/>
</div>
</crud-component>
<div class="settings-sticky-footer gl-mt-5 gl-flex gl-items-center">
<gl-button
data-testid="save-button"
type="submit"

View File

@ -45,21 +45,19 @@ export default {
<template>
<gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle">
<div class="gl-display-flex">
<gl-toggle
id="expiration-policy-toggle"
v-model="enabled"
:label="$options.i18n.toggleLabel"
label-position="hidden"
:disabled="disabled"
/>
<span class="gl-ml-5 gl-leading-24" data-testid="description">
<gl-sprintf :message="toggleText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</span>
<gl-toggle
id="expiration-policy-toggle"
v-model="enabled"
:label="$options.i18n.toggleLabel"
label-position="left"
:disabled="disabled"
/>
<div class="gl-text-subtle gl-mt-2" data-testid="description">
<gl-sprintf :message="toggleText">
<template #strong="{ content }">
{{ content }}
</template>
</gl-sprintf>
</div>
</gl-form-group>
</template>

View File

@ -48,10 +48,10 @@ export const NAME_REGEX_DESCRIPTION = s__(
);
export const ENABLED_TOGGLE_DESCRIPTION = s__(
'ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion.',
'ContainerRegistry|Enabled - tags that match the rules on this page are automatically scheduled for deletion.',
);
export const DISABLED_TOGGLE_DESCRIPTION = s__(
'ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted.',
'ContainerRegistry|Disabled - tags will not be automatically deleted.',
);
export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:');

View File

@ -30,6 +30,7 @@ import {
NEW_WORK_ITEM_GID,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_ROLLEDUP_DATES,
WIDGET_TYPE_CRM_CONTACTS,
} from '../constants';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import namespaceWorkItemTypesQuery from '../graphql/namespace_work_item_types.query.graphql';
@ -41,6 +42,7 @@ import WorkItemDescription from './work_item_description.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemLoading from './work_item_loading.vue';
import WorkItemCrmContacts from './work_item_crm_contacts.vue';
export default {
components: {
@ -55,6 +57,7 @@ export default {
WorkItemAssignees,
WorkItemLabels,
WorkItemLoading,
WorkItemCrmContacts,
WorkItemHealthStatus: () =>
import('ee_component/work_items/components/work_item_health_status.vue'),
WorkItemColor: () => import('ee_component/work_items/components/work_item_color.vue'),
@ -165,6 +168,9 @@ export default {
workItemColor() {
return findWidget(WIDGET_TYPE_COLOR, this.workItem);
},
workItemCrmContacts() {
return findWidget(WIDGET_TYPE_CRM_CONTACTS, this.workItem);
},
workItemTypesForSelect() {
return this.workItemTypes
? this.workItemTypes.map((node) => ({
@ -219,6 +225,9 @@ export default {
const labelsWidget = findWidget(WIDGET_TYPE_LABELS, this.workItem);
return labelsWidget?.labels?.nodes?.map((label) => label.id) || [];
},
workItemCrmContactIds() {
return this.workItemCrmContacts?.contacts?.nodes?.map((item) => item.id) || [];
},
workItemColorValue() {
const colorWidget = findWidget(WIDGET_TYPE_COLOR, this.workItem);
return colorWidget?.color || '';
@ -338,6 +347,12 @@ export default {
};
}
if (this.isWidgetSupported(WIDGET_TYPE_CRM_CONTACTS)) {
workItemCreateInput.crmContactsWidget = {
contactIds: this.workItemCrmContactIds,
};
}
try {
const response = await this.$apollo.mutate({
mutation: createWorkItemMutation,
@ -464,6 +479,16 @@ export default {
@error="$emit('error', $event)"
/>
</template>
<template v-if="workItemCrmContacts">
<work-item-crm-contacts
class="gl-mb-5"
:full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
:work-item-type="selectedWorkItemTypeName"
@error="$emit('error', $event)"
/>
</template>
<template v-if="workItemLabels">
<work-item-labels
class="gl-mb-5 js-labels"

View File

@ -5,6 +5,7 @@ import { ASC } from '~/notes/constants';
import { __ } from '~/locale';
import { clearDraft } from '~/lib/utils/autosave';
import DiscussionReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
@ -21,6 +22,7 @@ export default {
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
ResolveDiscussionButton,
},
mixins: [Tracking.mixin()],
props: {
@ -89,6 +91,26 @@ export default {
required: false,
default: false,
},
isDiscussionResolved: {
type: Boolean,
required: false,
default: false,
},
isDiscussionResolvable: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
hasReplies: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -174,6 +196,9 @@ export default {
'internal-note': this.isInternalThread,
};
},
resolveDiscussionTitle() {
return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
},
},
watch: {
autofocus: {
@ -283,8 +308,12 @@ export default {
:comment-button-text="commentButtonText"
:is-discussion-locked="isDiscussionLocked"
:is-work-item-confidential="isWorkItemConfidential"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:full-path="fullPath"
:has-replies="hasReplies"
:work-item-iid="workItemIid"
@toggleResolveDiscussion="$emit('resolve')"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
@error="$emit('error', $event)"
@ -294,6 +323,16 @@ export default {
data-testid="note-reply-textarea"
@focus="showReplyForm"
/>
<div v-if="!isNewDiscussion && !isEditing" class="discussion-actions">
<resolve-discussion-button
v-if="isDiscussionResolvable"
data-testid="resolve-discussion-button"
:is-resolving="isResolving"
:button-title="resolveDiscussionTitle"
@onClick="$emit('resolve')"
/>
</div>
</div>
</div>
</div>

View File

@ -121,12 +121,28 @@ export default {
required: false,
default: null,
},
isDiscussionResolved: {
type: Boolean,
required: false,
default: false,
},
isDiscussionResolvable: {
type: Boolean,
required: false,
default: false,
},
hasReplies: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
updateInProgress: false,
isNoteInternal: false,
toggleResolveChecked: this.isDiscussionResolved,
};
},
computed: {
@ -162,6 +178,12 @@ export default {
workItemTypeKey() {
return capitalizeFirstCharacter(this.workItemType).replace(' ', '');
},
showResolveDiscussionToggle() {
return !this.isNewDiscussion && this.isDiscussionResolvable && this.hasReplies;
},
resolveCheckboxLabel() {
return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
},
},
methods: {
setCommentText(newText) {
@ -194,6 +216,15 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
submitForm() {
if (this.toggleResolveChecked) {
this.$emit('toggleResolveDiscussion');
}
this.$emit('submitForm', {
commentText: this.commentText,
isNoteInternal: this.isNoteInternal,
});
},
},
};
</script>
@ -218,12 +249,22 @@ export default {
supports-quick-actions
:autofocus="autofocus"
@input="setCommentText"
@keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })"
@keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keydown.esc.stop="cancelEditing"
/>
</comment-field-layout>
<div class="note-form-actions" data-testid="work-item-comment-form-actions">
<div v-if="showResolveDiscussionToggle">
<label>
<gl-form-checkbox
v-model="toggleResolveChecked"
data-testid="toggle-resolve-checkbox"
>
{{ resolveCheckboxLabel }}
</gl-form-checkbox>
</label>
</div>
<gl-form-checkbox
v-if="isNewDiscussion"
v-model="isNoteInternal"
@ -245,7 +286,7 @@ export default {
data-testid="confirm-button"
:disabled="!commentText.length"
:loading="isSubmitting"
@click="$emit('submitForm', { commentText, isNoteInternal })"
@click="submitForm"
>{{ commentButtonTextComputed }}
</gl-button>
<work-item-state-toggle
@ -258,7 +299,7 @@ export default {
:full-path="fullPath"
:has-comment="Boolean(commentText.length)"
can-update
@submit-comment="$emit('submitForm', { commentText, isNoteInternal })"
@submit-comment="submitForm"
@error="$emit('error', $event)"
/>
<gl-button

View File

@ -2,6 +2,7 @@
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ASC } from '~/notes/constants';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import toggleWorkItemNoteResolveDiscussion from '~/work_items/graphql/notes/toggle_work_item_note_resolve_discussion.mutation.graphql';
import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
@ -77,14 +78,20 @@ export default {
required: false,
default: false,
},
isExpandedOnLoad: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isExpanded: true,
isExpanded: this.isExpandedOnLoad,
autofocus: false,
isReplying: false,
replyingText: '',
showForm: false,
isResolving: false,
};
},
computed: {
@ -104,7 +111,7 @@ export default {
return null;
},
discussionId() {
return this.discussion[0]?.discussion?.id || '';
return this.firstComment?.id || '';
},
shouldShowReplyForm() {
return this.showForm || this.hasReplies;
@ -112,6 +119,25 @@ export default {
isOnlyCommentOfAThread() {
return !this.hasReplies && !this.showForm;
},
firstComment() {
return this.discussion[0]?.discussion;
},
isDiscussionResolved() {
return this.firstComment?.resolved;
},
isDiscussionResolvable() {
return this.firstComment?.resolvable;
},
},
watch: {
discussion: {
handler(newDiscussion) {
if (newDiscussion[0].discussion.resolved === false) {
this.isExpanded = true;
}
},
deep: true,
},
},
methods: {
showReplyForm() {
@ -139,6 +165,52 @@ export default {
this.isReplying = true;
this.replyingText = commentText;
},
getToggledDiscussion(resolved) {
let resolvedBy = null;
if (resolved) {
resolvedBy = {
id: gon?.current_user_id,
name: gon?.current_user_fullname,
__typename: 'UserCore',
};
}
const toggledDiscussionNotes = [...this.discussion].map((note) => {
return {
...note,
discussion: {
...note.discussion,
resolved,
resolvedBy,
},
};
});
return {
id: this.discussionId,
notes: {
nodes: [...toggledDiscussionNotes],
},
};
},
async resolveDiscussion() {
this.isResolving = true;
try {
await this.$apollo.mutate({
mutation: toggleWorkItemNoteResolveDiscussion,
variables: { id: this.discussionId, resolve: !this.isDiscussionResolved },
optimisticResponse: {
discussionToggleResolve: {
errors: [],
discussion: this.getToggledDiscussion(!this.isDiscussionResolved),
__typename: 'DiscussionToggleResolvePayload',
},
},
});
} catch (error) {
this.$emit('error', error.message);
} finally {
this.isResolving = false;
}
},
},
};
</script>
@ -158,8 +230,12 @@ export default {
:class="{ 'gl-mb-4': hasReplies }"
:assignees="assignees"
:can-set-work-item-metadata="canSetWorkItemMetadata"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:is-resolving="isResolving"
@resolve="resolveDiscussion"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@reportAbuse="$emit('reportAbuse', note)"
@ -187,9 +263,13 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:can-set-work-item-metadata="canSetWorkItemMetadata"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:is-resolving="isResolving"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@reportAbuse="$emit('reportAbuse', note)"
@resolve="resolveDiscussion"
@error="$emit('error', $event)"
/>
<discussion-notes-replies-wrapper>
@ -214,6 +294,9 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:can-set-work-item-metadata="canSetWorkItemMetadata"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:is-resolving="isResolving"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', reply)"
@reportAbuse="$emit('reportAbuse', reply)"
@ -241,10 +324,15 @@ export default {
:is-discussion-locked="isDiscussionLocked"
:is-internal-thread="note.internal"
:is-work-item-confidential="isWorkItemConfidential"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:is-resolving="isResolving"
:has-replies="hasReplies"
@startReplying="showReplyForm"
@cancelEditing="hideReplyForm"
@replied="onReplied"
@replying="onReplying"
@resolve="resolveDiscussion"
@error="$emit('error', $event)"
/>
</template>

View File

@ -91,6 +91,21 @@ export default {
required: false,
default: false,
},
isDiscussionResolved: {
type: Boolean,
required: false,
default: false,
},
isDiscussionResolvable: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -126,6 +141,9 @@ export default {
showReply() {
return this.note.userPermissions.createNote && this.isFirstNote;
},
canResolve() {
return this.note.userPermissions.resolveNote && this.isFirstNote && this.hasReplies;
},
noteHeaderClass() {
return {
'note-header': true,
@ -174,6 +192,9 @@ export default {
isWorkItemConfidential() {
return this.workItem.confidential;
},
discussionResolvedBy() {
return this.note.discussion.resolvedBy;
},
},
apollo: {
workItem: {
@ -326,9 +347,13 @@ export default {
:work-item-id="workItemId"
:autofocus="isEditing"
:is-work-item-confidential="isWorkItemConfidential"
:is-discussion-resolved="isDiscussionResolved"
:is-discussion-resolvable="isDiscussionResolvable"
:has-replies="hasReplies"
:full-path="fullPath"
class="gl-pl-3 gl-mt-3"
@cancelEditing="isEditing = false"
@toggleResolveDiscussion="$emit('resolve')"
@submitForm="updateNote"
/>
<div v-else data-testid="note-wrapper">
@ -360,8 +385,14 @@ export default {
:is-author-contributor="note.authorIsContributor"
:max-access-level-of-author="note.maxAccessLevelOfAuthor"
:project-name="projectName"
:can-resolve="canResolve"
:resolvable="isDiscussionResolvable"
:is-resolved="isDiscussionResolved"
:is-resolving="isResolving"
:resolved-by="discussionResolvedBy"
@startReplying="showReplyForm"
@startEditing="startEditing"
@resolve="$emit('resolve')"
@error="($event) => $emit('error', $event)"
@notifyCopyDone="notifyCopyDone"
@deleteNote="$emit('deleteNote')"

View File

@ -21,6 +21,7 @@ export default {
assignUserText: __('Assign to commenting user'),
unassignUserText: __('Unassign from commenting user'),
reportAbuseText: __('Report abuse'),
resolveThreadTitle: __('Resolve thread'),
},
components: {
EmojiPicker: () => import('~/emoji/components/picker.vue'),
@ -108,6 +109,31 @@ export default {
required: false,
default: '',
},
canResolve: {
type: Boolean,
required: false,
default: false,
},
resolvable: {
type: Boolean,
required: false,
default: false,
},
isResolved: {
type: Boolean,
required: false,
default: false,
},
isResolving: {
type: Boolean,
required: false,
default: false,
},
resolvedBy: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
assignUserActionText() {
@ -131,8 +157,21 @@ export default {
name: this.projectName,
});
},
resolveIcon() {
if (!this.isResolving) {
return this.isResolved ? 'check-circle-filled' : 'check-circle';
}
return null;
},
resolveVariant() {
return this.isResolved ? 'success' : 'default';
},
resolveThreadTitle() {
return this.isResolved
? __('Resolved by ') + this.resolvedBy.name
: this.$options.i18n.resolveThreadTitle;
},
},
methods: {
async setAwardEmoji(name) {
const { mutation, mutationName, errorMessage } = getMutation({ note: this.note, name });
@ -199,6 +238,20 @@ export default {
>
{{ __('Contributor') }}
</user-access-role-badge>
<gl-button
v-if="canResolve"
ref="resolveButton"
v-gl-tooltip.hover
category="tertiary"
:variant="resolveVariant"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveThreadTitle"
:aria-label="resolveThreadTitle"
:icon="resolveIcon"
:loading="isResolving"
class="line-resolve-btn note-action-button"
@click="$emit('resolve')"
/>
<emoji-picker
v-if="showAwardEmoji"
toggle-class="add-reaction-button btn-default-tertiary"

View File

@ -17,6 +17,7 @@ import {
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_COLOR,
WIDGET_TYPE_DEVELOPMENT,
WIDGET_TYPE_CRM_CONTACTS,
WORK_ITEM_TYPE_VALUE_EPIC,
} from '../constants';
import WorkItemAssignees from './work_item_assignees.vue';
@ -26,6 +27,7 @@ import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemParent from './work_item_parent.vue';
import WorkItemTimeTracking from './work_item_time_tracking.vue';
import WorkItemDevelopment from './work_item_development/work_item_development.vue';
import WorkItemCrmContacts from './work_item_crm_contacts.vue';
export default {
ListType,
@ -38,6 +40,7 @@ export default {
WorkItemParent,
WorkItemTimeTracking,
WorkItemDevelopment,
WorkItemCrmContacts,
WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
@ -139,6 +142,9 @@ export default {
hasParent() {
return this.workItemHierarchy?.hasParent;
},
workItemCrmContacts() {
return this.isWidgetPresent(WIDGET_TYPE_CRM_CONTACTS) && this.glFeatures.workItemsAlpha;
},
},
methods: {
isWidgetPresent(type) {
@ -168,6 +174,15 @@ export default {
"
/>
</template>
<template v-if="workItemCrmContacts">
<work-item-crm-contacts
class="gl-mb-5"
:full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
:work-item-type="workItemType"
/>
</template>
<template v-if="workItemLabels">
<work-item-labels
class="gl-mb-5 js-labels"

View File

@ -0,0 +1,313 @@
<script>
import { GlIcon, GlLink, GlPopover } from '@gitlab/ui';
import { difference, groupBy, xor } from 'lodash';
import { findWidget } from '~/issues/list/utils';
import { __, n__, s__ } from '~/locale';
import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue';
import Tracking from '~/tracking';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql';
import {
i18n,
TRACKING_CATEGORY_SHOW,
I18N_WORK_ITEM_ERROR_FETCHING_CRM_CONTACTS,
WIDGET_TYPE_CRM_CONTACTS,
} from '../constants';
import { newWorkItemFullPath, newWorkItemId } from '../utils';
export default {
components: {
GlIcon,
GlLink,
GlPopover,
WorkItemSidebarDropdownWidget,
},
mixins: [Tracking.mixin()],
props: {
fullPath: {
type: String,
required: true,
},
workItemId: {
type: String,
required: true,
},
workItemIid: {
type: String,
required: true,
},
workItemType: {
type: String,
required: true,
},
},
data() {
return {
searchTerm: '',
searchStarted: false,
updateInProgress: false,
};
},
computed: {
createFlow() {
return this.workItemId === newWorkItemId(this.workItemType);
},
selectedItems() {
return this.workItemCrmContacts?.contacts.nodes || [];
},
isLoading() {
return this.$apollo.queries.searchItems.loading;
},
selectedItemIds() {
return this.selectedItems.map(({ id }) => id);
},
listItems() {
const contacts = this.searchItems || [];
const organizations = this.groupByOrganization(contacts, true);
return organizations.map(([key, values]) => {
return {
text: key,
options: values.map((contact) => {
return { value: contact.id, text: `${contact.firstName} ${contact.lastName}` };
}),
};
});
},
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
label: 'item_contact',
property: `type_${this.workItemType}`,
};
},
selectedOrganizations() {
return this.groupByOrganization(this.selectedItems, false);
},
workItemCrmContacts() {
return findWidget(WIDGET_TYPE_CRM_CONTACTS, this.workItem);
},
workItemFullPath() {
return this.createFlow
? newWorkItemFullPath(this.fullPath, this.workItemType)
: this.fullPath;
},
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
dropdownLabelText() {
return n__('%d contact', '%d contacts', this.selectedItemIds.length);
},
},
apollo: {
searchItems: {
query: getGroupContactsQuery,
variables() {
return {
groupFullPath: this.fullPath.split('/')[0],
searchTerm: this.searchTerm,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
return data.group?.contacts?.nodes;
},
error() {
this.$emit('error', I18N_WORK_ITEM_ERROR_FETCHING_CRM_CONTACTS);
},
},
workItem: {
query: workItemByIidQuery,
variables() {
return {
fullPath: this.workItemFullPath,
iid: this.workItemIid,
};
},
update(data) {
return data?.workspace?.workItem ?? {};
},
skip() {
return !this.workItemIid;
},
},
},
methods: {
groupByOrganization(unsortedContacts, separateSelectedContacts) {
// Sort the contacts first
const contacts = [...unsortedContacts].sort((a, b) => {
if (a.firstName !== b.firstName) {
return a.firstName.localeCompare(b.firstName);
}
return a.lastName.localeCompare(b.lastName);
});
let groups = [];
let remainingContacts;
// Group the selected contacts first
if (separateSelectedContacts && this.selectedItems.length) {
remainingContacts = contacts.filter(
(contact) => !this.selectedItemIds.includes(contact.id),
);
groups.push([
__('Selected'),
contacts.filter((contact) => this.selectedItemIds.includes(contact.id)),
]);
} else {
remainingContacts = contacts;
}
const orphanContacts = remainingContacts.filter(({ organization }) => !organization);
// Display each organization and their contacts next
remainingContacts = remainingContacts.filter(({ organization }) => organization);
const organizationGroups = Object.entries(groupBy(remainingContacts, 'organization.name'));
organizationGroups.sort((a, b) => a[0].localeCompare(b[0]));
groups = groups.concat(organizationGroups);
// Then finally display contacts without an organization
if (orphanContacts.length) {
groups.push([s__('Crm|No organization'), orphanContacts]);
}
return groups;
},
search(searchTerm = '') {
this.searchTerm = searchTerm;
this.searchStarted = true;
},
async updateItems(newSelectedItemIds) {
this.updateInProgress = true;
let newSelectedItems;
const differingItems = xor(newSelectedItemIds, this.selectedItemIds);
if (differingItems === 0) return;
if (newSelectedItemIds.length === 0) {
newSelectedItems = [];
} else {
const removeIds = difference(this.selectedItemIds, newSelectedItemIds);
if (removeIds.length > 0) {
newSelectedItems = this.selectedItems.filter(({ id }) => !removeIds.includes(id));
}
const addIds = difference(newSelectedItemIds, this.selectedItemIds);
if (addIds.length > 0) {
newSelectedItems = [
...this.selectedItems,
...this.searchItems.filter(({ id }) => addIds.includes(id)),
];
}
}
if (this.createFlow) {
this.$apollo.mutate({
mutation: updateNewWorkItemMutation,
variables: {
input: {
workItemType: this.workItemType,
fullPath: this.fullPath,
crmContacts: newSelectedItems,
},
},
});
this.updateInProgress = false;
return;
}
try {
const {
data: {
workItemUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
crmContactsWidget: {
contactIds: newSelectedItemIds,
},
},
},
});
if (errors.length > 0) {
throw new Error(errors[0].message);
}
this.track('updated_contacts');
} catch {
this.$emit('error', i18n.updateError);
} finally {
this.searchTerm = '';
this.updateInProgress = false;
}
},
},
};
</script>
<template>
<work-item-sidebar-dropdown-widget
:dropdown-label="s__('Crm|Contacts')"
:can-update="canUpdate"
dropdown-name="crm-contacts"
:loading="isLoading"
:list-items="listItems"
:item-value="selectedItemIds"
:update-in-progress="updateInProgress"
:toggle-dropdown-text="dropdownLabelText"
:header-text="s__('Crm|Select contacts')"
multi-select
clear-search-on-item-select
data-testid="work-item-crm-contacts"
@dropdownShown="search"
@searchStarted="search"
@updateValue="updateItems"
>
<template #readonly>
<div class="gl-gap-2 gl-mt-1">
<div
v-for="[organizationName, contacts] in selectedOrganizations"
:key="organizationName"
data-testid="organization"
>
<div class="gl-text-secondary gl-mt-3">{{ organizationName }}</div>
<div
v-for="contact in contacts"
:id="`contact_container_${contact.id}`"
:key="contact.id"
data-testid="contact"
>
<gl-link :id="`contact_${contact.id}`" class="gl-text-inherit">
{{ contact.firstName }} {{ contact.lastName }}
</gl-link>
<gl-popover
:target="`contact_${contact.id}`"
:container="`contact_container_${contact.id}`"
triggers="hover focus"
placement="top"
>
<div>{{ contact.firstName }} {{ contact.lastName }}</div>
<div class="gl-text-secondary">
<div>{{ contact.description }}</div>
<div v-if="contact.email">
<gl-icon name="mail" class="gl-mr-2" />{{ contact.email }}
</div>
<div v-if="contact.phone">
<gl-icon name="mobile" class="gl-mr-2" />{{ contact.phone }}
</div>
<div v-if="organizationName !== s__('Crm|No organization')">
<gl-icon name="building" class="gl-mr-2" />{{ organizationName }}
</div>
</div>
</gl-popover>
</div>
</div>
</div>
</template>
</work-item-sidebar-dropdown-widget>
</template>

View File

@ -280,6 +280,9 @@ export default {
reportAbuse(isOpen, reply = {}) {
this.$emit('openReportAbuse', reply);
},
isDiscussionResolved(discussion) {
return discussion.notes.nodes[0]?.discussion?.resolved;
},
async fetchMoreNotes() {
this.isLoadingMore = true;
await this.$apollo.queries.workItemNotes
@ -393,6 +396,7 @@ export default {
:can-set-work-item-metadata="canSetWorkItemMetadata"
:is-discussion-locked="isDiscussionLocked"
:is-work-item-confidential="isWorkItemConfidential"
:is-expanded-on-load="!isDiscussionResolved(discussion)"
@deleteNote="showDeleteNoteModal($event, discussion)"
@reportAbuse="reportAbuse(true, $event)"
@error="$emit('error', $event)"

View File

@ -21,10 +21,10 @@ export default {
inputId: 'work-item-parent-listbox-value',
noWorkItemId: 'no-work-item-id',
i18n: {
assignParentLabel: s__('WorkItem|Assign parent'),
assignParentLabel: s__('WorkItem|Select parent'),
parentLabel: s__('WorkItem|Parent'),
none: s__('WorkItem|None'),
unAssign: s__('WorkItem|Unassign'),
unAssign: s__('WorkItem|Clear'),
workItemsFetchError: s__(
'WorkItem|Something went wrong while fetching items. Please try again.',
),
@ -232,7 +232,7 @@ export default {
<gl-link
v-if="localSelectedItem"
data-testid="work-item-parent-link"
class="gl-link gl-text-gray-900 gl-display-inline-block gl-max-w-full gl-whitespace-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
class="gl-inline-block gl-align-top gl-text-gray-900 gl-max-w-full gl-whitespace-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
:href="parentWebUrl"
>{{ listboxText }}</gl-link
>

View File

@ -30,6 +30,7 @@ export const WIDGET_TYPE_LINKED_ITEMS = 'LINKED_ITEMS';
export const WIDGET_TYPE_COLOR = 'COLOR';
export const WIDGET_TYPE_DESIGNS = 'DESIGNS';
export const WIDGET_TYPE_DEVELOPMENT = 'DEVELOPMENT';
export const WIDGET_TYPE_CRM_CONTACTS = 'CRM_CONTACTS';
export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT';
export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE';
@ -67,6 +68,9 @@ export const i18n = {
export const I18N_WORK_ITEM_ERROR_FETCHING_LABELS = s__(
'WorkItem|Something went wrong when fetching labels. Please try again.',
);
export const I18N_WORK_ITEM_ERROR_FETCHING_CRM_CONTACTS = s__(
'WorkItem|Something went wrong when fetching CRM contacts. Please try again.',
);
export const I18N_WORK_ITEM_ERROR_FETCHING_TYPES = s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
);

View File

@ -27,6 +27,7 @@ import {
WIDGET_TYPE_ITERATION,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_CRM_CONTACTS,
NEW_WORK_ITEM_IID,
} from '../constants';
import workItemByIidQuery from './work_item_by_iid.query.graphql';
@ -240,6 +241,7 @@ export const setNewWorkItemCache = async (
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_TIME_TRACKING,
WIDGET_TYPE_PARTICIPANTS,
WIDGET_TYPE_CRM_CONTACTS,
];
if (!widgetDefinitions) {
@ -300,6 +302,17 @@ export const setNewWorkItemCache = async (
});
}
if (widgetName === WIDGET_TYPE_CRM_CONTACTS) {
widgets.push({
type: 'CRM_CONTACTS',
contacts: {
nodes: [],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
});
}
if (widgetName === WIDGET_TYPE_LABELS) {
const labelsWidgetData = widgetDefinitions.find(
(definition) => definition.type === WIDGET_TYPE_LABELS,

View File

@ -0,0 +1,15 @@
#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
mutation toggleWorkItemNoteResolveDiscussion($id: DiscussionID!, $resolve: Boolean!) {
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
discussion {
id
notes {
nodes {
...WorkItemNote
}
}
}
errors
}
}

View File

@ -19,6 +19,12 @@ fragment WorkItemNote on Note {
}
discussion {
id
resolved
resolvable
resolvedBy {
id
name
}
}
author {
...User

View File

@ -10,6 +10,7 @@ import {
WIDGET_TYPE_LABELS,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_CRM_CONTACTS,
NEW_WORK_ITEM_IID,
CLEAR_VALUE,
} from '../constants';
@ -64,6 +65,7 @@ export const updateNewWorkItemCache = (input, cache) => {
confidential,
labels,
rolledUpDates,
crmContacts,
} = input;
const query = workItemByIidQuery;
@ -95,6 +97,11 @@ export const updateNewWorkItemCache = (input, cache) => {
newData: description,
nodePath: 'description',
},
{
widgetType: WIDGET_TYPE_CRM_CONTACTS,
newData: crmContacts,
nodePath: 'contacts.nodes',
},
];
widgetUpdates.forEach(({ widgetType, newData, nodePath }) => {

View File

@ -31,6 +31,13 @@ input LocalLabelInput {
title: String
}
input LocalCrmContactsInput {
firstName: String
lastName: String
organizationName: String
id: ID!
}
input LocalUpdateWorkItemInput {
id: WorkItemID!
assignees: [LocalUserInput!]
@ -63,6 +70,7 @@ input LocalUpdateNewWorkItemInput {
confidential: Boolean
labels: [LocalLabelInput]
rolledUpDates: [LocalRolledUpDatesInput]
crmContacts: [LocalCrmContactsInput]
}
extend type Mutation {

View File

@ -150,4 +150,20 @@ fragment WorkItemWidgets on WorkItemWidget {
}
}
}
... on WorkItemWidgetCrmContacts {
contacts {
nodes {
id
email
firstName
lastName
phone
description
organization {
id
name
}
}
}
}
}

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
module Projects
module Prometheus
module Metrics
class BaseService
include Gitlab::Utils::StrongMemoize
def initialize(metric, params = {})
@metric = metric
@params = params.dup
end
protected
attr_reader :metric, :params
end
end
end
end

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
module Projects
module Prometheus
module Metrics
class DestroyService < Metrics::BaseService
def execute
metric.destroy
end
end
end
end
end

View File

@ -0,0 +1,12 @@
---
table_name: observability_metrics_issues_connections
classes:
- Observability::MetricsIssuesConnection
feature_categories:
- metrics
description: Represents the join between an Issue and an Observability metric stored in Gitlab Observability Backend.
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/160713
milestone: '17.3'
gitlab_schema: gitlab_main_cell
sharding_key:
namespace_id: namespaces

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class CreateMetricsIssuesConnections < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '17.3'
def up
create_table :observability_metrics_issues_connections do |t|
t.references :issue, null: false, foreign_key: { to_table: :issues, on_delete: :cascade }, index: false
t.bigint :namespace_id, null: false, index: true
t.timestamps_with_timezone null: false
t.integer :metric_type, null: false, limit: 2
t.text :metric_name, null: false, limit: 500
t.index [:issue_id, :metric_type, :metric_name],
unique: true,
name: 'idx_o11y_metric_issue_conn_on_issue_id_metric_type_name'
end
end
def down
drop_table :observability_metrics_issues_connections
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# See https://docs.gitlab.com/ee/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddNamespacesO11yMetricsFk < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '17.3'
def up
add_concurrent_foreign_key :observability_metrics_issues_connections,
:namespaces,
column: :namespace_id,
on_delete: :cascade
end
def down
with_lock_retries do
remove_foreign_key :observability_metrics_issues_connections, column: :namespace_id
end
end
end

View File

@ -0,0 +1 @@
e7b09f3a8bf7795ebcc1db9322786bd72e08911ef9706c5bc886624ec2e59e4f

View File

@ -0,0 +1 @@
57742714f77431808f51983d2f803280276c167e55dc05094bbcca8b8b0e58f9

View File

@ -13797,6 +13797,26 @@ CREATE SEQUENCE oauth_openid_requests_id_seq
ALTER SEQUENCE oauth_openid_requests_id_seq OWNED BY oauth_openid_requests.id;
CREATE TABLE observability_metrics_issues_connections (
id bigint NOT NULL,
issue_id bigint NOT NULL,
namespace_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
metric_type smallint NOT NULL,
metric_name text NOT NULL,
CONSTRAINT check_3c743c1262 CHECK ((char_length(metric_name) <= 500))
);
CREATE SEQUENCE observability_metrics_issues_connections_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE observability_metrics_issues_connections_id_seq OWNED BY observability_metrics_issues_connections.id;
CREATE TABLE onboarding_progresses (
id bigint NOT NULL,
namespace_id bigint NOT NULL,
@ -21371,6 +21391,8 @@ ALTER TABLE ONLY oauth_device_grants ALTER COLUMN id SET DEFAULT nextval('oauth_
ALTER TABLE ONLY oauth_openid_requests ALTER COLUMN id SET DEFAULT nextval('oauth_openid_requests_id_seq'::regclass);
ALTER TABLE ONLY observability_metrics_issues_connections ALTER COLUMN id SET DEFAULT nextval('observability_metrics_issues_connections_id_seq'::regclass);
ALTER TABLE ONLY onboarding_progresses ALTER COLUMN id SET DEFAULT nextval('onboarding_progresses_id_seq'::regclass);
ALTER TABLE ONLY operations_feature_flag_scopes ALTER COLUMN id SET DEFAULT nextval('operations_feature_flag_scopes_id_seq'::regclass);
@ -23734,6 +23756,9 @@ ALTER TABLE ONLY oauth_device_grants
ALTER TABLE ONLY oauth_openid_requests
ADD CONSTRAINT oauth_openid_requests_pkey PRIMARY KEY (id);
ALTER TABLE ONLY observability_metrics_issues_connections
ADD CONSTRAINT observability_metrics_issues_connections_pkey PRIMARY KEY (id);
ALTER TABLE ONLY onboarding_progresses
ADD CONSTRAINT onboarding_progresses_pkey PRIMARY KEY (id);
@ -26038,6 +26063,8 @@ CREATE UNIQUE INDEX idx_namespace_settings_on_default_compliance_framework_id ON
CREATE INDEX idx_namespace_settings_on_last_dormant_member_review_at ON namespace_settings USING btree (last_dormant_member_review_at) WHERE (remove_dormant_members IS TRUE);
CREATE UNIQUE INDEX idx_o11y_metric_issue_conn_on_issue_id_metric_type_name ON observability_metrics_issues_connections USING btree (issue_id, metric_type, metric_name);
CREATE UNIQUE INDEX idx_on_approval_group_rules_any_approver_type ON approval_group_rules USING btree (group_id, rule_type) WHERE (rule_type = 4);
CREATE UNIQUE INDEX idx_on_approval_group_rules_group_id_type_name ON approval_group_rules USING btree (group_id, rule_type, name);
@ -28462,6 +28489,8 @@ CREATE UNIQUE INDEX index_oauth_device_grants_on_user_code ON oauth_device_grant
CREATE INDEX index_oauth_openid_requests_on_access_grant_id ON oauth_openid_requests USING btree (access_grant_id);
CREATE INDEX index_observability_metrics_issues_connections_on_namespace_id ON observability_metrics_issues_connections USING btree (namespace_id);
CREATE UNIQUE INDEX index_on_deploy_keys_id_and_type_and_public ON keys USING btree (id, type) WHERE (public = true);
CREATE INDEX index_on_dingtalk_tracker_data_corpid ON dingtalk_tracker_data USING btree (corpid) WHERE (corpid IS NOT NULL);
@ -33606,6 +33635,9 @@ ALTER TABLE ONLY boards
ALTER TABLE ONLY epic_user_mentions
ADD CONSTRAINT fk_f1ab52883e FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY observability_metrics_issues_connections
ADD CONSTRAINT fk_f218d84a14 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE p_ci_pipeline_variables
ADD CONSTRAINT fk_f29c5f4380_p FOREIGN KEY (partition_id, pipeline_id) REFERENCES ci_pipelines(partition_id, id) ON UPDATE CASCADE ON DELETE CASCADE;
@ -34314,6 +34346,9 @@ ALTER TABLE ONLY ml_models
ALTER TABLE ONLY elastic_group_index_statuses
ADD CONSTRAINT fk_rails_52b9969b12 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY observability_metrics_issues_connections
ADD CONSTRAINT fk_rails_533fe605e3 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
ALTER TABLE ONLY bulk_import_configurations
ADD CONSTRAINT fk_rails_536b96bff1 FOREIGN KEY (bulk_import_id) REFERENCES bulk_imports(id) ON DELETE CASCADE;

View File

@ -39,8 +39,13 @@ averaged.
#### Add test coverage results using `coverage` keyword
To add test coverage results to a merge request using the project's `.gitlab-ci.yml` file, provide a regular expression
using the [`coverage`](../yaml/index.md#coverage) keyword.
You can display test coverage results in a merge request by adding the
[`coverage`](../yaml/index.md#coverage) keyword to your project's `.gitlab-ci.yml` file.
To aggregate multiple test coverage values:
- For each job you want to include in the aggregate value,
add the `coverage` keyword followed by a regular expression.
#### Test coverage examples
@ -114,7 +119,7 @@ You can require specific users or a group to approve merge requests that would r
To add a `Coverage-Check` approval rule:
1. Set up a [`coverage`](../yaml/index.md#coverage) regular expression for all jobs you want to include in the overall coverage value.
1. [Add test coverage results to a merge request](#add-test-coverage-results-using-coverage-keyword).
1. Go to your project and select **Settings > Merge requests**.
1. Under **Merge request approvals**, select **Enable** next to the `Coverage-Check` approval rule.
1. Select the **Target branch**.

View File

@ -2053,7 +2053,7 @@ In this example:
**Additional details**:
- You can find parse examples in [Code Coverage](../testing/code_coverage.md#test-coverage-examples).
- You can find regex examples in [Code Coverage](../testing/code_coverage.md#test-coverage-examples).
- If there is more than one matched line in the job output, the last line is used
(the first result of reverse search).
- If there are multiple matches in a single line, the last match is searched

View File

@ -277,7 +277,8 @@ pages:
Some other examples of mixing [variables](../../../ci/variables/index.md) with strings for dynamic prefixes:
- `pages.path_prefix: 'mr-$CI_COMMIT_REF_SLUG'`: Branch or tag name prefixed with `mr-`, like `mr-branch-name`.
- `pages.path_prefix: '-${CI_MERGE_REQUEST_IID}-'`: Merge request number prefixed and suffixed with `-`, like `-123-`.
- `pages.path_prefix: '_${CI_MERGE_REQUEST_IID}_'`: Merge request number
prefixed ans suffixed with `_`, like `_123_`.
### Use multiple deployments to create pages environments
@ -302,7 +303,7 @@ pages:
rules:
- if: $CI_COMMIT_BRANCH == "staging" # ensure to run on master (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '-stg' # prefix with _stg for the staging branch
PAGES_PREFIX: '_stg' # prefix with _stg for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # conditionally change the prefix on Merge Requests
when: manual # run pages manually on Merge Requests
variables:

View File

@ -235,6 +235,11 @@ msgid_plural "%d completed issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d contact"
msgid_plural "%d contacts"
msgstr[0] ""
msgstr[1] ""
msgid "%d contribution"
msgid_plural "%d contributions"
msgstr[0] ""
@ -14241,12 +14246,6 @@ msgid_plural "ContainerRegistry|%{count} tags"
msgstr[0] ""
msgstr[1] ""
msgid "ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted."
msgstr ""
msgid "ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion."
msgstr ""
msgid "ContainerRegistry|%{title} was successfully scheduled for deletion"
msgstr ""
@ -14382,6 +14381,9 @@ msgstr ""
msgid "ContainerRegistry|Digest: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Disabled - tags will not be automatically deleted."
msgstr ""
msgid "ContainerRegistry|Docker connection error"
msgstr ""
@ -14391,6 +14393,9 @@ msgstr ""
msgid "ContainerRegistry|Enable cleanup policy"
msgstr ""
msgid "ContainerRegistry|Enabled - tags that match the rules on this page are automatically scheduled for deletion."
msgstr ""
msgid "ContainerRegistry|GitLab is unable to validate this signature automatically. Validate the signature manually before trusting it."
msgstr ""
@ -16019,6 +16024,9 @@ msgstr ""
msgid "Crm|Organizations"
msgstr ""
msgid "Crm|Select contacts"
msgstr ""
msgid "Cron time zone"
msgstr ""
@ -39305,6 +39313,9 @@ msgstr ""
msgid "Pipelines|Validating GitLab CI configuration…"
msgstr ""
msgid "Pipelines|View merge train details"
msgstr ""
msgid "Pipelines|Visualize"
msgstr ""
@ -44930,6 +44941,9 @@ msgstr ""
msgid "Resolved by"
msgstr ""
msgid "Resolved by "
msgstr ""
msgid "Resolved by %{name}"
msgstr ""
@ -52378,6 +52392,9 @@ msgstr ""
msgid "Table of contents"
msgstr ""
msgid "Tables containing block elements (like multiple paragraphs, lists or blockquotes) are not supported in Markdown and will be converted to HTML."
msgstr ""
msgid "Tag"
msgstr ""
@ -53111,9 +53128,6 @@ msgstr ""
msgid "The contact does not belong to the issue group's root ancestor"
msgstr ""
msgid "The content editor may change the markdown formatting style of the document, which may not match your original markdown style."
msgstr ""
msgid "The content for this wiki page failed to load. To fix this error, reload the page."
msgstr ""
@ -60255,9 +60269,6 @@ msgstr ""
msgid "WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed."
msgstr ""
msgid "WorkItem|Assign parent"
msgstr ""
msgid "WorkItem|Blocked by"
msgstr ""
@ -60279,6 +60290,9 @@ msgstr ""
msgid "WorkItem|Child removed"
msgstr ""
msgid "WorkItem|Clear"
msgstr ""
msgid "WorkItem|Close %{workItemType}"
msgstr ""
@ -60495,6 +60509,9 @@ msgstr ""
msgid "WorkItem|Select a project"
msgstr ""
msgid "WorkItem|Select parent"
msgstr ""
msgid "WorkItem|Select type"
msgstr ""
@ -60516,6 +60533,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when deleting the task. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong when fetching CRM contacts. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong when fetching child items. Please refresh this page."
msgstr ""
@ -60639,9 +60659,6 @@ msgstr ""
msgid "WorkItem|Turn on confidentiality"
msgstr ""
msgid "WorkItem|Unassign"
msgstr ""
msgid "WorkItem|Undo"
msgstr ""

View File

@ -71,7 +71,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/svgs": "3.109.0",
"@gitlab/ui": "87.4.0",
"@gitlab/ui": "87.6.1",
"@gitlab/web-ide": "^0.0.1-dev-20240613133550",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.0.8-4",

View File

@ -98,7 +98,7 @@ RSpec.describe 'Project > Settings > Packages and registries > Container registr
within_testid 'container-expiration-policy-project-settings' do
expect(find_by_testid('enable-toggle'))
.to have_content('Disabled - Tags will not be automatically deleted.')
.to have_content('Disabled - tags will not be automatically deleted.')
end
end
end

View File

@ -1,4 +1,5 @@
import { builders } from 'prosemirror-test-builder';
import createEventHub from '~/helpers/event_hub_factory';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
import ListItem from '~/content_editor/extensions/list_item';
@ -8,6 +9,8 @@ import TableRow from '~/content_editor/extensions/table_row';
import TableHeader from '~/content_editor/extensions/table_header';
import { createTestEditor } from '../test_utils';
const eventHub = createEventHub();
describe('content_editor/extensions/table', () => {
let tiptapEditor;
let doc;
@ -21,7 +24,15 @@ describe('content_editor/extensions/table', () => {
beforeEach(() => {
tiptapEditor = createTestEditor({
extensions: [Table, TableCell, TableRow, TableHeader, BulletList, Bold, ListItem],
extensions: [
Table.configure({ eventHub }),
TableCell,
TableRow,
TableHeader,
BulletList,
Bold,
ListItem,
],
});
({
@ -47,13 +58,16 @@ describe('content_editor/extensions/table', () => {
it('triggers a warning (just once) if the table is markdown, but the changes in the document will render an HTML table instead', () => {
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.on('alert', mockAlert);
eventHub.$on('alert', mockAlert);
tiptapEditor.commands.setTextSelection({ from: 20, to: 22 });
tiptapEditor.commands.toggleBulletList();
jest.advanceTimersByTime(1001);
expect(mockAlert).toHaveBeenCalled();
expect(mockAlert).toHaveBeenCalledWith({
message: expect.any(String),
variant: 'warning',
});
mockAlert.mockReset();

View File

@ -13,6 +13,7 @@ import {
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/packages_and_registries/settings/project/constants';
import SettingsSection from '~/vue_shared/components/settings/settings_section.vue';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
import {
@ -37,13 +38,14 @@ describe('Cleanup image tags project settings', () => {
const findFormComponent = () => wrapper.findComponent(ContainerExpirationPolicyForm);
const findAlert = () => wrapper.findComponent(GlAlert);
const findTitle = () => wrapper.findByTestId('title');
const findDescription = () => wrapper.findByTestId('description');
const findSettingsSectionComponent = () => wrapper.findComponent(SettingsSection);
const findDescription = () => wrapper.findByTestId('settings-section-description');
const mountComponent = (provide = defaultProvidedValues, config) => {
wrapper = shallowMountExtended(component, {
stubs: {
GlSprintf,
SettingsSection,
},
provide,
...config,
@ -91,7 +93,7 @@ describe('Cleanup image tags project settings', () => {
await waitForPromises();
expect(findFormComponent().exists()).toBe(true);
expect(findTitle().text()).toMatchInterpolatedText(CONTAINER_CLEANUP_POLICY_TITLE);
expect(findSettingsSectionComponent().props('heading')).toBe(CONTAINER_CLEANUP_POLICY_TITLE);
expect(findDescription().text()).toMatchInterpolatedText(CONTAINER_CLEANUP_POLICY_DESCRIPTION);
});

View File

@ -5,7 +5,7 @@ import Vue, { nextTick } from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { GlCard, GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs';
import { GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs';
import component from '~/packages_and_registries/settings/project/components/container_expiration_policy_form.vue';
import { UPDATE_SETTINGS_ERROR_MESSAGE } from '~/packages_and_registries/settings/project/constants';
import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql';
@ -62,7 +62,6 @@ describe('Container Expiration Policy Settings Form', () => {
} = {}) => {
wrapper = shallowMount(component, {
stubs: {
GlCard,
GlLoadingIcon,
GlSprintf,
},

View File

@ -10,6 +10,7 @@ import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemCrmContacts from '~/work_items/components/work_item_crm_contacts.vue';
import { WORK_ITEM_TYPE_ENUM_EPIC } from '~/work_items/constants';
import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
@ -51,6 +52,7 @@ describe('Create work item component', () => {
const findDescriptionWidget = () => wrapper.findComponent(WorkItemDescription);
const findAssigneesWidget = () => wrapper.findComponent(WorkItemAssignees);
const findLabelsWidget = () => wrapper.findComponent(WorkItemLabels);
const findCrmContactsWidget = () => wrapper.findComponent(WorkItemCrmContacts);
const findSelect = () => wrapper.findComponent(GlFormSelect);
const findConfidentialCheckbox = () => wrapper.find('[data-testid="confidential-checkbox"]');
const findCreateWorkItemView = () => wrapper.find('[data-testid="create-work-item-view"]');
@ -296,5 +298,9 @@ describe('Create work item component', () => {
it('renders the work item labels widget', () => {
expect(findLabelsWidget().exists()).toBe(true);
});
it('renders the work item CRM contacts widget', () => {
expect(findCrmContactsWidget().exists()).toBe(true);
});
});
});

View File

@ -12,6 +12,7 @@ import createNoteMutation from '~/work_items/graphql/notes/create_work_item_note
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import DiscussionReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
import {
createWorkItemNoteResponse,
workItemByIidResponseFactory,
@ -34,6 +35,7 @@ describe('Work item add note', () => {
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findReplyPlaceholder = () => wrapper.findComponent(DiscussionReplyPlaceholder);
const findWorkItemLockedComponent = () => wrapper.findComponent(WorkItemCommentLocked);
const findResolveDiscussionButton = () => wrapper.findComponent(ResolveDiscussionButton);
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
@ -46,6 +48,11 @@ describe('Work item add note', () => {
isGroup = false,
workItemType = 'Task',
isInternalThread = false,
isNewDiscussion = false,
isDiscussionResolved = false,
isDiscussionResolvable = false,
isResolving = false,
hasReplies = false,
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
if (signedIn) {
@ -70,6 +77,11 @@ describe('Work item add note', () => {
markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
autocompleteDataSources: {},
isInternalThread,
isNewDiscussion,
isDiscussionResolved,
isDiscussionResolvable,
isResolving,
hasReplies,
},
stubs: {
WorkItemCommentLocked,
@ -297,4 +309,58 @@ describe('Work item add note', () => {
expect(findCommentForm().exists()).toBe(false);
});
});
describe('Resolve Discussion button', () => {
it('renders resolve discussion button when discussion is resolvable', async () => {
await createComponent({
isDiscussionResolvable: true,
isEditing: false,
});
expect(findResolveDiscussionButton().exists()).toBe(true);
});
it('does not render resolve discussion button when discussion is not resolvable', async () => {
await createComponent({
isDiscussionResolvable: false,
isEditing: false,
});
expect(findResolveDiscussionButton().exists()).toBe(false);
});
it('does not render resolve discussion button when it is a new discussion', async () => {
await createComponent({
isDiscussionResolvable: false,
isEditing: false,
isNewDiscussion: true,
});
expect(findResolveDiscussionButton().exists()).toBe(false);
});
it('emits `resolve` event when resolve discussion button is clicked', async () => {
await createComponent({
isDiscussionResolvable: true,
isEditing: false,
});
findResolveDiscussionButton().vm.$emit('onClick');
expect(wrapper.emitted('resolve')).toHaveLength(1);
});
it('passes correct props to resolve discussion button', async () => {
await createComponent({
isDiscussionResolvable: true,
isDiscussionResolved: false,
isResolving: true,
isEditing: false,
});
const resolveButton = findResolveDiscussionButton();
expect(resolveButton.props('isResolving')).toBe(true);
expect(resolveButton.props('buttonTitle')).toBe('Resolve thread');
});
});
});

View File

@ -1,7 +1,7 @@
import { GlFormCheckbox, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import * as autosave from '~/lib/utils/autosave';
@ -35,11 +35,12 @@ describe('Work item comment form component', () => {
const findCommentFieldLayout = () => wrapper.findComponent(CommentFieldLayout);
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findConfirmButton = () => wrapper.findByTestId('confirm-button');
const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon);
const findWorkItemToggleStateButton = () => wrapper.findComponent(WorkItemStateToggle);
const findToggleResolveCheckbox = () => wrapper.findByTestId('toggle-resolve-checkbox');
const createComponent = ({
isSubmitting = false,
@ -48,8 +49,11 @@ describe('Work item comment form component', () => {
workItemState = STATE_OPEN,
workItemType = 'Task',
isGroup = false,
hasReplies = false,
isDiscussionResolved = false,
isDiscussionResolvable = false,
} = {}) => {
wrapper = shallowMount(WorkItemCommentForm, {
wrapper = shallowMountExtended(WorkItemCommentForm, {
provide: {
isGroup,
},
@ -66,6 +70,9 @@ describe('Work item comment form component', () => {
markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
autocompleteDataSources: {},
isNewDiscussion,
isDiscussionResolvable,
isDiscussionResolved,
hasReplies,
},
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
@ -288,4 +295,42 @@ describe('Work item comment form component', () => {
});
});
});
describe('Toggle Resolve checkbox', () => {
it('does not render when used as a top level comment/discussion', () => {
createComponent({ isNewDiscussion: true });
expect(findToggleResolveCheckbox().exists()).toBe(false);
});
it('renders when used as a reply and the discussion is resolvable', () => {
createComponent({
hasReplies: true,
isDiscussionResolvable: true,
});
expect(findToggleResolveCheckbox().exists()).toBe(true);
});
it('emits the `toggleResolve` event on submitForm when resolved', async () => {
createComponent({
hasReplies: true,
isDiscussionResolvable: true,
isDiscussionResolved: false,
});
findToggleResolveCheckbox().vm.$emit('input', true);
findMarkdownEditor().vm.$emit('input', 'new comment');
findConfirmButton().vm.$emit('click');
await waitForPromises();
expect(wrapper.emitted('submitForm')).toEqual([
[{ commentText: 'new comment', isNoteInternal: false }],
]);
expect(wrapper.emitted('toggleResolveDiscussion')).toEqual([[]]);
});
});
});

View File

@ -1,12 +1,16 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import toggleWorkItemNoteResolveDiscussion from '~/work_items/graphql/notes/toggle_work_item_note_resolve_discussion.mutation.graphql';
import {
mockWorkItemCommentNote,
mockToggleResolveDiscussionResponse,
mockWorkItemNotesResponseWithComments,
} from 'jest/work_items/mock_data';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
@ -17,6 +21,8 @@ const mockWorkItemNotesWidgetResponseWithComments =
);
describe('Work Item Discussion', () => {
Vue.use(VueApollo);
let wrapper;
const mockWorkItemId = 'gid://gitlab/WorkItem/625';
@ -26,12 +32,20 @@ describe('Work Item Discussion', () => {
const findWorkItemAddNote = () => wrapper.findComponent(WorkItemAddNote);
const findWorkItemNoteReplying = () => wrapper.findComponent(WorkItemNoteReplying);
const toggleWorkItemResolveDiscussionHandler = jest
.fn()
.mockResolvedValue(mockToggleResolveDiscussionResponse);
const createComponent = ({
discussion = [mockWorkItemCommentNote],
workItemId = mockWorkItemId,
workItemType = 'Task',
isExpandedOnLoad = true,
} = {}) => {
wrapper = shallowMount(WorkItemDiscussion, {
apolloProvider: createMockApollo([
[toggleWorkItemNoteResolveDiscussion, toggleWorkItemResolveDiscussionHandler],
]),
propsData: {
fullPath: 'gitlab-org',
discussion,
@ -40,6 +54,7 @@ describe('Work Item Discussion', () => {
workItemType,
markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
autocompleteDataSources: {},
isExpandedOnLoad,
},
});
};
@ -148,4 +163,58 @@ describe('Work Item Discussion', () => {
expect(wrapper.emitted('error')).toEqual([[mockErrorText]]);
});
describe('Resolve discussion', () => {
beforeEach(() => {
window.gon.current_user_id = 'gid://gitlab/User/1';
window.gon.current_user_fullname = 'Administrator';
});
const resolvedDiscussionList =
mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes.slice();
resolvedDiscussionList.forEach((note) => {
return {
...note,
discussion: {
id: note.discussion.id,
resolved: true,
resolvable: true,
resolvedBy: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
__typename: 'UserCore',
},
__typename: 'Discussion',
},
};
});
it('Resolved discussion is not expanded on default', () => {
createComponent({
discussion: resolvedDiscussionList,
isExpandedOnLoad: false,
});
expect(findToggleRepliesWidget().props('collapsed')).toBe(true);
});
it('toggles resolved status when toggle icon is clicked from note header', async () => {
createComponent({
discussion:
mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes.slice(),
isExpandedOnLoad: true,
});
const mainComment = findThreadAtIndex(0);
expect(findToggleRepliesWidget().props('collapsed')).toBe(false);
mainComment.vm.$emit('resolve');
expect(toggleWorkItemResolveDiscussionHandler).toHaveBeenCalled();
await nextTick();
expect(findToggleRepliesWidget().props('collapsed')).toBe(false);
});
});
});

View File

@ -11,8 +11,8 @@ import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_
import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
import {
mockWorkItemNotesResponseWithComments,
mockAwardEmojiThumbsUp,
mockWorkItemNotesResponseWithComments,
} from 'jest/work_items/mock_data';
import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants';

View File

@ -8,6 +8,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemParent from '~/work_items/components/work_item_parent.vue';
import WorkItemTimeTracking from '~/work_items/components/work_item_time_tracking.vue';
import WorkItemDevelopment from '~/work_items/components/work_item_development/work_item_development.vue';
import WorkItemCrmContacts from '~/work_items/components/work_item_crm_contacts.vue';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue';
import { workItemResponseFactory } from '../mock_data';
@ -25,6 +26,7 @@ describe('WorkItemAttributesWrapper component', () => {
const findWorkItemTimeTracking = () => wrapper.findComponent(WorkItemTimeTracking);
const findWorkItemParticipants = () => wrapper.findComponent(Participants);
const findWorkItemDevelopment = () => wrapper.findComponent(WorkItemDevelopment);
const findWorkItemCrmContacts = () => wrapper.findComponent(WorkItemCrmContacts);
const createComponent = ({
workItem = workItemQueryResponse.data.workItem,
@ -184,6 +186,34 @@ describe('WorkItemAttributesWrapper component', () => {
});
});
describe('CRM contacts widget', () => {
describe('when workItemsAlpha FF is disabled', () => {
it.each`
description | crmContactsWidgetPresent | exists
${'renders when widget is returned from API'} | ${true} | ${false}
${'does not render when widget is not returned from API'} | ${false} | ${false}
`('$description', ({ crmContactsWidgetPresent, exists }) => {
const response = workItemResponseFactory({ crmContactsWidgetPresent });
createComponent({ workItem: response.data.workItem });
expect(findWorkItemCrmContacts().exists()).toBe(exists);
});
});
describe('when workItemsAlpha FF is enabled', () => {
it.each`
description | crmContactsWidgetPresent | exists
${'renders when widget is returned from API'} | ${true} | ${true}
${'does not render when widget is not returned from API'} | ${false} | ${false}
`('$description', ({ crmContactsWidgetPresent, exists }) => {
const response = workItemResponseFactory({ crmContactsWidgetPresent });
createComponent({ workItem: response.data.workItem, workItemsAlpha: true });
expect(findWorkItemCrmContacts().exists()).toBe(exists);
});
});
});
describe('participants widget', () => {
it.each`
description | participantsWidgetPresent | exists

View File

@ -0,0 +1,250 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
TRACKING_CATEGORY_SHOW,
I18N_WORK_ITEM_ERROR_FETCHING_CRM_CONTACTS,
} from '~/work_items/constants';
import searchQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemCrmContacts from '~/work_items/components/work_item_crm_contacts.vue';
import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue';
import {
getGroupCrmContactsResponse,
mockCrmContacts,
updateWorkItemMutationResponseFactory,
updateWorkItemMutationErrorResponse,
workItemByIidResponseFactory,
} from '../mock_data';
Vue.use(VueApollo);
const workItemId = 'gid://gitlab/WorkItem/10';
const mockItems = mockCrmContacts;
describe('WorkItemCrmContacts component', () => {
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
let wrapper;
const item1Id = mockItems[0].id;
const item3Id = mockItems[2].id;
const searchQuerySuccessHandler = jest
.fn()
.mockResolvedValue(getGroupCrmContactsResponse(mockItems));
const errorHandler = jest.fn().mockRejectedValue('Error');
const successUpdateWorkItemMutationHandler = jest
.fn()
.mockResolvedValue(updateWorkItemMutationResponseFactory({ crmContacts: [mockItems[0]] }));
const createComponent = ({
searchQueryHandler = searchQuerySuccessHandler,
updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler,
workItemIid = '1',
items = [],
} = {}) => {
const workItemQueryResponse = workItemByIidResponseFactory({
canUpdate: true,
crmContacts: items,
});
const workItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
wrapper = shallowMountExtended(WorkItemCrmContacts, {
apolloProvider: createMockApollo([
[searchQuery, searchQueryHandler],
[updateWorkItemMutation, updateWorkItemMutationHandler],
[workItemByIidQuery, workItemQueryHandler],
]),
propsData: {
workItemId,
workItemIid,
fullPath: 'test-project-path',
workItemType: 'Task',
},
});
};
const findWorkItemSidebarDropdownWidget = () =>
wrapper.findComponent(WorkItemSidebarDropdownWidget);
const findAllItems = () => wrapper.findAllByTestId('contact');
const findAllGroups = () => wrapper.findAllByTestId('organization');
const findItem = () => findAllItems().at(0);
const showDropdown = () => {
findWorkItemSidebarDropdownWidget().vm.$emit('dropdownShown');
};
const updateItems = async (items) => {
findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted');
await waitForPromises();
findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', items);
};
const getMutationInput = (contactIds) => {
return {
input: {
id: workItemId,
crmContactsWidget: {
contactIds,
},
},
};
};
it('renders the work item sidebar dropdown widget with default props', async () => {
createComponent();
await waitForPromises();
expect(findWorkItemSidebarDropdownWidget().props()).toMatchObject({
dropdownLabel: 'Contacts',
canUpdate: true,
dropdownName: 'crm-contacts',
updateInProgress: false,
toggleDropdownText: '0 contacts',
headerText: 'Select contacts',
multiSelect: true,
itemValue: [],
});
expect(findAllItems()).toHaveLength(0);
});
it('renders the items when they are already present', async () => {
createComponent({ items: mockItems });
await waitForPromises();
expect(findWorkItemSidebarDropdownWidget().props('itemValue')).toStrictEqual(
mockItems.map(({ id }) => id),
);
expect(findAllItems()).toHaveLength(3);
expect(findAllGroups()).toHaveLength(2);
expect(findItem().text()).toContain("Jenee O'Reilly");
expect(findItem().text()).toContain("Jenee.O'Reilly-12@example.org");
expect(findItem().text()).toContain('Anderson LLC-4');
});
it.each`
expectedAssertion | searchTerm | handler | result
${'when dropdown is shown'} | ${''} | ${searchQuerySuccessHandler} | ${3}
${'when correct input is entered'} | ${'Item 1'} | ${jest.fn().mockResolvedValue(getGroupCrmContactsResponse([mockItems[0]]))} | ${1}
${'and shows no matching results when incorrect input is entered'} | ${'Item 2'} | ${jest.fn().mockResolvedValue(getGroupCrmContactsResponse([]))} | ${0}
`('calls search label query $expectedAssertion', async ({ searchTerm, result, handler }) => {
createComponent({
searchQueryHandler: handler,
});
showDropdown();
await findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted', searchTerm);
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(true);
await waitForPromises();
expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
expect(
findWorkItemSidebarDropdownWidget()
.props('listItems')
.flatMap(({ options }) => options),
).toHaveLength(result);
expect(handler).toHaveBeenCalledWith({
groupFullPath: 'test-project-path',
searchTerm,
nextPageCursor: '',
prevPageCursor: '',
});
});
it('emits error event if search query fails', async () => {
createComponent({ searchQueryHandler: errorHandler });
showDropdown();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_ERROR_FETCHING_CRM_CONTACTS]]);
});
it('update items when items are updated', async () => {
createComponent();
showDropdown();
updateItems([item1Id]);
await waitForPromises();
expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith(getMutationInput([item1Id]));
});
it('clears all items when updateValue has no items', async () => {
createComponent();
findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', []);
await waitForPromises();
expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith(getMutationInput([]));
});
it('shows selected items, then organizations then orphans', async () => {
createComponent();
updateItems([item1Id, item3Id]);
await waitForPromises();
showDropdown();
const [item1, item2, item3] = mockItems;
const selected = [{ text: `${item1.firstName} ${item1.lastName}`, value: item1.id }];
const unselected = [{ text: `${item2.firstName} ${item2.lastName}`, value: item2.id }];
const orphans = [{ text: `${item3.firstName} ${item3.lastName}`, value: item3.id }];
expect(findWorkItemSidebarDropdownWidget().props('listItems')).toEqual([
{ options: selected, text: 'Selected' },
{ options: unselected, text: 'Anderson LLC-4' },
{ options: orphans, text: 'No organization' },
]);
});
describe('tracking', () => {
let trackingSpy;
beforeEach(() => {
createComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
trackingSpy = null;
});
it('tracks editing the items on dropdown widget updateValue', async () => {
showDropdown();
updateItems([item1Id]);
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_contacts', {
category: TRACKING_CATEGORY_SHOW,
label: 'item_contact',
property: 'type_Task',
});
});
});
it.each`
errorType | expectedErrorMessage | failureHandler
${'graphql error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)}
${'network error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockRejectedValue(new Error())}
`(
'emits an error when there is a $errorType',
async ({ expectedErrorMessage, failureHandler }) => {
createComponent({
updateWorkItemMutationHandler: failureHandler,
});
updateItems([item1Id]);
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]);
},
);
});

View File

@ -24,10 +24,10 @@ import {
workItemQueryResponse,
mockWorkItemNotesByIidResponse,
mockMoreWorkItemNotesResponse,
mockWorkItemNotesResponseWithComments,
workItemNotesCreateSubscriptionResponse,
workItemNotesUpdateSubscriptionResponse,
workItemNotesDeleteSubscriptionResponse,
mockWorkItemNotesResponseWithComments,
} from '../mock_data';
const mockWorkItemId = workItemQueryResponse.data.workItem.id;

View File

@ -187,11 +187,11 @@ describe('WorkItemParent component', () => {
expect(findSidebarDropdownWidget().exists()).toBe(true);
expect(findSidebarDropdownWidget().props()).toMatchObject({
listItems: [],
headerText: 'Assign parent',
headerText: 'Select parent',
loading: false,
searchable: true,
infiniteScroll: false,
resetButtonLabel: 'Unassign',
resetButtonLabel: 'Clear',
});
});

View File

@ -48,6 +48,50 @@ export const mockLabels = [
},
];
export const mockCrmContacts = [
{
__typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/213',
firstName: 'Jenee',
lastName: "O'Reilly",
email: "Jenee.O'Reilly-12@example.org",
phone: null,
description: null,
active: true,
organization: {
__typename: 'CustomerRelationsOrganization',
id: 'gid://gitlab/CustomerRelations::Organization/55',
name: 'Anderson LLC-4',
},
},
{
__typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/216',
firstName: 'Kassie',
lastName: 'Oberbrunner',
email: 'Kassie.Oberbrunner-15@example.org',
phone: null,
description: null,
active: true,
organization: {
__typename: 'CustomerRelationsOrganization',
id: 'gid://gitlab/CustomerRelations::Organization/55',
name: 'Anderson LLC-4',
},
},
{
__typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/232',
firstName: 'Liza',
lastName: 'Osinski',
email: 'Liza.Osinski-31@example.org',
phone: null,
description: null,
active: true,
organization: null,
},
];
export const mockMilestone = {
__typename: 'Milestone',
id: 'gid://gitlab/Milestone/30',
@ -917,8 +961,10 @@ export const workItemResponseFactory = ({
labelsWidgetPresent = true,
hierarchyWidgetPresent = true,
linkedItemsWidgetPresent = true,
crmContactsWidgetPresent = true,
colorWidgetPresent = true,
labels = mockLabels,
crmContacts = mockCrmContacts,
allowsScopedLabels = false,
lastEditedAt = null,
lastEditedBy = null,
@ -1248,6 +1294,18 @@ export const workItemResponseFactory = ({
: {
type: 'MOCK TYPE',
},
crmContactsWidgetPresent
? {
__typename: 'WorkItemWidgetCrmContacts',
type: 'CRM_CONTACTS',
contacts: {
nodes: crmContacts,
__typename: 'CustomerRelationsContactConnection',
},
}
: {
type: 'MOCK TYPE',
},
],
},
},
@ -2634,6 +2692,23 @@ export const getProjectLabelsResponse = (labels) => ({
},
});
export const getGroupCrmContactsResponse = (contacts) => ({
data: {
group: {
id: '1',
contacts: {
nodes: contacts,
pageInfo: {
hasNextPage: false,
endCursor: null,
hasPreviousPage: false,
startCursor: null,
},
},
},
},
});
export const mockIterationWidgetResponse = {
description: 'Iteration description',
dueDate: '2022-07-19',
@ -2841,6 +2916,10 @@ export const mockWorkItemNotesResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -2942,6 +3021,10 @@ export const mockWorkItemNotesResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3053,6 +3136,10 @@ export const mockWorkItemNotesByIidResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3107,6 +3194,10 @@ export const mockWorkItemNotesByIidResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723568765',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3161,6 +3252,10 @@ export const mockWorkItemNotesByIidResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3271,6 +3366,10 @@ export const mockMoreWorkItemNotesResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1112356a59e',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3325,6 +3424,10 @@ export const mockMoreWorkItemNotesResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1272356a59e',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3378,6 +3481,10 @@ export const mockMoreWorkItemNotesResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3449,6 +3556,9 @@ export const createWorkItemNoteResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
resolved: false,
resolvable: true,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -3506,6 +3616,10 @@ export const mockWorkItemCommentNote = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
resolved: false,
resolvable: true,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3603,6 +3717,9 @@ export const mockWorkItemNotesResponseWithComments = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
resolved: false,
resolvable: true,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -3645,6 +3762,9 @@ export const mockWorkItemNotesResponseWithComments = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
resolved: false,
resolvable: true,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -3696,6 +3816,10 @@ export const mockWorkItemNotesResponseWithComments = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
resolved: false,
resolvable: true,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3771,6 +3895,10 @@ export const workItemNotesCreateSubscriptionResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3851,6 +3979,10 @@ export const workItemNotesUpdateSubscriptionResponse = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
userPermissions: {
adminNote: false,
@ -3908,6 +4040,9 @@ export const workItemSystemNoteWithMetadata = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/7d4a46ea0525e2eeed451f7b718b0ebe73205374',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -4001,6 +4136,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/aa72f4c2f3eef66afa6d79a805178801ce4bd89f',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -4066,6 +4204,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/a7d3cf7bd72f7a98f802845f538af65cb11a02cc',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -4131,6 +4272,9 @@ export const workItemNotesWithSystemNotesWithChangedDescription = {
authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/391eed1ee0a258cc966a51dde900424f3b51b95d',
resolved: false,
resolvable: false,
resolvedBy: null,
__typename: 'Discussion',
},
author: {
@ -4672,6 +4816,14 @@ export const createWorkItemQueryResponse = {
totalTimeSpent: 0,
__typename: 'WorkItemWidgetTimeTracking',
},
{
type: 'CRM_CONTACTS',
contacts: {
nodes: [],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
},
],
__typename: 'WorkItem',
},
@ -4679,3 +4831,121 @@ export const createWorkItemQueryResponse = {
},
},
};
export const mockToggleResolveDiscussionResponse = {
data: {
discussionToggleResolve: {
discussion: {
id: 'gid://gitlab/Discussion/c4be5bec43a737e0966dbc4c040b1517e7febfa9',
notes: {
nodes: [
{
id: 'gid://gitlab/DiscussionNote/2506',
body: 'test3',
bodyHtml: '<p data-sourcepos="1:1-1:5" dir="auto">test3</p>',
system: false,
internal: false,
systemNoteIconName: null,
createdAt: '2024-07-19T05:52:01Z',
lastEditedAt: '2024-07-26T10:06:02Z',
url: 'http://127.0.0.1:3000/flightjs/Flight/-/issues/134#note_2506',
authorIsContributor: false,
maxAccessLevelOfAuthor: 'Owner',
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/c4be5bec43a737e0966dbc4c040b1517e7febfa9',
resolved: true,
resolvable: true,
resolvedBy: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
__typename: 'UserCore',
},
__typename: 'Discussion',
},
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/258d8dc916db8cea2cafb6c3cd0cb0246efe061421dbd83ec3a350428cabda4f?s=80&d=identicon',
name: 'Administrator',
username: 'root',
webUrl: 'http://127.0.0.1:3000/root',
webPath: '/root',
__typename: 'UserCore',
},
awardEmoji: {
nodes: [],
__typename: 'AwardEmojiConnection',
},
userPermissions: {
adminNote: true,
awardEmoji: true,
readNote: true,
createNote: true,
resolveNote: true,
repositionNote: true,
__typename: 'NotePermissions',
},
systemNoteMetadata: null,
__typename: 'Note',
},
{
id: 'gid://gitlab/DiscussionNote/2539',
body: 'comment',
bodyHtml: '<p data-sourcepos="1:1-1:7" dir="auto">comment</p>',
system: false,
internal: false,
systemNoteIconName: null,
createdAt: '2024-07-23T05:07:46Z',
lastEditedAt: '2024-07-26T10:06:02Z',
url: 'http://127.0.0.1:3000/flightjs/Flight/-/issues/134#note_2539',
authorIsContributor: false,
maxAccessLevelOfAuthor: 'Owner',
lastEditedBy: null,
discussion: {
id: 'gid://gitlab/Discussion/c4be5bec43a737e0966dbc4c040b1517e7febfa9',
resolved: true,
resolvable: true,
resolvedBy: {
id: 'gid://gitlab/User/1',
name: 'Administrator',
__typename: 'UserCore',
},
__typename: 'Discussion',
},
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/258d8dc916db8cea2cafb6c3cd0cb0246efe061421dbd83ec3a350428cabda4f?s=80&d=identicon',
name: 'Administrator',
username: 'root',
webUrl: 'http://127.0.0.1:3000/root',
webPath: '/root',
__typename: 'UserCore',
},
awardEmoji: {
nodes: [],
__typename: 'AwardEmojiConnection',
},
userPermissions: {
adminNote: true,
awardEmoji: true,
readNote: true,
createNote: true,
resolveNote: true,
repositionNote: true,
__typename: 'NotePermissions',
},
systemNoteMetadata: null,
__typename: 'Note',
},
],
__typename: 'NoteConnection',
},
__typename: 'Discussion',
},
errors: [],
__typename: 'DiscussionToggleResolvePayload',
},
},
};

View File

@ -6,9 +6,9 @@ import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_
import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql';
import {
mockWorkItemNotesResponseWithComments,
mockAwardEmojiThumbsUp,
mockAwardEmojiThumbsDown,
mockWorkItemNotesResponseWithComments,
} from '../mock_data';
function getWorkItem(data) {

View File

@ -36,6 +36,10 @@ RSpec.describe Projects::PipelineHelper do
end
describe '#js_pipeline_header_data' do
before do
allow(helper).to receive(:current_user).and_return(user)
end
subject(:pipeline_header_data) { helper.js_pipeline_header_data(project, pipeline) }
it 'returns pipeline header data' do

View File

@ -80,6 +80,7 @@ issues:
- email
- issuable_resource_links
- synced_epic
- observability_metrics
work_item_type:
- issues
- namespace

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Prometheus::Metrics::DestroyService, feature_category: :metrics do
let(:metric) { create(:prometheus_metric) }
subject { described_class.new(metric) }
it 'destroys metric' do
subject.execute
expect(PrometheusMetric.find_by(id: metric.id)).to be_nil
end
end

View File

@ -34,7 +34,7 @@ require 'rspec-parameterized'
require 'shoulda/matchers'
require 'test_prof/recipes/rspec/let_it_be'
require 'test_prof/factory_default'
require 'test_prof/factory_prof/nate_heckler'
require 'test_prof/factory_prof/nate_heckler' if ENV.fetch('ENABLE_FACTORY_PROF', 'true') == 'true'
require 'parslet/rig/rspec'
require 'axe-rspec'

View File

@ -8075,7 +8075,6 @@
- './spec/services/projects/overwrite_project_service_spec.rb'
- './spec/services/projects/participants_service_spec.rb'
- './spec/services/projects/prometheus/alerts/notify_service_spec.rb'
- './spec/services/projects/prometheus/metrics/destroy_service_spec.rb'
- './spec/services/projects/protect_default_branch_service_spec.rb'
- './spec/services/projects/readme_renderer_service_spec.rb'
- './spec/services/projects/record_target_platforms_service_spec.rb'

View File

@ -49,4 +49,43 @@ RSpec.shared_examples 'rich text editor - common' do
expect(page).not_to have_text('An error occurred')
end
end
describe 'block content is added to a table' do
it 'converts a markdown table to HTML and shows a warning for it' do
click_on 'Add a table'
switch_to_content_editor
type_in_content_editor '* list item'
expect(page).to have_text(
"Tables containing block elements (like multiple paragraphs, lists or blockquotes) are not \
supported in Markdown and will be converted to HTML."
)
switch_to_markdown_editor
expect(page.find('textarea').value).to include '<table>
<tr>
<th>header</th>
<th>header</th>
</tr>
<tr>
<td>
</td>
<td>
</td>
</tr>
<tr>
<td>
</td>
<td>
* list item
</td>
</tr>
</table>'
end
end
end

View File

@ -170,7 +170,7 @@ RSpec.shared_examples 'work items rolled up dates' do
context 'when updating child work item dates' do
it 'rolled up child dates' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/473408
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(102)
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(105)
child_title = 'A child issue'
add_new_child(title: child_title, start_date: '2020-12-01', due_date: '2020-12-02')
@ -192,7 +192,7 @@ RSpec.shared_examples 'work items rolled up dates' do
context 'when removing all children' do
it 'rolled up child dates' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/473408
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(108)
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(114)
add_new_child(title: 'child issue 1', start_date: '2020-11-01', due_date: '2020-12-02')
add_new_child(title: 'child issue 2', start_date: '2020-12-01', due_date: '2021-01-02')
@ -249,6 +249,9 @@ RSpec.shared_examples 'work items rolled up dates' do
end
it 'rolled up child dates' do
# https://gitlab.com/gitlab-org/gitlab/-/issues/473408
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(101)
add_existing_child(child_work_item)
within work_item_rolledup_dates_selector do

View File

@ -524,7 +524,7 @@ RSpec.shared_examples 'work items parent' do |type|
find_and_click_edit(work_item_parent_selector)
find_and_click_clear(work_item_parent_selector, 'Unassign')
find_and_click_clear(work_item_parent_selector, 'Clear')
expect(find(work_item_parent_selector)).to have_content('None')
end

View File

@ -8,7 +8,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0
github.com/BurntSushi/toml v1.4.0
github.com/alecthomas/chroma/v2 v2.14.0
github.com/aws/aws-sdk-go v1.54.18
github.com/aws/aws-sdk-go v1.55.2
github.com/disintegration/imaging v1.6.2
github.com/dlclark/regexp2 v1.11.2
github.com/getsentry/raven-go v0.2.0
@ -20,12 +20,12 @@ require (
github.com/jpillora/backoff v1.0.0
github.com/mitchellh/copystructure v1.2.0
github.com/prometheus/client_golang v1.19.1-0.20240328134234-93cf5d4f5f78
github.com/redis/go-redis/v9 v9.5.3
github.com/redis/go-redis/v9 v9.6.1
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/goconvey v1.8.1
github.com/stretchr/testify v1.9.0
gitlab.com/gitlab-org/gitaly/v16 v16.11.6
gitlab.com/gitlab-org/gitaly/v16 v16.11.7
gitlab.com/gitlab-org/labkit v1.21.0
go.uber.org/goleak v1.3.0
gocloud.dev v0.37.0

View File

@ -96,8 +96,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/aws/aws-sdk-go v1.44.256/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go v1.54.18 h1:t8DGtN8A2wEiazoJxeDbfPsbxCKtjoRLuO7jBSgJzo4=
github.com/aws/aws-sdk-go v1.54.18/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go v1.55.2 h1:/2OFM8uFfK9e+cqHTw9YPrvTzIXT2XkFGXRM7WbJb7E=
github.com/aws/aws-sdk-go v1.55.2/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.25.3 h1:xYiLpZTQs1mzvz5PaI6uR0Wh57ippuEthxS4iK5v0n0=
github.com/aws/aws-sdk-go-v2 v1.25.3/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 h1:gTK2uhtAPtFcdRRJilZPx8uJLL2J85xK11nKtWL0wfU=
@ -417,8 +417,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/prometheus v0.50.1 h1:N2L+DYrxqPh4WZStU+o1p/gQlBaqFbcLBTjlp3vpdXw=
github.com/prometheus/prometheus v0.50.1/go.mod h1:FvE8dtQ1Ww63IlyKBn1V4s+zMwF9kHkVNkQBR1pM4CU=
github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU=
github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
@ -482,8 +482,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
gitlab.com/gitlab-org/gitaly/v16 v16.11.6 h1:4oOHqKO7/fCVrdyuONg4E6ZlX6vu3H7+YLdHQMfb1a8=
gitlab.com/gitlab-org/gitaly/v16 v16.11.6/go.mod h1:lJizRUtXRd1SBHjNbbbL9OsGN4TiugvfRBd8bIsdWI0=
gitlab.com/gitlab-org/gitaly/v16 v16.11.7 h1:csL3bPTHIEE0147aCo1HFgNLMgmHwlwr1Ns65OlcMuE=
gitlab.com/gitlab-org/gitaly/v16 v16.11.7/go.mod h1:lJizRUtXRd1SBHjNbbbL9OsGN4TiugvfRBd8bIsdWI0=
gitlab.com/gitlab-org/labkit v1.21.0 h1:hLmdBDtXjD1yOmZ+uJOac3a5Tlo83QaezwhES4IYik4=
gitlab.com/gitlab-org/labkit v1.21.0/go.mod h1:zeATDAaSBelPcPLbTTq8J3ZJEHyPTLVBM1q3nva+/W4=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=

View File

@ -1359,10 +1359,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.109.0.tgz#af953d8114768343034f1f02bc8e2d93eb613c65"
integrity sha512-MmBTsco2LIh/l16iJQy6R98YDOlE3C++AE0Z1+KCpAX/3+fLAmULx2sWp+JnmM0ws8J0LaeLN6+vWiPaEWA16Q==
"@gitlab/ui@87.4.0":
version "87.4.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-87.4.0.tgz#6261602c944801e0fda84d8a85199862ec327320"
integrity sha512-Ap6i17oNrV0pgjz+AFvsStZslizCZmLyUnyxAW5JGBKA1QpvR+2pgfnBjKyrpgoHy5kmVDQoOKy84PsErT0U1g==
"@gitlab/ui@87.6.1":
version "87.6.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-87.6.1.tgz#f6d9fad3689890d430fe179e3d58f8e75f00369e"
integrity sha512-5Vfl64zrJaXAeJDrPNtpv5yFygGqceucJb4Fe8RvMEiY9fbSwnwlU22BAv45b5mWEiY/C3wsURLDwNw16Xt2pg==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"