diff options
Diffstat (limited to 'app/assets/javascripts/work_items')
21 files changed, 613 insertions, 153 deletions
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4585426edaa..4d6a27f61ac 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -10,7 +10,7 @@ import { GlDropdownDivider, GlIntersectionObserver, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql'; import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; @@ -126,6 +126,9 @@ export default { }, }, computed: { + assigneesTitleId() { + return uniqueId('assignees-title-'); + }, searchUsers() { return this.users.nodes.map((node) => addClass({ ...node, ...node.user })); }, @@ -139,9 +142,6 @@ export default { property: `type_${this.workItemType}`, }; }, - assigneeListEmpty() { - return this.assignees.length === 0; - }, containerClass() { return !this.isEditing ? 'gl-shadow-none!' : ''; }, @@ -296,12 +296,14 @@ export default { <template> <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> <span + :id="assigneesTitleId" class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="assignees-title" >{{ assigneeText }}</span > <gl-token-selector ref="tokenSelector" + :aria-labelledby="assigneesTitleId" :selected-tokens="localAssignees" :container-class="containerClass" :class="{ 'gl-hover-border-gray-200': canUpdate }" @@ -319,7 +321,7 @@ export default { > <template #empty-placeholder> <div - class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2" + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-secondary gl-pr-4 gl-pl-2 gl-top-2" data-testid="empty-state" > <gl-icon name="profile" /> 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 c2e4a50fe31..57babe4569d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -5,6 +5,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { __, s__ } from '~/locale'; +import EditedAt from '~/issues/show/components/edited.vue'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import workItemQuery from '../graphql/work_item.query.graphql'; @@ -16,6 +17,7 @@ export default { SafeHtml: GlSafeHtmlDirective, }, components: { + EditedAt, GlButton, GlFormGroup, MarkdownField, @@ -89,6 +91,15 @@ export default { workItemType() { return this.workItem?.workItemType?.name; }, + lastEditedAt() { + return this.workItemDescription?.lastEditedAt; + }, + lastEditedByName() { + return this.workItemDescription?.lastEditedBy?.name; + }, + lastEditedByPath() { + return this.workItemDescription?.lastEditedBy?.webPath; + }, markdownPreviewPath() { return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ this.workItemType @@ -228,12 +239,18 @@ export default { class="gl-ml-auto" icon="pencil" data-testid="edit-description" - :aria-label="__('Edit')" + :aria-label="__('Edit description')" @click="startEditing" /> </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> + <edited-at + v-if="lastEditedAt" + :updated-at="lastEditedAt" + :updated-by-name="lastEditedByName" + :updated-by-path="lastEditedByPath" + /> </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 3d25df9fcb8..af9b8c6101a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -7,7 +7,10 @@ import { GlBadge, GlButton, GlTooltipDirective, + GlEmptyState, } from '@gitlab/ui'; +import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import { s__ } from '~/locale'; 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'; @@ -20,11 +23,14 @@ import { WIDGET_TYPE_WEIGHT, WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, + WIDGET_TYPE_MILESTONE, + 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 updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; @@ -35,6 +41,7 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; +import WorkItemMilestone from './work_item_milestone.vue'; import WorkItemInformation from './work_item_information.vue'; export default { @@ -49,6 +56,7 @@ export default { GlLoadingIcon, GlSkeletonLoader, GlIcon, + GlEmptyState, WorkItemAssignees, WorkItemActions, WorkItemDescription, @@ -60,6 +68,8 @@ export default { WorkItemInformation, LocalStorageSync, WorkItemTypeIcon, + WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemMilestone, }, mixins: [glFeatureFlagMixin()], props: { @@ -82,6 +92,7 @@ export default { data() { return { error: undefined, + updateError: undefined, workItem: {}, showInfoBanner: true, updateInProgress: false, @@ -100,9 +111,10 @@ export default { }, error() { this.error = this.$options.i18n.fetchError; + document.title = s__('404|Not found'); }, result() { - if (!this.isModal) { + if (!this.isModal && this.workItem.project) { const path = this.workItem.project?.fullPath ? ` · ${this.workItem.project.fullPath}` : ''; @@ -127,7 +139,18 @@ export default { }; }, skip() { - return !this.workItemDueDate; + return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); + }, + }, + { + document: workItemAssigneesSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + skip() { + return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, }, ], @@ -152,37 +175,44 @@ export default { workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, + parentWorkItem() { + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; + }, + parentWorkItemConfidentiality() { + return this.parentWorkItem?.confidential; + }, + parentUrl() { + return `../../issues/${this.parentWorkItem?.iid}`; + }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, + noAccessSvgPath() { + return `data:image/svg+xml;utf8,${encodeURIComponent(noAccessSvg)}`; + }, hasDescriptionWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION); }, workItemAssignees() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); + return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); }, workItemLabels() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.isWidgetPresent(WIDGET_TYPE_LABELS); }, workItemDueDate() { - return this.workItem?.widgets?.find( - (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE, - ); + return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); }, workItemWeight() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); + return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); }, workItemHierarchy() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); + return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, - parentWorkItem() { - return this.workItemHierarchy?.parent; + workItemIteration() { + return this.isWidgetPresent(WIDGET_TYPE_ITERATION); }, - parentWorkItemConfidentiality() { - return this.parentWorkItem?.confidential; - }, - parentUrl() { - return `../../issues/${this.parentWorkItem?.iid}`; - }, - workItemIconName() { - return this.workItem?.workItemType?.iconName; + workItemMilestone() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE); }, }, beforeDestroy() { @@ -191,6 +221,9 @@ export default { this.dismissBanner(); }, methods: { + isWidgetPresent(type) { + return this.workItem?.widgets?.find((widget) => widget.type === type); + }, dismissBanner() { this.showInfoBanner = false; }, @@ -236,7 +269,7 @@ export default { }, ) .catch((error) => { - this.error = error.message; + this.updateError = error.message; }) .finally(() => { this.updateInProgress = false; @@ -249,8 +282,13 @@ export default { <template> <section class="gl-pt-5"> - <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined"> - {{ error }} + <gl-alert + v-if="updateError" + class="gl-mb-3" + variant="danger" + @dismiss="updateError = undefined" + > + {{ updateError }} </gl-alert> <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5"> @@ -289,7 +327,7 @@ export default { </li> </ul> <work-item-type-icon - v-else + v-else-if="!error" :work-item-icon-name="workItemIconName" :work-item-type="workItemType && workItemType.toUpperCase()" show-text @@ -316,7 +354,7 @@ export default { :is-parent-confidential="parentWorkItemConfidentiality" @deleteWorkItem="$emit('deleteWorkItem', workItemType)" @toggleWorkItemConfidentiality="toggleConfidentiality" - @error="error = $event" + @error="updateError = $event" /> <gl-button v-if="isModal" @@ -332,24 +370,25 @@ export default { :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" > <work-item-information - v-if="showInfoBanner" + v-if="showInfoBanner && !error" :show-info-banner="showInfoBanner" @work-item-banner-dismissed="dismissBanner" /> </local-storage-sync> <work-item-title + v-if="workItem.title" :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" :work-item-parent-id="workItemParentId" :can-update="canUpdate" - @error="error = $event" + @error="updateError = $event" /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" :can-update="canUpdate" - @error="error = $event" + @error="updateError = $event" /> <work-item-assignees v-if="workItemAssignees" @@ -360,24 +399,33 @@ export default { :work-item-type="workItemType" :can-invite-members="workItemAssignees.canInviteMembers" :full-path="fullPath" - @error="error = $event" + @error="updateError = $event" + /> + <work-item-labels + v-if="workItemLabels" + :work-item-id="workItem.id" + :can-update="canUpdate" + :full-path="fullPath" + @error="updateError = $event" + /> + <work-item-due-date + v-if="workItemDueDate" + :can-update="canUpdate" + :due-date="workItemDueDate.dueDate" + :start-date="workItemDueDate.startDate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" /> <template v-if="workItemsMvc2Enabled"> - <work-item-labels - v-if="workItemLabels" + <work-item-milestone + v-if="workItemMilestone" :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.nodes[0]" + :work-item-type="workItemType" :can-update="canUpdate" :full-path="fullPath" - @error="error = $event" - /> - <work-item-due-date - v-if="workItemDueDate" - :can-update="canUpdate" - :due-date="workItemDueDate.dueDate" - :start-date="workItemDueDate.startDate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - @error="error = $event" + @error="updateError = $event" /> </template> <work-item-weight @@ -387,14 +435,31 @@ export default { :weight="workItemWeight.weight" :work-item-id="workItem.id" :work-item-type="workItemType" - @error="error = $event" + @error="updateError = $event" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> + </template> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" :full-path="fullPath" class="gl-pt-5" - @error="error = $event" + @error="updateError = $event" + /> + <gl-empty-state + v-if="error" + :title="$options.i18n.fetchErrorTitle" + :description="error" + :svg-path="noAccessSvgPath" /> </template> </section> 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 05f8fa8f5e1..eae11c2bb2f 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 @@ -198,7 +198,7 @@ export default { label-cols="3" label-cols-lg="2" > - <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4"> + <span v-if="isReadonlyWithNoDates" class="gl-text-secondary gl-ml-4"> {{ $options.i18n.none }} </span> <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5"> 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 b8b5198be57..05077862690 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -1,16 +1,22 @@ <script> import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, uniqueId, without } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +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 localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants'; +import { + i18n, + I18N_WORK_ITEM_ERROR_FETCHING_LABELS, + TRACKING_CATEGORY_SHOW, + WIDGET_TYPE_LABELS, +} from '../constants'; function isTokenSelectorElement(el) { return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); @@ -52,6 +58,8 @@ export default { localLabels: [], searchKey: '', searchLabels: [], + addLabelIds: [], + removeLabelIds: [], }; }, apollo: { @@ -68,13 +76,21 @@ export default { error() { this.$emit('error', i18n.fetchError); }, + subscribeToMore: { + document: workItemLabelsSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + }, }, searchLabels: { query: labelSearchQuery, variables() { return { fullPath: this.fullPath, - search: this.searchKey, + searchTerm: this.searchKey, }; }, skip() { @@ -84,11 +100,14 @@ export default { return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label })); }, error() { - this.$emit('error', i18n.fetchError); + this.$emit('error', I18N_WORK_ITEM_ERROR_FETCHING_LABELS); }, }, }, computed: { + labelsTitleId() { + return uniqueId('labels-title-'); + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -97,10 +116,7 @@ export default { }; }, allowScopedLabels() { - return this.labelsWidget.allowScopedLabels; - }, - listEmpty() { - return this.labels.length === 0; + return this.labelsWidget?.allowsScopedLabels; }, containerClass() { return !this.isEditing ? 'gl-shadow-none!' : ''; @@ -109,10 +125,10 @@ export default { return this.$apollo.queries.searchLabels.loading; }, labelsWidget() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, labels() { - return this.labelsWidget?.nodes || []; + return this.labelsWidget?.labels?.nodes || []; }, }, watch: { @@ -131,44 +147,74 @@ export default { }, removeLabel({ id }) { this.localLabels = this.localLabels.filter((label) => label.id !== id); + this.removeLabelIds.push(id); + this.setLabels(); }, - setLabels(event) { + async setLabels() { + if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; + this.searchKey = ''; - if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; this.isEditing = false; - this.$apollo - .mutate({ - mutation: localUpdateWorkItemMutation, + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, variables: { input: { id: this.workItemId, - labels: this.localLabels, + labelsWidget: { + addLabelIds: this.addLabelIds, + removeLabelIds: this.removeLabelIds, + }, }, }, - }) - .catch((e) => { - this.$emit('error', e); }); - this.track('updated_labels'); + + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + + this.addLabelIds = []; + this.removeLabelIds = []; + + this.track('updated_labels'); + } catch { + this.throwUpdateError(); + } + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + // If mutation is rejected, we're rolling back to initial state + this.localLabels = this.labels.map(addClass); + this.addLabelIds = []; + this.removeLabelIds = []; + }, + handleBlur(event) { + if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; + this.setLabels(); }, handleFocus() { this.isEditing = true; this.searchStarted = true; }, async focusTokenSelector(labels) { - if (this.allowScopedLabels) { - const newLabel = labels[labels.length - 1]; - const existingLabels = labels.slice(0, labels.length - 1); - - const newLabelKey = scopedLabelKey(newLabel); + const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id); + const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id); - const removeLabelsWithSameScope = existingLabels.filter((label) => { - const sameKey = newLabelKey === scopedLabelKey(label); - return !sameKey; - }); + if (labelsToAdd.length > 0) { + this.addLabelIds.push(...labelsToAdd); + } - this.localLabels = [...removeLabelsWithSameScope, newLabel]; + if (labelsToRemove.length > 0) { + this.removeLabelIds.push(...labelsToRemove); } + + this.localLabels = labels; + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); @@ -194,13 +240,15 @@ export default { <template> <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap"> <span + :id="labelsTitleId" class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" data-testid="labels-title" >{{ __('Labels') }}</span > <gl-token-selector ref="tokenSelector" - v-model="localLabels" + :selected-tokens="localLabels" + :aria-labelledby="labelsTitleId" :container-class="containerClass" :dropdown-items="searchLabels" :loading="isLoading" @@ -210,13 +258,13 @@ export default { @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" - @blur="setLabels" + @blur="handleBlur" @mouseover.native="handleMouseOver" @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div - class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" + class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-secondary gl-pr-4 gl-top-2" data-testid="empty-state" > <span v-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span> 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 8f31b07b6a3..37aa48be6e5 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 @@ -16,7 +16,13 @@ export default function initWorkItemLinks() { return; } - const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset; + const { + projectPath, + wiHasIssueWeightsFeature, + iid, + wiHasIterationsFeature, + projectNamespace, + } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new new Vue({ @@ -31,6 +37,8 @@ export default function initWorkItemLinks() { iid, 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 840fd910272..0d3e951de7e 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 @@ -5,7 +5,7 @@ import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import { isMetaKey } from '~/lib/utils/common_utils'; import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; @@ -59,7 +59,7 @@ export default { }, }, parentIssue: { - query: issueConfidentialQuery, + query: getIssueDetailsQuery, variables() { return { fullPath: this.projectPath, @@ -86,6 +86,9 @@ export default { confidential() { return this.parentIssue?.confidential || this.workItem?.confidential || false; }, + issuableIteration() { + return this.parentIssue?.iteration; + }, children() { return ( this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children @@ -257,7 +260,7 @@ export default { class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" data-testid="children-count" > - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" /> + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" /> {{ childrenCountLabel }} </span> </div> @@ -294,7 +297,7 @@ export default { <template v-else> <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> - <p class="gl-mt-3 gl-mb-4"> + <p class="gl-mb-3"> {{ $options.i18n.emptyStateMessage }} </p> </div> @@ -305,6 +308,7 @@ export default { :issuable-gid="issuableGid" :children-ids="childrenIds" :parent-confidential="confidential" + :parent-iteration="issuableIteration" @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 8b848995d44..a01f4616cab 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 @@ -16,7 +16,7 @@ export default { GlFormGroup, GlFormInput, }, - inject: ['projectPath'], + inject: ['projectPath', 'hasIterationsFeature'], props: { issuableGid: { type: String, @@ -33,6 +33,11 @@ export default { required: false, default: false, }, + parentIteration: { + type: Object, + required: false, + default: () => {}, + }, }, apollo: { workItemTypes: { @@ -77,6 +82,9 @@ export default { taskWorkItemType() { return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; }, + parentIterationId() { + return this.parentIteration?.id; + }, }, methods: { getIdFromGraphQLId, @@ -133,6 +141,13 @@ export default { } else { this.unsetError(); this.$emit('addWorkItemChild', data.workItemCreate.workItem); + /** + * 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) { + this.addIterationToWorkItem(data.workItemCreate.workItem.id); + } } }) .catch(() => { @@ -143,6 +158,19 @@ export default { this.childToCreateTitle = null; }); }, + async addIterationToWorkItem(workItemId) { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: workItemId, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }, + }, + }); + }, }, i18n: { inputLabel: __('Title'), @@ -182,7 +210,7 @@ export default { > <template #result="{ item }"> <div class="gl-display-flex"> - <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> + <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> <div>{{ item.title }}</div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue new file mode 100644 index 00000000000..c4a36e36555 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -0,0 +1,248 @@ +<script> +import { + GlFormGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSkeletonLoader, + GlSearchBoxByType, + GlDropdownText, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { debounce } from 'lodash'; +import Tracking from '~/tracking'; +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 { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + TRACKING_CATEGORY_SHOW, +} from '../constants'; + +const noMilestoneId = 'no-milestone-id'; + +export default { + i18n: { + MILESTONE: s__('WorkItem|Milestone'), + NONE: s__('WorkItem|None'), + MILESTONE_PLACEHOLDER: s__('WorkItem|Add to milestone'), + NO_MATCHING_RESULTS: s__('WorkItem|No matching results'), + NO_MILESTONE: s__('WorkItem|No milestone'), + MILESTONE_FETCH_ERROR: s__( + 'WorkItem|Something went wrong while fetching milestones. Please try again.', + ), + }, + components: { + GlFormGroup, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSkeletonLoader, + GlSearchBoxByType, + GlDropdownText, + }, + mixins: [Tracking.mixin()], + props: { + workItemId: { + type: String, + required: true, + }, + workItemMilestone: { + type: Object, + required: false, + default: () => {}, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + fullPath: { + type: String, + required: true, + }, + }, + data() { + return { + localMilestone: this.workItemMilestone, + searchTerm: '', + shouldFetch: false, + updateInProgress: false, + isFocused: false, + milestones: [], + }; + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_milestone', + property: `type_${this.workItemType}`, + }; + }, + emptyPlaceholder() { + return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE; + }, + dropdownText() { + return this.localMilestone?.title || this.emptyPlaceholder; + }, + isLoadingMilestones() { + return this.$apollo.queries.milestones.loading; + }, + isNoMilestone() { + return this.localMilestone?.id === noMilestoneId || !this.localMilestone?.id; + }, + dropdownClasses() { + return { + 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, + 'is-not-focused': !this.isFocused, + }; + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + apollo: { + milestones: { + query: projectMilestonesQuery, + variables() { + return { + fullPath: this.fullPath, + title: this.searchTerm, + first: 20, + }; + }, + skip() { + return !this.shouldFetch; + }, + update(data) { + return data?.workspace?.attributes?.nodes || []; + }, + error() { + this.$emit('error', this.i18n.MILESTONE_FETCH_ERROR); + }, + }, + }, + methods: { + handleMilestoneClick(milestone) { + this.localMilestone = milestone; + }, + onDropdownShown() { + this.$refs.search.focusInput(); + this.shouldFetch = true; + this.isFocused = true; + }, + onDropdownHide() { + this.isFocused = false; + this.searchTerm = ''; + this.shouldFetch = false; + this.updateMilestone(); + }, + setSearchKey(value) { + this.searchTerm = value; + }, + isMilestoneChecked(milestone) { + return this.localMilestone?.id === milestone?.id; + }, + updateMilestone() { + if (this.workItemMilestone?.id === this.localMilestone?.id) { + return; + } + + this.track('updated_milestone'); + this.updateInProgress = true; + this.$apollo + .mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + milestone: { + milestoneId: this.localMilestone?.id, + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('\n')); + } + }) + .catch((error) => { + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); + Sentry.captureException(error); + }) + .finally(() => { + this.updateInProgress = false; + }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-dropdown" + :label="$options.i18n.MILESTONE" + label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3" + label-cols="3" + label-cols-lg="2" + > + <span + v-if="!canUpdate" + class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal" + data-testid="disabled-text" + > + {{ dropdownText }} + </span> + <gl-dropdown + v-else + :toggle-class="dropdownClasses" + :text="dropdownText" + :loading="updateInProgress" + @shown="onDropdownShown" + @hide="onDropdownHide" + > + <template #header> + <gl-search-box-by-type ref="search" :value="searchTerm" @input="debouncedSearchKeyUpdate" /> + </template> + <gl-dropdown-item + data-testid="no-milestone" + is-check-item + :is-checked="isNoMilestone" + @click="handleMilestoneClick({ id: 'no-milestone-id' })" + > + {{ $options.i18n.NO_MILESTONE }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-text v-if="isLoadingMilestones"> + <gl-skeleton-loader :height="90"> + <rect width="380" height="10" x="10" y="15" rx="4" /> + <rect width="280" height="10" x="10" y="30" rx="4" /> + <rect width="380" height="10" x="10" y="50" rx="4" /> + <rect width="280" height="10" x="10" y="65" rx="4" /> + </gl-skeleton-loader> + </gl-dropdown-text> + <template v-else-if="milestones.length"> + <gl-dropdown-item + v-for="milestone in milestones" + :key="milestone.id" + is-check-item + :is-checked="isMilestoneChecked(milestone)" + @click="handleMilestoneClick(milestone)" + > + {{ milestone.title }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 31e75663055..96a6493357c 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -53,7 +53,7 @@ export default { v-gl-tooltip.hover="showTooltipOnHover" :name="iconName" :title="workItemTooltipTitle" - class="gl-mr-2 gl-text-gray-500" + class="gl-mr-2 gl-text-secondary" /> <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> </span> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 78219e62d01..7737c535650 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -17,6 +17,9 @@ export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; +export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; +export const WIDGET_TYPE_ITERATION = 'ITERATION'; + export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; @@ -26,13 +29,19 @@ export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const i18n = { - fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + fetchErrorTitle: s__('WorkItem|Work item not found'), + fetchError: s__( + "WorkItem|This work item is not available. It either doesn't exist or you don't have permission to view it.", + ), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), confidentialTooltip: s__( 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', ), }; +export const I18N_WORK_ITEM_ERROR_FETCHING_LABELS = s__( + 'WorkItem|Something went wrong when fetching labels. Please try again.', +); export const I18N_WORK_ITEM_ERROR_CREATING = s__( 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.', ); @@ -48,6 +57,10 @@ export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__( ); export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted'); +export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( + 'WorkItem|Something went wrong when fetching iterations. Please try again.', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql new file mode 100644 index 00000000000..6edb6c89f16 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql @@ -0,0 +1,9 @@ +query issuableDetails($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + confidential + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 36ffba8a540..36779dfe11e 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,6 +1,6 @@ enum LocalWidgetType { ASSIGNEES - LABELS + MILESTONE } interface LocalWorkItemWidget { @@ -12,10 +12,9 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget { nodes: [UserCore] } -type LocalWorkItemLabels implements LocalWorkItemWidget { +type LocalWorkItemMilestone implements LocalWorkItemWidget { type: LocalWidgetType! - allowScopedLabels: Boolean! - nodes: [Label!] + nodes: [Milestone!] } extend type WorkItem { @@ -30,17 +29,14 @@ input LocalUserInput { avatarUrl: String } -input LocalLabelInput { - id: ID! - title: String! - color: String - description: String +input LocalMilestoneInput { + milestoneId: ID! } input LocalUpdateWorkItemInput { id: WorkItemID! assignees: [LocalUserInput!] - labels: [LocalLabelInput] + milestone: LocalMilestoneInput! } type LocalWorkItemPayload { diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index f4c77ed2ec0..bb05c9b2135 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,3 @@ -#import "~/graphql_shared/fragments/user.fragment.graphql" #import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" fragment WorkItem on WorkItem { diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 276061af193..fa0ab56df75 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,15 +1,16 @@ -#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem mockWidgets @client { - ... on LocalWorkItemLabels { + ... on LocalWorkItemMilestone { type - allowScopedLabels nodes { - ...Label + id + title + expired + dueDate } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql new file mode 100644 index 00000000000..d5b2de8c4c6 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_assignees.subscription.graphql @@ -0,0 +1,21 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +subscription issuableAssignees($issuableId: IssuableID!) { + issuableAssigneesUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + canInviteMembers + assignees { + nodes { + ...User + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql index 7e045fdf431..d8760f147e1 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql @@ -4,6 +4,7 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) { id widgets { ... on WorkItemWidgetStartAndDueDate { + type dueDate startDate } diff --git a/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql new file mode 100644 index 00000000000..86d936bf4dd --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_labels.subscription.graphql @@ -0,0 +1,19 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +subscription workItemLabels($issuableId: IssuableID!) { + issuableLabelsUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetLabels { + type + labels { + nodes { + ...Label + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 3005069f59a..d404cfb10ed 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -1,8 +1,16 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { type description descriptionHtml + lastEditedAt + lastEditedBy { + name + webPath + } } ... on WorkItemWidgetAssignees { type @@ -14,6 +22,14 @@ fragment WorkItemWidgets on WorkItemWidget { } } } + ... on WorkItemWidgetLabels { + type + labels { + nodes { + ...Label + } + } + } ... on WorkItemWidgetStartAndDueDate { type dueDate diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index bb4c7052238..f872d8c6b12 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,13 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + projectNamespace, + hasIterationsFeature, + } = el.dataset; return new Vue({ el, @@ -17,6 +23,8 @@ export const initWorkItemsRoot = () => { fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), issuesListPath, + projectNamespace, + hasIterationsFeature: parseBoolean(hasIterationsFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 3b7257591e2..4908b99e5b0 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -6,7 +6,6 @@ import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; -import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; import ItemTitle from '../components/item_title.vue'; @@ -29,26 +28,6 @@ export default { required: false, default: '', }, - issueGid: { - type: String, - required: false, - default: '', - }, - lockVersion: { - type: Number, - required: false, - default: null, - }, - lineNumberStart: { - type: String, - required: false, - default: null, - }, - lineNumberEnd: { - type: String, - required: false, - default: null, - }, }, data() { return { @@ -136,28 +115,6 @@ export default { this.error = this.createErrorText; } }, - async createWorkItemFromTask() { - try { - const { data } = await this.$apollo.mutate({ - mutation: createWorkItemFromTaskMutation, - variables: { - input: { - id: this.issueGid, - workItemData: { - lockVersion: this.lockVersion, - title: this.title, - lineNumberStart: Number(this.lineNumberStart), - lineNumberEnd: Number(this.lineNumberEnd), - workItemTypeId: this.selectedWorkItemType, - }, - }, - }, - }); - this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); - } catch { - this.error = this.createErrorText; - } - }, handleTitleInput(title) { this.title = title; }, |