diff options
Diffstat (limited to 'app/assets/javascripts/work_items/components')
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> |