Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-27 21:11:07 +00:00
parent 3b060a68f3
commit 9da482ecb8
43 changed files with 1156 additions and 371 deletions

View file

@ -2,29 +2,35 @@
import '~/commons/bootstrap';
import {
GlIcon,
GlLink,
GlTooltip,
GlTooltipDirective,
GlButton,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueAssignees,
CiIcon,
GlIcon,
GlLink,
GlTooltip,
IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
IssueDueDate,
GlButton,
WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -47,6 +53,11 @@ export default {
required: false,
default: '',
},
workItemType: {
type: String,
required: false,
default: '',
},
},
computed: {
stateTitle() {
@ -62,6 +73,27 @@ export default {
iconClasses() {
return `${this.iconClass} ic-${this.iconName}`;
},
workItemId() {
return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey);
},
},
methods: {
handleTitleClick(event) {
if (this.workItemType === 'TASK') {
event.preventDefault();
this.$refs.modal.show();
this.updateWorkItemIdUrlQuery(this.idKey);
}
},
handleWorkItemDeleted(workItemId) {
this.$emit('relatedIssueRemoveRequest', workItemId);
},
updateWorkItemIdUrlQuery(workItemId) {
updateHistory({
url: setUrlParams({ work_item_id: workItemId }),
replace: true,
});
},
},
};
</script>
@ -102,7 +134,13 @@ export default {
class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
<gl-link
:href="computedPath"
class="sortable-link gl-font-weight-normal"
@click="handleTitleClick"
>
{{ title }}
</gl-link>
</div>
<!-- Info area: meta, path, and assignees -->
@ -178,16 +216,15 @@ export default {
<span
v-if="isLocked"
ref="lockIcon"
v-gl-tooltip
class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
data-testid="lockIcon"
>
<gl-icon name="lock" />
</span>
<gl-button
v-else-if="canRemove"
ref="removeButton"
v-gl-tooltip
icon="close"
category="tertiary"
@ -198,5 +235,11 @@ export default {
:aria-label="__('Remove')"
@click="onRemoveRequest"
/>
<work-item-detail-modal
ref="modal"
:work-item-id="workItemId"
@close="updateWorkItemIdUrlQuery"
@workItemDeleted="handleWorkItemDeleted"
/>
</div>
</template>

View file

@ -9,7 +9,7 @@ import { IssueType } from '~/issues/constants';
import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import initRelatedIssues from '~/related_issues';
import { initRelatedIssues } from '~/related_issues';
import {
initHeaderActions,
initIncidentApp,

View file

@ -326,6 +326,7 @@ export default {
}
const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
this.addHoverListeners(taskLink, workItemId);
taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
e.preventDefault();
this.openWorkItemDetailModal(taskLink);

View file

@ -5,12 +5,17 @@ import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
import Tracking from '~/tracking';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE,
TRACKING_ACTION_CLICK_PIPELINE_LINK,
TRACKING_ACTION_CLICK_COMMIT_LINK,
TRACKING_LABEL_PACKAGE_HISTORY,
} from '../../constants';
import getPackagePipelinesQuery from '../../graphql/queries/get_package_pipelines.query.graphql';
import PackageHistoryLoader from './package_history_loader.vue';
@ -37,6 +42,9 @@ export default {
PackageHistoryLoader,
TimeAgoTooltip,
},
mixins: [Tracking.mixin()],
TRACKING_ACTION_CLICK_PIPELINE_LINK,
TRACKING_ACTION_CLICK_COMMIT_LINK,
props: {
packageEntity: {
type: Object,
@ -97,6 +105,11 @@ export default {
first: GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
};
},
tracking() {
return {
category: packageTypeToTrackCategory(this.packageType),
};
},
},
methods: {
truncate(value) {
@ -105,6 +118,12 @@ export default {
convertToBaseId(value) {
return getIdFromGraphQLId(value);
},
trackPipelineClick() {
this.track(TRACKING_ACTION_CLICK_PIPELINE_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
},
trackCommitClick() {
this.track(TRACKING_ACTION_CLICK_COMMIT_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
},
},
};
</script>
@ -140,7 +159,9 @@ export default {
<history-item icon="commit" data-testid="first-pipeline-commit">
<gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
<gl-link :href="firstPipeline.commitPath">#{{ truncate(firstPipeline.sha) }}</gl-link>
<gl-link :href="firstPipeline.commitPath" @click="trackCommitClick"
>#{{ truncate(firstPipeline.sha) }}</gl-link
>
</template>
<template #branch>
<strong>{{ firstPipeline.ref }}</strong>
@ -150,7 +171,9 @@ export default {
<history-item icon="pipeline" data-testid="first-pipeline-pipeline">
<gl-sprintf :message="$options.i18n.createdByPipelineText">
<template #link>
<gl-link :href="firstPipeline.path">#{{ convertToBaseId(firstPipeline.id) }}</gl-link>
<gl-link :href="firstPipeline.path" @click="trackPipelineClick"
>#{{ convertToBaseId(firstPipeline.id) }}</gl-link
>
</template>
<template #datetime>
<time-ago-tooltip :time="firstPipeline.createdAt" />
@ -189,13 +212,17 @@ export default {
>
<gl-sprintf :message="$options.i18n.combinedUpdateText">
<template #link>
<gl-link :href="pipeline.commitPath">#{{ truncate(pipeline.sha) }}</gl-link>
<gl-link :href="pipeline.commitPath" @click="trackCommitClick"
>#{{ truncate(pipeline.sha) }}</gl-link
>
</template>
<template #branch>
<strong>{{ pipeline.ref }}</strong>
</template>
<template #pipeline>
<gl-link :href="pipeline.path">#{{ convertToBaseId(pipeline.id) }}</gl-link>
<gl-link :href="pipeline.path" @click="trackPipelineClick"
>#{{ convertToBaseId(pipeline.id) }}</gl-link
>
</template>
<template #datetime>
<time-ago-tooltip :time="pipeline.createdAt" />

View file

@ -69,6 +69,11 @@ export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
export const TRACKING_ACTION_CLICK_PIPELINE_LINK = 'click_pipeline_link_from_package';
export const TRACKING_ACTION_CLICK_COMMIT_LINK = 'click_commit_link_from_package';
export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history';
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',

View file

@ -1,6 +1,6 @@
import { initShow } from '~/issues';
import { store } from '~/notes/stores';
import initRelatedIssues from '~/related_issues';
import { initRelatedIssues } from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initWorkItemLinks from '~/work_items/components/work_item_links';

View file

@ -5,7 +5,6 @@ import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue
import { defaultSortableOptions } from '~/sortable/constants';
export default {
name: 'RelatedIssuesList',
components: {
GlLoadingIcon,
RelatedIssuableItem,
@ -141,6 +140,7 @@ export default {
:path-id-separator="pathIdSeparator"
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
:work-item-type="issue.type"
event-namespace="relatedIssue"
data-qa-selector="related_issuable_content"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"

View file

@ -24,6 +24,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/
import createFlash from '~/flash';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
relatedIssuesRemoveErrorMap,
@ -123,6 +124,14 @@ export default {
return this.state.relatedIssues.find((issue) => issue.id === id);
},
onRelatedIssueRemoveRequest(idToRemove) {
if (isGid(idToRemove)) {
const deletedId = getIdFromGraphQLId(idToRemove);
this.state.relatedIssues = this.state.relatedIssues.filter(
(issue) => issue.id !== deletedId,
);
return;
}
const issueToRemove = this.findRelatedIssueById(idToRemove);
if (issueToRemove) {

View file

@ -1,30 +1,33 @@
import Vue from 'vue';
import apolloProvider from '~/issues/show/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedIssuesRoot from './components/related_issues_root.vue';
export default function initRelatedIssues(issueType = 'issue') {
const relatedIssuesRootElement = document.querySelector('.js-related-issues-root');
if (relatedIssuesRootElement) {
// eslint-disable-next-line no-new
new Vue({
el: relatedIssuesRootElement,
name: 'RelatedIssuesRoot',
components: {
relatedIssuesRoot: RelatedIssuesRoot,
},
render: (createElement) =>
createElement('related-issues-root', {
props: {
endpoint: relatedIssuesRootElement.dataset.endpoint,
canAdmin: parseBoolean(relatedIssuesRootElement.dataset.canAddRelatedIssues),
helpPath: relatedIssuesRootElement.dataset.helpPath,
showCategorizedIssues: parseBoolean(
relatedIssuesRootElement.dataset.showCategorizedIssues,
),
issuableType: issueType,
autoCompleteEpics: false,
},
}),
});
export function initRelatedIssues(issueType = 'issue') {
const el = document.querySelector('.js-related-issues-root');
if (!el) {
return null;
}
return new Vue({
el,
name: 'RelatedIssuesRoot',
apolloProvider,
provide: {
fullPath: el.dataset.fullPath,
hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
},
render: (createElement) =>
createElement(RelatedIssuesRoot, {
props: {
endpoint: el.dataset.endpoint,
canAdmin: parseBoolean(el.dataset.canAddRelatedIssues),
helpPath: el.dataset.helpPath,
showCategorizedIssues: parseBoolean(el.dataset.showCategorizedIssues),
issuableType: issueType,
autoCompleteEpics: false,
},
}),
});
}

View file

@ -2,9 +2,13 @@
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from './work_item_detail.vue';
export default {
i18n: {
errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'),
},
components: {
GlAlert,
GlModal,
@ -45,6 +49,13 @@ export default {
},
methods: {
deleteWorkItem() {
if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
this.deleteWorkItemWithTaskData();
} else {
this.deleteWorkItemWithoutTaskData();
}
},
deleteWorkItemWithTaskData() {
this.$apollo
.mutate({
mutation: deleteWorkItemFromTaskMutation,
@ -70,17 +81,33 @@ export default {
},
}) => {
if (errors?.length) {
throw new Error(errors[0].message);
throw new Error(errors[0]);
}
this.$emit('workItemDeleted', descriptionHtml);
this.$refs.modal.hide();
this.hide();
},
)
.catch((e) => {
this.error =
e.message ||
s__('WorkItem|Something went wrong when deleting the task. Please try again.');
.catch((error) => {
this.setErrorMessage(error.message);
});
},
deleteWorkItemWithoutTaskData() {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
variables: { input: { id: this.workItemId } },
})
.then(({ data }) => {
if (data.workItemDelete.errors?.length) {
throw new Error(data.workItemDelete.errors[0]);
}
this.$emit('workItemDeleted', this.workItemId);
this.hide();
})
.catch((error) => {
this.setErrorMessage(error.message);
});
},
closeModal() {
@ -91,7 +118,7 @@ export default {
this.$refs.modal.hide();
},
setErrorMessage(message) {
this.error = message;
this.error = message || this.$options.i18n.errorMessage;
},
show() {
this.$refs.modal.show();

View file

@ -1,5 +1,7 @@
- if can?(current_user, :read_issue_link, @project)
.js-related-issues-root{ data: { endpoint: project_issue_links_path(@project, @issue),
can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}",
full_path: @project.full_path,
has_issue_weights_feature: @project.licensed_feature_available?(:issue_weights).to_s,
help_path: help_page_path('user/project/issues/related_issues'),
show_categorized_issues: "false" } }
show_categorized_issues: @project.licensed_feature_available?(:blocked_issues).to_s } }

View file

@ -19,7 +19,7 @@
= render_if_exists 'projects/issues/work_item_links'
= render_if_exists 'projects/issues/linked_resources'
= render_if_exists 'projects/issues/related_issues'
= render 'projects/issues/related_issues'
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }

View file

@ -86,6 +86,12 @@
- 'i_code_review_merge_request_widget_test_summary_expand_success'
- 'i_code_review_merge_request_widget_test_summary_expand_warning'
- 'i_code_review_merge_request_widget_test_summary_expand_failed'
- 'i_code_review_merge_request_widget_accessibility_view'
- 'i_code_review_merge_request_widget_accessibility_full_report_clicked'
- 'i_code_review_merge_request_widget_accessibility_expand'
- 'i_code_review_merge_request_widget_accessibility_expand_success'
- 'i_code_review_merge_request_widget_accessibility_expand_warning'
- 'i_code_review_merge_request_widget_accessibility_expand_failed'
- name: code_review_category_monthly_active_users
operator: OR
source: redis
@ -160,6 +166,12 @@
- 'i_code_review_merge_request_widget_test_summary_expand_success'
- 'i_code_review_merge_request_widget_test_summary_expand_warning'
- 'i_code_review_merge_request_widget_test_summary_expand_failed'
- 'i_code_review_merge_request_widget_accessibility_view'
- 'i_code_review_merge_request_widget_accessibility_full_report_clicked'
- 'i_code_review_merge_request_widget_accessibility_expand'
- 'i_code_review_merge_request_widget_accessibility_expand_success'
- 'i_code_review_merge_request_widget_accessibility_expand_warning'
- 'i_code_review_merge_request_widget_accessibility_expand_failed'
- name: code_review_extension_category_monthly_active_users
operator: OR
source: redis

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_view_monthly
description: The count of unique users (monthly) who were able to see the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_view
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_full_report_clicked_monthly
description: The count of unique users (monthly) who clicked the Full Report button on the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_full_report_clicked
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_monthly
description: The count of unique users (monthly) who expanded the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_success_monthly
description: The count of unique users (monthly) who expanded the Accessibility widget extension while it is in its Success state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_success
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_warning_monthly
description: The count of unique users (monthly) who expanded the Accessibility widget extension while it is in its Warning state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_warning
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_failed_monthly
description: The count of unique users (monthly) who expanded the Accessibility widget extension while it is in its Failed state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 28d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_failed
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_view_weekly
description: The count of unique users (weekly) who were able to see the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_view
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_full_report_clicked_weekly
description: The count of unique users (weekly) who clicked the Full Report button on the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_full_report_clicked
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_weekly
description: The count of unique users (weekly) who expanded the Accessibility widget extension
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_success_weekly
description: The count of unique users (weekly) who expanded the Accessibility widget extension while it is in its Success state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_success
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_warning_weekly
description: The count of unique users (weekly) who expanded the Accessibility widget extension while it is in its Warning state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_warning
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
key_path: redis_hll_counters.code_review.i_code_review_merge_request_widget_accessibility_expand_failed_weekly
description: The count of unique users (weekly) who expanded the Accessibility widget extension while it is in its Failed state
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: 7d
data_source: redis_hll
data_category: optional
instrumentation_class: RedisHLLMetric
options:
events:
- i_code_review_merge_request_widget_accessibility_expand_failed
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_view
description: Total number of times the Accessibility widget extension was viewed (rendered to the screen)
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_view
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_full_report_clicked
description: Total number of times the Accessibility widget extension Full Report button was clicked
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_full_report_clicked
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_expand
description: Total number of times the Accessibility widget extension was expanded (in any state)
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_expand
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_expand_success
description: Total number of times the Accessibility widget extension was expanded (while in its Success state)
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_expand_success
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_expand_warning
description: Total number of times the Accessibility widget extension was expanded (while in its Warning state)
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_expand_warning
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,24 @@
---
key_path: counts.i_code_review_merge_request_widget_accessibility_count_expand_failed
description: Total number of times the Accessibility widget extension was expanded (while in its Failed state)
product_section: dev
product_stage: create
product_group: code_review
product_category: code_review
value_type: number
status: active
milestone: "15.3"
introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93232"
time_frame: all
data_source: redis
data_category: optional
options:
events:
- i_code_review_merge_request_widget_accessibility_count_expand_failed
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class UpdateVulnerabilitiesProjectIdIdIndex < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
NEW_INDEX_NAME = 'idx_vulnerabilities_partial_devops_adoption_and_default_branch'
OLD_INDEX_NAME = 'idx_vulnerabilities_partial_devops_adoption'
def up
add_concurrent_index :vulnerabilities, [:project_id, :created_at, :present_on_default_branch],
where: 'state != 1',
name: NEW_INDEX_NAME
remove_concurrent_index_by_name(:vulnerabilities, OLD_INDEX_NAME)
end
def down
add_concurrent_index :vulnerabilities, [:project_id, :created_at], where: 'state != 1', name: OLD_INDEX_NAME
remove_concurrent_index_by_name(:vulnerabilities, NEW_INDEX_NAME)
end
end

View file

@ -0,0 +1 @@
4a618d15ee56e7cb9a20385824cc63cf12f8a2eb3604c787f79356398094a3b6

View file

@ -27241,7 +27241,7 @@ CREATE UNIQUE INDEX idx_vuln_signatures_on_occurrences_id_and_signature_sha ON v
CREATE UNIQUE INDEX idx_vuln_signatures_uniqueness_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, algorithm_type, signature_sha);
CREATE INDEX idx_vulnerabilities_partial_devops_adoption ON vulnerabilities USING btree (project_id, created_at) WHERE (state <> 1);
CREATE INDEX idx_vulnerabilities_partial_devops_adoption_and_default_branch ON vulnerabilities USING btree (project_id, created_at, present_on_default_branch) WHERE (state <> 1);
CREATE UNIQUE INDEX idx_vulnerability_ext_issue_links_on_vulne_id_and_ext_issue ON vulnerability_external_issue_links USING btree (vulnerability_id, external_type, external_project_key, external_issue_key);

View file

@ -97,16 +97,6 @@ Example response:
"type": "development",
"group": "group::geo",
"default_enabled": true
},
{
"name": "analytics_devops_adoption_codeowners",
"introduced_by_url": "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59874",
"rollout_issue_url": "https://gitlab.com/gitlab-org/gitlab/-/issues/328542",
"milestone": "13.12",
"log_state_changes": null,
"type": "development",
"group": "group::optimize",
"default_enabled": true
}
]
```

View file

@ -45,6 +45,12 @@ Do not include the deprecation announcement in the merge request that introduces
Use a separate MR to create a deprecation entry. For steps to create a deprecation entry, see
[Deprecations](https://about.gitlab.com/handbook/marketing/blog/release-posts/#deprecations).
## How are Community Contributions to a deprecated feature handled?
Development on deprecated features is restricted to Priority 1 / Severity 1 bug fixes. Any community contributions to deprecated features are unlikely to be prioritized during milestone planning.
However, at GitLab, we [give agency](https://about.gitlab.com/handbook/values/#give-agency) to our team members. So, a member of the team associated with the contribution may decide to review and merge it at their discretion.
## When can a feature be removed/changed?
Generally, feature or configuration can be removed/changed only on major release.

View file

@ -325,3 +325,28 @@
redis_slot: code_review
category: code_review
aggregation: weekly
## Accessibility
- name: i_code_review_merge_request_widget_accessibility_view
redis_slot: code_review
category: code_review
aggregation: weekly
- name: i_code_review_merge_request_widget_accessibility_full_report_clicked
redis_slot: code_review
category: code_review
aggregation: weekly
- name: i_code_review_merge_request_widget_accessibility_expand
redis_slot: code_review
category: code_review
aggregation: weekly
- name: i_code_review_merge_request_widget_accessibility_expand_success
redis_slot: code_review
category: code_review
aggregation: weekly
- name: i_code_review_merge_request_widget_accessibility_expand_warning
redis_slot: code_review
category: code_review
aggregation: weekly
- name: i_code_review_merge_request_widget_accessibility_expand_failed
redis_slot: code_review
category: code_review
aggregation: weekly

View file

@ -5,7 +5,7 @@ module Gitlab
class MergeRequestWidgetExtensionCounter < BaseCounter
KNOWN_EVENTS = %w[view full_report_clicked expand expand_success expand_warning expand_failed].freeze
PREFIX = 'i_code_review_merge_request_widget'
WIDGETS = %w[test_summary].freeze
WIDGETS = %w[accessibility test_summary].freeze
class << self
private

View file

@ -1,23 +1,25 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlIcon, GlLink, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
}));
describe('RelatedIssuableItem', () => {
let wrapper;
function mountComponent({ mountMethod = mount, stubs = {}, props = {}, slots = {} } = {}) {
wrapper = mountMethod(RelatedIssuableItem, {
propsData: props,
slots,
stubs,
});
}
const props = {
const defaultProps = {
idKey: 1,
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
@ -31,84 +33,94 @@ describe('RelatedIssuableItem', () => {
assignees: defaultAssignees,
eventNamespace: 'relatedIssue',
};
const slots = {
dueDate: '<div class="js-due-date-slot"></div>',
weight: '<div class="js-weight-slot"></div>',
};
const findRemoveButton = () => wrapper.find({ ref: 'removeButton' });
const findLockIcon = () => wrapper.find({ ref: 'lockIcon' });
const findIcon = () => wrapper.findComponent(GlIcon);
const findIssueDueDate = () => wrapper.findComponent(IssueDueDate);
const findLockIcon = () => wrapper.find('[data-testid="lockIcon"]');
const findRemoveButton = () => wrapper.findComponent(GlButton);
const findTitleLink = () => wrapper.findComponent(GlLink);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
beforeEach(() => {
mountComponent({ props, slots });
});
function mountComponent({ data = {}, props = {} } = {}) {
wrapper = shallowMount(RelatedIssuableItem, {
propsData: {
...defaultProps,
...props,
},
data() {
return data;
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('contains issuable-info-container class when canReorder is false', () => {
expect(wrapper.props('canReorder')).toBe(false);
expect(wrapper.find('.issuable-info-container').exists()).toBe(true);
mountComponent({ props: { canReorder: false } });
expect(wrapper.classes('issuable-info-container')).toBe(true);
});
it('does not render token state', () => {
mountComponent();
expect(wrapper.find('.text-secondary svg').exists()).toBe(false);
});
it('does not render remove button', () => {
expect(wrapper.find({ ref: 'removeButton' }).exists()).toBe(false);
mountComponent();
expect(findRemoveButton().exists()).toBe(false);
});
describe('token title', () => {
beforeEach(() => {
mountComponent();
});
it('links to computedPath', () => {
expect(wrapper.find('.item-title a').attributes('href')).toEqual(wrapper.props('path'));
expect(findTitleLink().attributes('href')).toBe(defaultProps.path);
});
it('renders confidential icon', () => {
expect(wrapper.find('.confidential-icon').exists()).toBe(true);
expect(findIcon().attributes('title')).toBe(__('Confidential'));
});
it('renders title', () => {
expect(wrapper.find('.item-title a').text()).toEqual(props.title);
expect(findTitleLink().text()).toBe(defaultProps.title);
});
});
describe('token state', () => {
const tokenState = () => wrapper.find({ ref: 'iconElementXL' });
beforeEach(() => {
wrapper.setProps({ state: 'opened' });
});
it('renders if hasState', () => {
expect(tokenState().exists()).toBe(true);
});
it('renders state title', () => {
const stateTitle = tokenState().attributes('title');
const formattedCreateDate = formatDate(props.createdAt);
mountComponent({ props: { state: 'opened' } });
const stateTitle = findIcon().attributes('title');
const formattedCreateDate = formatDate(defaultProps.createdAt);
expect(stateTitle).toContain('<span class="bold">Created</span>');
expect(stateTitle).toContain(`<span class="text-tertiary">${formattedCreateDate}</span>`);
});
it('renders aria label', () => {
expect(tokenState().attributes('aria-label')).toEqual('opened');
mountComponent({ props: { state: 'opened' } });
expect(findIcon().attributes('arialabel')).toBe('opened');
});
it('renders open icon when open state', () => {
expect(tokenState().classes('issue-token-state-icon-open')).toBe(true);
mountComponent({ props: { state: 'opened' } });
expect(findIcon().props('name')).toBe('issue-open-m');
expect(findIcon().classes('issue-token-state-icon-open')).toBe(true);
});
it('renders close icon when close state', async () => {
wrapper.setProps({
state: 'closed',
closedAt: '2018-12-01T00:00:00.00Z',
});
await nextTick();
it('renders close icon when close state', () => {
mountComponent({ props: { state: 'closed', closedAt: '2018-12-01T00:00:00.00Z' } });
expect(tokenState().classes('issue-token-state-icon-closed')).toBe(true);
expect(findIcon().props('name')).toBe('issue-close');
expect(findIcon().classes('issue-token-state-icon-closed')).toBe(true);
});
});
@ -116,75 +128,66 @@ describe('RelatedIssuableItem', () => {
const tokenMetadata = () => wrapper.find('.item-meta');
it('renders item path and ID', () => {
mountComponent();
const pathAndID = tokenMetadata().find('.item-path-id').text();
expect(pathAndID).toContain('gitlab-org/gitlab-test');
expect(pathAndID).toContain('#1');
});
it('renders milestone icon and name', () => {
const milestoneIcon = tokenMetadata().find('.item-milestone svg');
const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title');
it('renders milestone', () => {
mountComponent();
expect(milestoneIcon.attributes('data-testid')).toBe('clock-icon');
expect(milestoneTitle.text()).toContain('Milestone title');
expect(wrapper.findComponent(IssueMilestone).props('milestone')).toEqual(
defaultProps.milestone,
);
});
it('renders due date component with correct due date', () => {
expect(wrapper.find(IssueDueDate).props('date')).toBe(props.dueDate);
mountComponent();
expect(findIssueDueDate().props('date')).toBe(defaultProps.dueDate);
});
it('does not render red icon for overdue issue that is closed', async () => {
mountComponent({
props: {
...props,
closedAt: '2018-12-01T00:00:00.00Z',
},
});
await nextTick();
it('does not render red icon for overdue issue that is closed', () => {
mountComponent({ props: { closedAt: '2018-12-01T00:00:00.00Z' } });
expect(wrapper.find(IssueDueDate).props('closed')).toBe(true);
expect(findIssueDueDate().props('closed')).toBe(true);
});
});
describe('token assignees', () => {
it('renders assignees avatars', () => {
// Expect 2 times 2 because assignees are rendered twice, due to layout issues
expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBeDefined();
mountComponent();
expect(wrapper.find('.item-assignees .avatar-counter').text()).toContain('+2');
expect(wrapper.findComponent(IssueAssignees).props('assignees')).toEqual(
defaultProps.assignees,
);
});
});
describe('remove button', () => {
beforeEach(() => {
wrapper.setProps({ canRemove: true });
mountComponent({ props: { canRemove: true }, data: { removeDisabled: true } });
});
it('renders if canRemove', () => {
expect(findRemoveButton().exists()).toBe(true);
expect(findRemoveButton().props('icon')).toBe('close');
expect(findRemoveButton().attributes('aria-label')).toBe(__('Remove'));
});
it('does not render the lock icon', () => {
expect(findLockIcon().exists()).toBe(false);
});
it('renders disabled button when removeDisabled', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ removeDisabled: true });
await nextTick();
expect(findRemoveButton().attributes('disabled')).toEqual('disabled');
it('renders disabled button when removeDisabled', () => {
expect(findRemoveButton().attributes('disabled')).toBe('true');
});
it('triggers onRemoveRequest when clicked', async () => {
findRemoveButton().trigger('click');
await nextTick();
const { relatedIssueRemoveRequest } = wrapper.emitted();
it('triggers onRemoveRequest when clicked', () => {
findRemoveButton().vm.$emit('click');
expect(relatedIssueRemoveRequest.length).toBe(1);
expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]);
expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[defaultProps.idKey]]);
});
});
@ -192,10 +195,7 @@ describe('RelatedIssuableItem', () => {
const lockedMessage = 'Issues created from a vulnerability cannot be removed';
beforeEach(() => {
wrapper.setProps({
isLocked: true,
lockedMessage,
});
mountComponent({ props: { isLocked: true, lockedMessage } });
});
it('does not render the remove button', () => {
@ -206,4 +206,67 @@ describe('RelatedIssuableItem', () => {
expect(findLockIcon().attributes('title')).toBe(lockedMessage);
});
});
describe('work item modal', () => {
const workItem = 'gid://gitlab/WorkItem/1';
it('renders', () => {
mountComponent();
expect(findWorkItemDetailModal().props('workItemId')).toBe(workItem);
});
describe('when work item is issue and the related issue title is clicked', () => {
it('does not open', () => {
mountComponent({ props: { workItemType: 'ISSUE' } });
wrapper.vm.$refs.modal.show = jest.fn();
findTitleLink().vm.$emit('click', { preventDefault: () => {} });
expect(wrapper.vm.$refs.modal.show).not.toHaveBeenCalled();
});
});
describe('when work item is task and the related issue title is clicked', () => {
beforeEach(() => {
mountComponent({ props: { workItemType: 'TASK' } });
wrapper.vm.$refs.modal.show = jest.fn();
findTitleLink().vm.$emit('click', { preventDefault: () => {} });
});
it('opens', () => {
expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled();
});
it('updates the url params with the work item id', () => {
expect(updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/?work_item_id=1`,
replace: true,
});
});
});
describe('when it emits "workItemDeleted" event', () => {
it('emits "relatedIssueRemoveRequest" event', () => {
mountComponent();
findWorkItemDetailModal().vm.$emit('workItemDeleted', workItem);
expect(wrapper.emitted('relatedIssueRemoveRequest')).toEqual([[workItem]]);
});
});
describe('when it emits "close" event', () => {
it('removes the work item id from the url params', () => {
mountComponent();
findWorkItemDetailModal().vm.$emit('close');
expect(updateHistory).toHaveBeenCalledWith({
url: `${TEST_HOST}/`,
replace: true,
});
});
});
});
});

View file

@ -1,4 +1,4 @@
import { mount, shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
@ -9,8 +9,9 @@ import {
} from 'jest/issuable/components/related_issuable_mock_data';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
import { linkedIssueTypesMap } from '~/related_issues/constants';
import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
import relatedIssuesService from '~/related_issues/services/related_issues_service';
jest.mock('~/flash');
@ -19,6 +20,8 @@ describe('RelatedIssuesRoot', () => {
let wrapper;
let mock;
const findRelatedIssuesBlock = () => wrapper.findComponent(RelatedIssuesBlock);
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(defaultProps.endpoint).reply(200, []);
@ -26,100 +29,114 @@ describe('RelatedIssuesRoot', () => {
afterEach(() => {
mock.restore();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
wrapper.destroy();
});
const createComponent = (mountFn = mount) => {
wrapper = mountFn(RelatedIssuesRoot, {
propsData: defaultProps,
const createComponent = ({ props = {}, data = {} } = {}) => {
wrapper = mount(RelatedIssuesRoot, {
propsData: {
...defaultProps,
...props,
},
data() {
return data;
},
});
// Wait for fetch request `fetchRelatedIssues` to complete before starting to test
return waitForPromises();
};
describe('methods', () => {
describe('onRelatedIssueRemoveRequest', () => {
beforeEach(() => {
jest
.spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues')
.mockReturnValue(Promise.reject());
return createComponent().then(() => {
describe('events', () => {
describe('when "relatedIssueRemoveRequest" event is emitted', () => {
describe('when emitted value is a numerical issue', () => {
beforeEach(async () => {
jest
.spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues')
.mockReturnValue(Promise.reject());
await createComponent();
wrapper.vm.store.setRelatedIssues([issuable1]);
});
});
it('remove related issue and succeeds', () => {
mock.onDelete(issuable1.referencePath).reply(200, { issues: [] });
it('removes related issue on API success', async () => {
mock.onDelete(issuable1.referencePath).reply(200, { issues: [] });
wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id);
findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', issuable1.id);
await axios.waitForAll();
return axios.waitForAll().then(() => {
expect(wrapper.vm.state.relatedIssues).toEqual([]);
expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([]);
});
it('does not remove related issue on API error', async () => {
mock.onDelete(issuable1.referencePath).reply(422, {});
findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', issuable1.id);
await axios.waitForAll();
expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([
expect.objectContaining({ id: issuable1.id }),
]);
});
});
it('remove related issue, fails, and restores to related issues', () => {
mock.onDelete(issuable1.referencePath).reply(422, {});
describe('when emitted value is a work item id', () => {
it('removes related issue', async () => {
const workItem = `gid://gitlab/WorkItem/${issuable1.id}`;
createComponent({ data: { state: { relatedIssues: [issuable1] } } });
wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id);
findRelatedIssuesBlock().vm.$emit('relatedIssueRemoveRequest', workItem);
await nextTick();
return axios.waitForAll().then(() => {
expect(wrapper.vm.state.relatedIssues).toHaveLength(1);
expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([]);
});
});
});
describe('onToggleAddRelatedIssuesForm', () => {
beforeEach(() => createComponent(shallowMount));
describe('when "toggleAddRelatedIssuesForm" event is emitted', () => {
it('toggles related issues form to visible from hidden', async () => {
createComponent();
it('toggle related issues form to visible', () => {
wrapper.vm.onToggleAddRelatedIssuesForm();
findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm');
await nextTick();
expect(wrapper.vm.isFormVisible).toEqual(true);
expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(true);
});
it('show add related issues form to hidden', () => {
wrapper.vm.isFormVisible = true;
it('toggles related issues form to hidden from visible', async () => {
createComponent({ data: { isFormVisible: true } });
wrapper.vm.onToggleAddRelatedIssuesForm();
findRelatedIssuesBlock().vm.$emit('toggleAddRelatedIssuesForm');
await nextTick();
expect(wrapper.vm.isFormVisible).toEqual(false);
expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false);
});
});
describe('onPendingIssueRemoveRequest', () => {
beforeEach(() =>
createComponent().then(() => {
wrapper.vm.store.setPendingReferences([issuable1.reference]);
}),
);
it('remove pending related issue', () => {
expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
wrapper.vm.onPendingIssueRemoveRequest(0);
expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
});
});
describe('onPendingFormSubmit', () => {
describe('when "pendingIssuableRemoveRequest" event is emitted', () => {
beforeEach(() => {
createComponent();
wrapper.vm.store.setPendingReferences([issuable1.reference]);
});
it('removes pending related issue', async () => {
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(1);
findRelatedIssuesBlock().vm.$emit('pendingIssuableRemoveRequest', 0);
await nextTick();
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0);
});
});
describe('when "addIssuableFormSubmit" event is emitted', () => {
beforeEach(async () => {
jest
.spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues')
.mockReturnValue(Promise.reject());
return createComponent().then(() => {
jest.spyOn(wrapper.vm, 'processAllReferences');
jest.spyOn(wrapper.vm.service, 'addRelatedIssues');
createFlash.mockClear();
});
await createComponent();
jest.spyOn(wrapper.vm, 'processAllReferences');
jest.spyOn(wrapper.vm.service, 'addRelatedIssues');
createFlash.mockClear();
});
it('processes references before submitting', () => {
@ -130,23 +147,22 @@ describe('RelatedIssuesRoot', () => {
linkedIssueType,
};
wrapper.vm.onPendingFormSubmit(emitObj);
findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', emitObj);
expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input);
expect(wrapper.vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType);
});
it('submit zero pending issue as related issue', () => {
it('submits zero pending issues as related issue', () => {
wrapper.vm.store.setPendingReferences([]);
wrapper.vm.onPendingFormSubmit({});
return waitForPromises().then(() => {
expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
expect(wrapper.vm.state.relatedIssues).toHaveLength(0);
});
findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {});
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0);
expect(findRelatedIssuesBlock().props('relatedIssues')).toHaveLength(0);
});
it('submit pending issue as related issue', () => {
it('submits pending issue as related issue', async () => {
mock.onPost(defaultProps.endpoint).reply(200, {
issuables: [issuable1],
result: {
@ -154,18 +170,18 @@ describe('RelatedIssuesRoot', () => {
status: 'success',
},
});
wrapper.vm.store.setPendingReferences([issuable1.reference]);
wrapper.vm.onPendingFormSubmit({});
return waitForPromises().then(() => {
expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
expect(wrapper.vm.state.relatedIssues).toHaveLength(1);
expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
});
findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {});
await waitForPromises();
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0);
expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([
expect.objectContaining({ id: issuable1.id }),
]);
});
it('submit multiple pending issues as related issues', () => {
it('submits multiple pending issues as related issues', async () => {
mock.onPost(defaultProps.endpoint).reply(200, {
issuables: [issuable1, issuable2],
result: {
@ -173,202 +189,149 @@ describe('RelatedIssuesRoot', () => {
status: 'success',
},
});
wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
wrapper.vm.onPendingFormSubmit({});
return waitForPromises().then(() => {
expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
expect(wrapper.vm.state.relatedIssues).toHaveLength(2);
expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id);
});
findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', {});
await waitForPromises();
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0);
expect(findRelatedIssuesBlock().props('relatedIssues')).toEqual([
expect.objectContaining({ id: issuable1.id }),
expect.objectContaining({ id: issuable2.id }),
]);
});
it('displays a message from the backend upon error', () => {
it('displays a message from the backend upon error', async () => {
const input = '#123';
const message = 'error';
mock.onPost(defaultProps.endpoint).reply(409, { message });
wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]);
expect(createFlash).not.toHaveBeenCalled();
wrapper.vm.onPendingFormSubmit(input);
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
message,
});
});
findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input);
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message });
});
});
describe('onPendingFormCancel', () => {
beforeEach(() =>
createComponent().then(() => {
wrapper.vm.isFormVisible = true;
wrapper.vm.inputValue = 'foo';
}),
);
it('when canceling and hiding add issuable form', async () => {
wrapper.vm.onPendingFormCancel();
describe('when "addIssuableFormCancel" event is emitted', () => {
beforeEach(() => createComponent({ data: { isFormVisible: true, inputValue: 'foo' } }));
it('hides form and resets input', async () => {
findRelatedIssuesBlock().vm.$emit('addIssuableFormCancel');
await nextTick();
expect(wrapper.vm.isFormVisible).toEqual(false);
expect(wrapper.vm.inputValue).toEqual('');
expect(wrapper.vm.state.pendingReferences).toHaveLength(0);
expect(findRelatedIssuesBlock().props('isFormVisible')).toBe(false);
expect(findRelatedIssuesBlock().props('inputValue')).toBe('');
expect(findRelatedIssuesBlock().props('pendingReferences')).toHaveLength(0);
});
});
describe('fetchRelatedIssues', () => {
beforeEach(() => createComponent());
it('sets isFetching while fetching', async () => {
wrapper.vm.fetchRelatedIssues();
expect(wrapper.vm.isFetching).toEqual(true);
await waitForPromises();
expect(wrapper.vm.isFetching).toEqual(false);
});
it('should fetch related issues', async () => {
mock.onGet(defaultProps.endpoint).reply(200, [issuable1, issuable2]);
wrapper.vm.fetchRelatedIssues();
await waitForPromises();
expect(wrapper.vm.state.relatedIssues).toHaveLength(2);
expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id);
expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id);
});
});
describe('onInput', () => {
beforeEach(() => createComponent());
it('fill in issue number reference and adds to pending related issues', () => {
describe('when "addIssuableFormInput" event is emitted', () => {
it('updates pending references with issue reference', async () => {
const input = '#123 ';
wrapper.vm.onInput({
createComponent();
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: [input.trim()],
touchedReference: input,
});
await nextTick();
expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123');
expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]);
});
it('fill in with full reference', () => {
it('updates pending references with full reference', async () => {
const input = 'asdf/qwer#444 ';
wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
createComponent();
expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: [input.trim()],
touchedReference: input,
});
await nextTick();
expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([input.trim()]);
});
it('fill in with issue link', () => {
it('updates pending references with issue link', async () => {
const link = 'http://localhost:3000/foo/bar/issues/111';
const input = `${link} `;
wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input });
createComponent();
expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
expect(wrapper.vm.state.pendingReferences[0]).toEqual(link);
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: [input.trim()],
touchedReference: input,
});
await nextTick();
expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([link]);
});
it('fill in with multiple references', () => {
it('updates pending references with multiple references', async () => {
const input = 'asdf/qwer#444 #12 ';
wrapper.vm.onInput({
createComponent();
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: input.trim().split(/\s/),
touchedReference: '2',
});
await nextTick();
expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444');
expect(wrapper.vm.state.pendingReferences[1]).toEqual('#12');
expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([
'asdf/qwer#444',
'#12',
]);
});
it('fill in with some invalid things', () => {
it('updates pending references with invalid values', async () => {
const input = 'something random ';
wrapper.vm.onInput({
createComponent();
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: input.trim().split(/\s/),
touchedReference: '2',
});
await nextTick();
expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('something');
expect(wrapper.vm.state.pendingReferences[1]).toEqual('random');
expect(findRelatedIssuesBlock().props('pendingReferences')).toEqual([
'something',
'random',
]);
});
it.each`
pathIdSeparator
${'#'}
${'&'}
`(
'prepends $pathIdSeparator when user enters a numeric value [0-9]',
async ({ pathIdSeparator }) => {
it.each(['#', '&'])(
'prepends %s when user enters a numeric value [0-9]',
async (pathIdSeparator) => {
const input = '23';
createComponent({ props: { pathIdSeparator } });
await wrapper.setProps({
pathIdSeparator,
});
wrapper.vm.onInput({
findRelatedIssuesBlock().vm.$emit('addIssuableFormInput', {
untouchedRawReferences: input.trim().split(/\s/),
touchedReference: input,
});
await nextTick();
expect(wrapper.vm.inputValue).toBe(`${pathIdSeparator}${input}`);
expect(findRelatedIssuesBlock().props('inputValue')).toBe(`${pathIdSeparator}${input}`);
},
);
it('prepends # when user enters a number', async () => {
const input = 23;
wrapper.vm.onInput({
untouchedRawReferences: String(input).trim().split(/\s/),
touchedReference: input,
});
expect(wrapper.vm.inputValue).toBe(`#${input}`);
});
});
describe('onBlur', () => {
beforeEach(() =>
createComponent().then(() => {
jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {});
}),
);
describe('when "addIssuableFormBlur" event is emitted', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {});
});
it('add any references to pending when blurring', () => {
it('adds any references to pending when blurring', () => {
const input = '#123';
wrapper.vm.onBlur(input);
findRelatedIssuesBlock().vm.$emit('addIssuableFormBlur', input);
expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input);
});
});
describe('processAllReferences', () => {
beforeEach(() => createComponent());
it('add valid reference to pending', () => {
const input = '#123';
wrapper.vm.processAllReferences(input);
expect(wrapper.vm.state.pendingReferences).toHaveLength(1);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123');
});
it('add any valid references to pending', () => {
const input = 'asdf #123';
wrapper.vm.processAllReferences(input);
expect(wrapper.vm.state.pendingReferences).toHaveLength(2);
expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf');
expect(wrapper.vm.state.pendingReferences[1]).toEqual('#123');
});
});
});
});

View file

@ -17,6 +17,12 @@ import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import waitForPromises from 'helpers/wait_for_promises';
import getPackagePipelines from '~/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql';
import Tracking from '~/tracking';
import {
TRACKING_ACTION_CLICK_PIPELINE_LINK,
TRACKING_ACTION_CLICK_COMMIT_LINK,
TRACKING_LABEL_PACKAGE_HISTORY,
} from '~/packages_and_registries/package_registry/constants';
Vue.use(VueApollo);
@ -181,7 +187,6 @@ describe('Package History', () => {
it('link', () => {
const linkElement = findElementLink(element);
const exist = Boolean(link);
expect(linkElement.exists()).toBe(exist);
if (exist) {
expect(linkElement.attributes('href')).toBe(link);
@ -189,4 +194,29 @@ describe('Package History', () => {
});
},
);
describe('tracking', () => {
let eventSpy;
const category = 'UI::Packages';
beforeEach(() => {
mountComponent();
eventSpy = jest.spyOn(Tracking, 'event');
});
it('clicking pipeline link tracks the right action', () => {
wrapper.vm.trackPipelineClick();
expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_PIPELINE_LINK, {
category,
label: TRACKING_LABEL_PACKAGE_HISTORY,
});
});
it('clicking commit link tracks the right action', () => {
wrapper.vm.trackCommitClick();
expect(eventSpy).toHaveBeenCalledWith(category, TRACKING_ACTION_CLICK_COMMIT_LINK, {
category,
label: TRACKING_LABEL_PACKAGE_HISTORY,
});
});
});
});

View file

@ -7,6 +7,13 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import deleteWorkItemFromTaskMutation from '~/work_items/graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import {
deleteWorkItemFromTaskMutationErrorResponse,
deleteWorkItemFromTaskMutationResponse,
deleteWorkItemMutationErrorResponse,
deleteWorkItemResponse,
} from '../mock_data';
describe('WorkItemDetailModal component', () => {
let wrapper;
@ -25,28 +32,38 @@ describe('WorkItemDetailModal component', () => {
},
};
const defaultPropsData = {
issueGid: 'gid://gitlab/WorkItem/1',
workItemId: 'gid://gitlab/WorkItem/2',
};
const findModal = () => wrapper.findComponent(GlModal);
const findAlert = () => wrapper.findComponent(GlAlert);
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
const createComponent = ({ workItemId = '1', issueGid = '2', error = false } = {}) => {
const createComponent = ({
lockVersion,
lineNumberStart,
lineNumberEnd,
error = false,
deleteWorkItemFromTaskMutationHandler = jest
.fn()
.mockResolvedValue(deleteWorkItemFromTaskMutationResponse),
deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
} = {}) => {
const apolloProvider = createMockApollo([
[
deleteWorkItemFromTaskMutation,
jest.fn().mockResolvedValue({
data: {
workItemDeleteTask: {
workItem: { id: 123, descriptionHtml: 'updated work item desc' },
errors: [],
},
},
}),
],
[deleteWorkItemFromTaskMutation, deleteWorkItemFromTaskMutationHandler],
[deleteWorkItemMutation, deleteWorkItemMutationHandler],
]);
wrapper = shallowMount(WorkItemDetailModal, {
apolloProvider,
propsData: { workItemId, issueGid },
propsData: {
...defaultPropsData,
lockVersion,
lineNumberStart,
lineNumberEnd,
},
data() {
return {
error,
@ -67,8 +84,8 @@ describe('WorkItemDetailModal component', () => {
expect(findWorkItemDetail().props()).toEqual({
isModal: true,
workItemId: '1',
workItemParentId: '2',
workItemId: defaultPropsData.workItemId,
workItemParentId: defaultPropsData.issueGid,
});
});
@ -109,16 +126,85 @@ describe('WorkItemDetailModal component', () => {
});
describe('delete work item', () => {
it('emits workItemDeleted and closes modal', async () => {
createComponent();
const newDesc = 'updated work item desc';
describe('when there is task data', () => {
it('emits workItemDeleted and closes modal', async () => {
const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationResponse);
createComponent({
lockVersion: 1,
lineNumberStart: '3',
lineNumberEnd: '3',
deleteWorkItemFromTaskMutationHandler: mutationMock,
});
const newDesc = 'updated work item desc';
findWorkItemDetail().vm.$emit('deleteWorkItem');
findWorkItemDetail().vm.$emit('deleteWorkItem');
await waitForPromises();
await waitForPromises();
expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]);
expect(hideModal).toHaveBeenCalled();
expect(mutationMock).toHaveBeenCalledWith({
input: {
id: defaultPropsData.issueGid,
lockVersion: 1,
taskData: { id: defaultPropsData.workItemId, lineNumberEnd: 3, lineNumberStart: 3 },
},
});
});
expect(wrapper.emitted('workItemDeleted')).toEqual([[newDesc]]);
expect(hideModal).toHaveBeenCalled();
it.each`
errorType | mutationMock | errorMessage
${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemFromTaskMutationErrorResponse)} | ${'Error'}
${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'}
`(
'shows an error message when there is $errorType',
async ({ mutationMock, errorMessage }) => {
createComponent({
lockVersion: 1,
lineNumberStart: '3',
lineNumberEnd: '3',
deleteWorkItemFromTaskMutationHandler: mutationMock,
});
findWorkItemDetail().vm.$emit('deleteWorkItem');
await waitForPromises();
expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
expect(hideModal).not.toHaveBeenCalled();
expect(findAlert().text()).toBe(errorMessage);
},
);
});
describe('when there is no task data', () => {
it('emits workItemDeleted and closes modal', async () => {
const mutationMock = jest.fn().mockResolvedValue(deleteWorkItemResponse);
createComponent({ deleteWorkItemMutationHandler: mutationMock });
findWorkItemDetail().vm.$emit('deleteWorkItem');
await waitForPromises();
expect(wrapper.emitted('workItemDeleted')).toEqual([[defaultPropsData.workItemId]]);
expect(hideModal).toHaveBeenCalled();
expect(mutationMock).toHaveBeenCalledWith({ input: { id: defaultPropsData.workItemId } });
});
it.each`
errorType | mutationMock | errorMessage
${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(deleteWorkItemMutationErrorResponse)} | ${'Error'}
${'a network error'} | ${jest.fn().mockRejectedValue(new Error('GraphQL networkError'))} | ${'GraphQL networkError'}
`(
'shows an error message when there is $errorType',
async ({ mutationMock, errorMessage }) => {
createComponent({ deleteWorkItemMutationHandler: mutationMock });
findWorkItemDetail().vm.$emit('deleteWorkItem');
await waitForPromises();
expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
expect(hideModal).not.toHaveBeenCalled();
expect(findAlert().text()).toBe(errorMessage);
},
);
});
});
});

View file

@ -289,6 +289,32 @@ export const deleteWorkItemFailureResponse = {
],
};
export const deleteWorkItemMutationErrorResponse = {
data: {
workItemDelete: {
errors: ['Error'],
},
},
};
export const deleteWorkItemFromTaskMutationResponse = {
data: {
workItemDeleteTask: {
workItem: { id: 123, descriptionHtml: 'updated work item desc' },
errors: [],
},
},
};
export const deleteWorkItemFromTaskMutationErrorResponse = {
data: {
workItemDeleteTask: {
workItem: { id: 123, descriptionHtml: 'updated work item desc' },
errors: ['Error'],
},
},
};
export const workItemTitleSubscriptionResponse = {
data: {
issuableTitleUpdated: {