Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/work_items/components')
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue203
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue117
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue91
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue22
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue56
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue192
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue37
9 files changed, 547 insertions, 181 deletions
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 57babe4569d..57930951856 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlFormGroup, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlButton, GlFormGroup } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
@@ -7,22 +7,25 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { __, s__ } from '~/locale';
import EditedAt from '~/issues/show/components/edited.vue';
import Tracking from '~/tracking';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import workItemQuery from '../graphql/work_item.query.graphql';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getWorkItemQuery } from '../utils';
+import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
+import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
export default {
- directives: {
- SafeHtml: GlSafeHtmlDirective,
- },
components: {
EditedAt,
GlButton,
GlFormGroup,
+ MarkdownEditor,
MarkdownField,
+ WorkItemDescriptionRendered,
},
- mixins: [Tracking.mixin()],
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
props: {
workItemId: {
type: String,
@@ -32,6 +35,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
@@ -41,21 +53,37 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
+ descriptionHtml: '',
};
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;
},
+ result() {
+ this.descriptionText = this.workItemDescription?.description;
+ this.descriptionHtml = this.workItemDescription?.descriptionHtml;
+ },
error() {
- this.error = i18n.fetchError;
+ this.$emit('error', i18n.fetchError);
+ },
+ subscribeToMore: {
+ document: workItemDescriptionSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
},
},
},
@@ -64,7 +92,7 @@ export default {
return this.workItemId;
},
canEdit() {
- return this.workItem?.userPermissions?.updateWorkItem;
+ return this.workItem?.userPermissions?.updateWorkItem || false;
},
tracking() {
return {
@@ -73,12 +101,6 @@ export default {
property: `type_${this.workItemType}`,
};
},
- descriptionHtml() {
- return this.workItemDescription?.descriptionHtml;
- },
- descriptionEmpty() {
- return this.descriptionHtml?.trim() === '';
- },
workItemDescription() {
const descriptionWidget = this.workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
@@ -114,7 +136,7 @@ export default {
await this.$nextTick();
- this.$refs.textarea.focus();
+ this.$refs.textarea?.focus();
},
async cancelEditing() {
const isDirty = this.descriptionText !== this.workItemDescription?.description;
@@ -142,8 +164,10 @@ export default {
updateDraft(this.autosaveKey, this.descriptionText);
},
- async updateWorkItem(event) {
- if (event.key) {
+ async updateWorkItem(event = {}) {
+ const { key } = event;
+
+ if (key) {
this.isSubmittingWithKeydown = true;
}
@@ -179,73 +203,90 @@ export default {
this.isSubmitting = false;
},
+ setDescriptionText(newText) {
+ this.descriptionText = newText;
+ updateDraft(this.autosaveKey, this.descriptionText);
+ },
+ handleDescriptionTextUpdated(newText) {
+ this.descriptionText = newText;
+ this.updateWorkItem();
+ },
},
};
</script>
<template>
- <gl-form-group
- v-if="isEditing"
- class="gl-my-5 gl-border-t gl-pt-6"
- :label="__('Description')"
- label-for="work-item-description"
- >
- <markdown-field
- can-attach-file
- :textarea-value="descriptionText"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="$options.markdownDocsPath"
- class="gl-p-3 bordered-box gl-mt-5"
+ <div>
+ <gl-form-group
+ v-if="isEditing"
+ class="gl-mb-5 gl-border-t gl-pt-6"
+ :label="__('Description')"
+ label-for="work-item-description"
>
- <template #textarea>
- <textarea
- id="work-item-description"
- ref="textarea"
- v-model="descriptionText"
- :disabled="isSubmitting"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- @keydown.meta.enter="updateWorkItem"
- @keydown.ctrl.enter="updateWorkItem"
- @keydown.exact.esc.stop="cancelEditing"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
-
- <div class="gl-display-flex">
- <gl-button
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- data-testid="save-description"
- @click="updateWorkItem"
- >{{ __('Save') }}</gl-button
- >
- <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{
- __('Cancel')
- }}</gl-button>
- </div>
- </gl-form-group>
- <div v-else class="gl-mb-5 gl-border-t">
- <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
- <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
- <gl-button
- v-if="canEdit"
- class="gl-ml-auto"
- icon="pencil"
- data-testid="edit-description"
- :aria-label="__('Edit description')"
- @click="startEditing"
+ <markdown-editor
+ v-if="glFeatures.workItemsMvc2"
+ class="gl-my-3 common-note-form"
+ :value="descriptionText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :form-field-aria-label="__('Description')"
+ :form-field-placeholder="__('Write a comment or drag your files here…')"
+ form-field-id="work-item-description"
+ form-field-name="work-item-description"
+ enable-autocomplete
+ init-on-autofocus
+ @input="setDescriptionText"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
/>
- </div>
-
- <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
- <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div>
+ <markdown-field
+ v-else
+ can-attach-file
+ :textarea-value="descriptionText"
+ :is-submitting="isSubmitting"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ class="gl-p-3 bordered-box gl-mt-5"
+ >
+ <template #textarea>
+ <textarea
+ id="work-item-description"
+ ref="textarea"
+ v-model="descriptionText"
+ :disabled="isSubmitting"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ @keydown.exact.esc.stop="cancelEditing"
+ @input="onInput"
+ ></textarea>
+ </template>
+ </markdown-field>
+ <div class="gl-display-flex">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ __('Save') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form-group>
+ <work-item-description-rendered
+ v-else
+ :work-item-description="workItemDescription"
+ :can-edit="canEdit"
+ @startEditing="startEditing"
+ @descriptionUpdated="handleDescriptionTextUpdated"
+ />
<edited-at
v-if="lastEditedAt"
:updated-at="lastEditedAt"
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
new file mode 100644
index 00000000000..e6f8a301c5e
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -0,0 +1,117 @@
+<script>
+import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
+
+export default {
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ workItemDescription: {
+ type: Object,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ descriptionText() {
+ return this.workItemDescription?.description;
+ },
+ descriptionHtml() {
+ return this.workItemDescription?.descriptionHtml;
+ },
+ descriptionEmpty() {
+ return this.descriptionHtml?.trim() === '';
+ },
+ },
+ watch: {
+ descriptionHtml: {
+ handler() {
+ this.renderGFM();
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ async renderGFM() {
+ await this.$nextTick();
+
+ $(this.$refs['gfm-content']).renderGFM();
+
+ if (this.canEdit) {
+ this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
+
+ // enable boxes, disabled by default in markdown
+ this.checkboxes.forEach((checkbox) => {
+ // eslint-disable-next-line no-param-reassign
+ checkbox.disabled = false;
+ });
+ }
+ },
+ toggleCheckboxes(event) {
+ const { target } = event;
+
+ if (isCheckbox(target)) {
+ target.disabled = true;
+
+ const { sourcepos } = target.parentElement.dataset;
+
+ if (!sourcepos) return;
+
+ const [startRange] = sourcepos.split('-');
+ let [startRow] = startRange.split(':');
+ startRow = Number(startRow) - 1;
+
+ const descriptionTextRows = this.descriptionText.split('\n');
+ const newDescriptionText = descriptionTextRows
+ .map((row, index) => {
+ if (startRow === index) {
+ if (target.checked) {
+ return row.replace(/\[ \]/, '[x]');
+ }
+ return row.replace(/\[[x~]\]/i, '[ ]');
+ }
+ return row;
+ })
+ .join('\n');
+
+ this.$emit('descriptionUpdated', newDescriptionText);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-5 gl-border-t gl-pt-5">
+ <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
+ <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
+ <gl-button
+ v-if="canEdit"
+ class="gl-ml-auto"
+ icon="pencil"
+ data-testid="edit-description"
+ :aria-label="__('Edit description')"
+ @click="$emit('startEditing')"
+ />
+ </div>
+
+ <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+ <div
+ v-else
+ ref="gfm-content"
+ v-safe-html="descriptionHtml"
+ class="md gl-mb-5 gl-min-h-8"
+ @change="toggleCheckboxes"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index af9b8c6101a..7e9fa24e3f5 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,4 +1,5 @@
<script>
+import { isEmpty } from 'lodash';
import {
GlAlert,
GlSkeletonLoader,
@@ -11,6 +12,7 @@ import {
} from '@gitlab/ui';
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
import { s__ } from '~/locale';
+import { parseBoolean } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
@@ -27,12 +29,13 @@ import {
WIDGET_TYPE_ITERATION,
} from '../constants';
-import workItemQuery from '../graphql/work_item.query.graphql';
import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
+import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
+import { getWorkItemQuery } from '../utils';
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
@@ -72,6 +75,7 @@ export default {
WorkItemMilestone,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
props: {
isModal: {
type: Boolean,
@@ -83,6 +87,11 @@ export default {
required: false,
default: null,
},
+ iid: {
+ type: String,
+ required: false,
+ default: null,
+ },
workItemParentId: {
type: String,
required: false,
@@ -100,20 +109,26 @@ export default {
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
},
skip() {
return !this.workItemId;
},
+ update(data) {
+ const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return workItem ?? {};
+ },
error() {
- this.error = this.$options.i18n.fetchError;
- document.title = s__('404|Not found');
+ this.setEmptyState();
},
result() {
+ if (isEmpty(this.workItem)) {
+ this.setEmptyState();
+ }
if (!this.isModal && this.workItem.project) {
const path = this.workItem.project?.fullPath
? ` · ${this.workItem.project.fullPath}`
@@ -127,30 +142,44 @@ export default {
document: workItemTitleSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
};
},
+ skip() {
+ return !this.workItem?.id;
+ },
},
{
document: workItemDatesSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
};
},
skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
+ return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id;
},
},
{
document: workItemAssigneesSubscription,
variables() {
return {
- issuableId: this.workItemId,
+ issuableId: this.workItem.id,
+ };
+ },
+ skip() {
+ return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id;
+ },
+ },
+ {
+ document: workItemMilestoneSubscription,
+ variables() {
+ return {
+ issuableId: this.workItem.id,
};
},
skip() {
- return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
+ return !this.isWidgetPresent(WIDGET_TYPE_MILESTONE) || !this.workItem?.id;
},
},
],
@@ -212,7 +241,20 @@ export default {
return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
},
workItemMilestone() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE);
+ return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
+ },
+ fetchByIid() {
+ return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path);
+ },
+ queryVariables() {
+ return this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ }
+ : {
+ id: this.workItemId,
+ };
},
},
beforeDestroy() {
@@ -231,7 +273,7 @@ export default {
this.updateInProgress = true;
let updateMutation = updateWorkItemMutation;
let inputVariables = {
- id: this.workItemId,
+ id: this.workItem.id,
confidential: confidentialStatus,
};
@@ -240,7 +282,7 @@ export default {
inputVariables = {
id: this.parentWorkItem.id,
taskData: {
- id: this.workItemId,
+ id: this.workItem.id,
confidential: confidentialStatus,
},
};
@@ -275,6 +317,10 @@ export default {
this.updateInProgress = false;
});
},
+ setEmptyState() {
+ this.error = this.$options.i18n.fetchError;
+ document.title = s__('404|Not found');
+ },
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@@ -352,7 +398,7 @@ export default {
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem', workItemType)"
+ @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
/>
@@ -406,6 +452,8 @@ export default {
:work-item-id="workItem.id"
:can-update="canUpdate"
:full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
@error="updateError = $event"
/>
<work-item-due-date
@@ -421,8 +469,10 @@ export default {
<work-item-milestone
v-if="workItemMilestone"
:work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.nodes[0]"
+ :work-item-milestone="workItemMilestone.milestone"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
:can-update="canUpdate"
:full-path="fullPath"
@error="updateError = $event"
@@ -435,6 +485,8 @@ export default {
:weight="workItemWeight.weight"
:work-item-id="workItem.id"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
@error="updateError = $event"
/>
<template v-if="workItemsMvc2Enabled">
@@ -445,6 +497,9 @@ export default {
:can-update="canUpdate"
:work-item-id="workItem.id"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
@error="updateError = $event"
/>
</template>
@@ -452,6 +507,8 @@ export default {
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
:full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
class="gl-pt-5"
@error="updateError = $event"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
index eae11c2bb2f..9ee302855c7 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -134,12 +134,12 @@ export default {
async clickShowDueDate() {
this.showDueDateInput = true;
await this.$nextTick();
- this.$refs.dueDatePicker.calendar.show();
+ this.$refs.dueDatePicker.show();
},
async clickShowStartDate() {
this.showStartDateInput = true;
await this.$nextTick();
- this.$refs.startDatePicker.calendar.show();
+ this.$refs.startDatePicker.show();
},
handleStartDateInput() {
if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) {
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 05077862690..22af3c653e9 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -8,7 +8,7 @@ import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/labe
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
-import workItemQuery from '../graphql/work_item.query.graphql';
+import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import {
@@ -50,6 +50,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -64,11 +73,14 @@ export default {
},
apollo: {
workItem: {
- query: workItemQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
- return {
- id: this.workItemId,
- };
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
},
skip() {
return !this.workItemId;
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 37aa48be6e5..0251dcc33fa 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -6,10 +6,6 @@ import WorkItemLinks from './work_item_links.vue';
Vue.use(GlToast);
export default function initWorkItemLinks() {
- if (!window.gon.features.workItemsHierarchy) {
- return;
- }
-
const workItemLinksRoot = document.querySelector('.js-work-item-links-root');
if (!workItemLinksRoot) {
@@ -21,7 +17,6 @@ export default function initWorkItemLinks() {
wiHasIssueWeightsFeature,
iid,
wiHasIterationsFeature,
- projectNamespace,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@@ -38,7 +33,6 @@ export default function initWorkItemLinks() {
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
- projectNamespace,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 0d3e951de7e..3d469b790a1 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlAlert,
+ GlLoadingIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { produce } from 'immer';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -9,7 +17,12 @@ import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_detail
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
-import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, WIDGET_TYPE_HIERARCHY } from '../../constants';
+import {
+ FORM_TYPES,
+ WIDGET_ICONS,
+ WORK_ITEM_STATUS_TEXT,
+ WIDGET_TYPE_HIERARCHY,
+} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import workItemQuery from '../../graphql/work_item.query.graphql';
@@ -20,6 +33,8 @@ import WorkItemLinksForm from './work_item_links_form.vue';
export default {
components: {
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlAlert,
GlLoadingIcon,
@@ -80,6 +95,7 @@ export default {
prefetchedWorkItem: null,
error: undefined,
parentIssue: null,
+ formType: null,
};
},
computed: {
@@ -89,6 +105,9 @@ export default {
issuableIteration() {
return this.parentIssue?.iteration;
},
+ issuableMilestone() {
+ return this.parentIssue?.milestone;
+ },
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@@ -125,9 +144,10 @@ export default {
toggle() {
this.isOpen = !this.isOpen;
},
- showAddForm() {
+ showAddForm(formType) {
this.isOpen = true;
this.isShownAddForm = true;
+ this.formType = formType;
this.$nextTick(() => {
this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus();
});
@@ -239,15 +259,18 @@ export default {
'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.',
),
addChildButtonLabel: s__('WorkItem|Add'),
+ addChildOptionLabel: s__('WorkItem|Existing task'),
+ createChildOptionLabel: s__('WorkItem|New task'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
+ FORM_TYPES,
};
</script>
<template>
<div
- class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
data-testid="work-item-links"
>
<div
@@ -264,15 +287,26 @@ export default {
{{ childrenCountLabel }}
</span>
</div>
- <gl-button
+ <gl-dropdown
v-if="canUpdate"
- category="secondary"
+ right
size="small"
- data-testid="toggle-add-form"
- @click="showAddForm"
+ :text="$options.i18n.addChildButtonLabel"
+ data-testid="toggle-form"
>
- {{ $options.i18n.addChildButtonLabel }}
- </gl-button>
+ <gl-dropdown-item
+ data-testid="toggle-create-form"
+ @click="showAddForm($options.FORM_TYPES.create)"
+ >
+ {{ $options.i18n.createChildOptionLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ data-testid="toggle-add-form"
+ @click="showAddForm($options.FORM_TYPES.add)"
+ >
+ {{ $options.i18n.addChildOptionLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
<gl-button
category="tertiary"
@@ -309,6 +343,8 @@ export default {
:children-ids="childrenIds"
:parent-confidential="confidential"
:parent-iteration="issuableIteration"
+ :parent-milestone="issuableMilestone"
+ :form-type="formType"
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index a01f4616cab..095ea86e0d8 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,21 +1,26 @@
<script>
-import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
+import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui';
+import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
-import { TASK_TYPE_NAME } from '../../constants';
+import { FORM_TYPES, TASK_TYPE_NAME } from '../../constants';
export default {
components: {
GlAlert,
GlForm,
- GlFormCombobox,
+ GlTokenSelector,
GlButton,
GlFormGroup,
GlFormInput,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
props: {
issuableGid: {
@@ -38,6 +43,15 @@ export default {
required: false,
default: () => {},
},
+ parentMilestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ formType: {
+ type: String,
+ required: true,
+ },
},
apollo: {
workItemTypes: {
@@ -51,33 +65,73 @@ export default {
return data.workspace?.workItemTypes?.nodes;
},
},
+ availableWorkItems: {
+ query: projectWorkItemsQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ searchTerm: this.search?.title || this.search,
+ types: ['TASK'],
+ in: this.search ? 'TITLE' : undefined,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id));
+ },
+ },
},
data() {
return {
+ workItemTypes: [],
availableWorkItems: [],
search: '',
+ searchStarted: false,
error: null,
childToCreateTitle: null,
+ workItemsToAdd: [],
};
},
computed: {
- actionsList() {
- return [
- {
- label: this.$options.i18n.createChildOptionLabel,
- fn: () => {
- this.childToCreateTitle = this.search?.title || this.search;
- },
+ workItemInput() {
+ let workItemInput = {
+ title: this.search?.title || this.search,
+ projectPath: this.projectPath,
+ workItemTypeId: this.taskWorkItemType,
+ hierarchyWidget: {
+ parentId: this.issuableGid,
},
- ];
+ confidential: this.parentConfidential,
+ };
+
+ if (this.associateMilestone) {
+ workItemInput = {
+ ...workItemInput,
+ milestoneWidget: {
+ milestoneId: this.parentMilestoneId,
+ },
+ };
+ }
+ return workItemInput;
+ },
+ workItemsMvc2Enabled() {
+ return this.glFeatures.workItemsMvc2;
+ },
+ isCreateForm() {
+ return this.formType === FORM_TYPES.create;
},
addOrCreateButtonLabel() {
- return this.childToCreateTitle
- ? this.$options.i18n.createChildOptionLabel
- : this.$options.i18n.addTaskButtonLabel;
+ if (this.isCreateForm) {
+ return this.$options.i18n.createChildOptionLabel;
+ } else if (this.workItemsToAdd.length > 1) {
+ return this.$options.i18n.addTasksButtonLabel;
+ }
+ return this.$options.i18n.addTaskButtonLabel;
},
addOrCreateMethod() {
- return this.childToCreateTitle ? this.createChild : this.addChild;
+ return this.isCreateForm ? this.createChild : this.addChild;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
@@ -85,6 +139,24 @@ export default {
parentIterationId() {
return this.parentIteration?.id;
},
+ associateIteration() {
+ return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled;
+ },
+ parentMilestoneId() {
+ return this.parentMilestone?.id;
+ },
+ associateMilestone() {
+ return this.parentMilestoneId && this.workItemsMvc2Enabled;
+ },
+ isSubmitButtonDisabled() {
+ return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0;
+ },
+ isLoading() {
+ return this.$apollo.queries.availableWorkItems.loading;
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getIdFromGraphQLId,
@@ -92,6 +164,7 @@ export default {
this.error = null;
},
addChild() {
+ this.searchStarted = false;
this.$apollo
.mutate({
mutation: updateWorkItemMutation,
@@ -99,7 +172,7 @@ export default {
input: {
id: this.issuableGid,
hierarchyWidget: {
- childrenIds: [this.search.id],
+ childrenIds: this.workItemsToAdd.map((wi) => wi.id),
},
},
},
@@ -109,7 +182,7 @@ export default {
[this.error] = data.workItemUpdate.errors;
} else {
this.unsetError();
- this.$emit('addWorkItemChild', this.search);
+ this.workItemsToAdd = [];
}
})
.catch(() => {
@@ -124,15 +197,7 @@ export default {
.mutate({
mutation: createWorkItemMutation,
variables: {
- input: {
- title: this.search?.title || this.search,
- projectPath: this.projectPath,
- workItemTypeId: this.taskWorkItemType,
- hierarchyWidget: {
- parentId: this.issuableGid,
- },
- confidential: this.parentConfidential,
- },
+ input: this.workItemInput,
},
})
.then(({ data }) => {
@@ -145,7 +210,7 @@ export default {
* call update mutation only when there is an iteration associated with the issue
*/
// TODO: setting the iteration should be moved to the creation mutation once the backend is done
- if (this.parentIterationId && this.hasIterationsFeature) {
+ if (this.associateIteration) {
this.addIterationToWorkItem(data.workItemCreate.workItem.id);
}
}
@@ -171,10 +236,25 @@ export default {
},
});
},
+ setSearchKey(value) {
+ this.search = value;
+ },
+ handleFocus() {
+ this.searchStarted = true;
+ },
+ handleMouseOver() {
+ this.timeout = setTimeout(() => {
+ this.searchStarted = true;
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ handleMouseOut() {
+ clearTimeout(this.timeout);
+ },
},
i18n: {
inputLabel: __('Title'),
addTaskButtonLabel: s__('WorkItem|Add task'),
+ addTasksButtonLabel: s__('WorkItem|Add tasks'),
addChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to add a child. Please try again.',
),
@@ -182,7 +262,8 @@ export default {
createChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to create a child. Please try again.',
),
- placeholder: s__('WorkItem|Add a title'),
+ createPlaceholder: s__('WorkItem|Add a title'),
+ addPlaceholder: s__('WorkItem|Search existing tasks'),
fieldValidationMessage: __('Maximum of 255 characters'),
},
};
@@ -191,56 +272,59 @@ export default {
<template>
<gl-form
class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
- @submit.prevent="createChild"
+ @submit.prevent="addOrCreateMethod"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
{{ error }}
</gl-alert>
- <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 -->
- <gl-form-combobox
- v-if="false"
- v-model="search"
- :token-list="availableWorkItems"
- match-value-to-attr="title"
- class="gl-mb-4"
- :label-text="$options.i18n.inputLabel"
- :action-list="actionsList"
- label-sr-only
- autofocus
- >
- <template #result="{ item }">
- <div class="gl-display-flex">
- <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div>
- <div>{{ item.title }}</div>
- </div>
- </template>
- <template #action="{ item }">
- <span class="gl-text-blue-500">{{ item.label }}</span>
- </template>
- </gl-form-combobox>
<gl-form-group
+ v-if="isCreateForm"
:label="$options.i18n.inputLabel"
:description="$options.i18n.fieldValidationMessage"
>
<gl-form-input
ref="wiTitleInput"
v-model="search"
- :placeholder="$options.i18n.placeholder"
+ :placeholder="$options.i18n.createPlaceholder"
maxlength="255"
class="gl-mb-3"
autofocus
/>
</gl-form-group>
+ <gl-token-selector
+ v-else
+ v-model="workItemsToAdd"
+ :dropdown-items="availableWorkItems"
+ :loading="isLoading"
+ :placeholder="$options.i18n.addPlaceholder"
+ menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
+ class="gl-mb-4"
+ data-testid="work-item-token-select-input"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
+ >
+ <template #token-content="{ token }">
+ {{ token.title }}
+ </template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <div class="gl-display-flex">
+ <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
+ <div class="gl-text-truncate">{{ dropdownItem.title }}</div>
+ </div>
+ </template>
+ </gl-token-selector>
<gl-button
category="primary"
variant="confirm"
size="small"
type="submit"
- :disabled="search.length === 0"
+ :disabled="isSubmitButtonDisabled"
data-testid="add-child-button"
class="gl-mr-2"
>
- {{ $options.i18n.createChildOptionLabel }}
+ {{ addOrCreateButtonLabel }}
</gl-button>
<gl-button category="secondary" size="small" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
index c4a36e36555..a8d3b57aae0 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -11,10 +11,10 @@ import {
import * as Sentry from '@sentry/browser';
import { debounce } from 'lodash';
import Tracking from '~/tracking';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
sprintfWorkItem,
@@ -33,6 +33,7 @@ export default {
MILESTONE_FETCH_ERROR: s__(
'WorkItem|Something went wrong while fetching milestones. Please try again.',
),
+ EXPIRED_TEXT: __('(expired)'),
},
components: {
GlFormGroup,
@@ -68,6 +69,15 @@ export default {
type: String,
required: true,
},
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -90,8 +100,13 @@ export default {
emptyPlaceholder() {
return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE;
},
+ expired() {
+ return this.localMilestone?.expired ? ` ${this.$options.i18n.EXPIRED_TEXT}` : '';
+ },
dropdownText() {
- return this.localMilestone?.title || this.emptyPlaceholder;
+ return this.localMilestone?.title
+ ? `${this.localMilestone?.title}${this.expired}`
+ : this.emptyPlaceholder;
},
isLoadingMilestones() {
return this.$apollo.queries.milestones.loading;
@@ -106,6 +121,14 @@ export default {
};
},
},
+ watch: {
+ workItemMilestone: {
+ handler(newVal) {
+ this.localMilestone = newVal;
+ },
+ deep: true,
+ },
+ },
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
@@ -160,12 +183,13 @@ export default {
this.updateInProgress = true;
this.$apollo
.mutate({
- mutation: localUpdateWorkItemMutation,
+ mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
- milestone: {
- milestoneId: this.localMilestone?.id,
+ milestoneWidget: {
+ milestoneId:
+ this.localMilestone?.id === 'no-milestone-id' ? null : this.localMilestone?.id,
},
},
},
@@ -240,6 +264,7 @@ export default {
@click="handleMilestoneClick(milestone)"
>
{{ milestone.title }}
+ <template v-if="milestone.expired">{{ $options.i18n.EXPIRED_TEXT }}</template>
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text>