diff options
Diffstat (limited to 'app/assets/javascripts/work_items')
31 files changed, 1719 insertions, 257 deletions
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue new file mode 100644 index 00000000000..92a2fcaf1df --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -0,0 +1,229 @@ +<script> +/** + * Common component to render a system note, icon and user information. + * + * This component need not be used with any store neither has any vuex dependency + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * bodyHtml: String, + * systemNoteIconName: String + * }" + * /> + */ +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import axios from '~/lib/utils/axios_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import NoteHeader from '~/notes/components/note_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + i18n: { + deleteButtonLabel: __('Remove description history'), + }, + name: 'SystemNote', + components: { + GlIcon, + NoteHeader, + TimelineEntryItem, + GlButton, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + lines: [], + showLines: false, + loadingDiff: false, + isLoadingDescriptionVersion: false, + }; + }, + computed: { + targetNoteHash() { + return getLocationHash(); + }, + descriptionVersions() { + return []; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.bodyHtml).unwrap().html(); + }, + hasMoreCommits() { + return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT; + }, + descriptionVersion() { + return this.descriptionVersions[this.note.description_version_id]; + }, + }, + mounted() { + renderGFM(this.$refs['gfm-content']); + }, + methods: { + fetchDescriptionVersion() {}, + softDeleteDescriptionVersion() {}, + + async toggleDiff() { + this.showLines = !this.showLines; + + if (!this.lines.length) { + this.loadingDiff = true; + const { data } = await axios.get(this.note.outdated_line_change_path); + + this.lines = data.map((l) => ({ + ...l, + rich_text: l.rich_text.replace(/^[+ -]/, ''), + })); + this.loadingDiff = false; + } + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, + userColorSchemeClass: window.gon.user_color_scheme, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" + class="note system-note note-wrapper" + > + <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + :author="note.author" + :created-at="note.createdAt" + :note-id="note.id" + :is-system-note="true" + > + <span ref="gfm-content" v-safe-html="actionTextHtml"></span> + <template + v-if="canSeeDescriptionVersion || note.outdated_line_change_path" + #extra-controls + > + · + <gl-button + v-if="canSeeDescriptionVersion" + variant="link" + :icon="descriptionVersionToggleIcon" + data-testid="compare-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDescriptionVersion" + >{{ __('Compare with previous version') }}</gl-button + > + <gl-button + v-if="note.outdated_line_change_path" + :icon="showLines ? 'chevron-up' : 'chevron-down'" + variant="link" + data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDiff" + > + {{ __('Compare changes') }} + </gl-button> + </template> + </note-header> + </div> + <div class="note-body"> + <div + v-safe-html="note.bodyHtml" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" + class="note-text md" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> + <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> + <span>{{ __('Toggle commit list') }}</span> + </div> + </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loader /> + </pre> + <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre> + <gl-button + v-if="displayDeleteButton" + v-gl-tooltip + :title="$options.i18n.deleteButtonLabel" + :aria-label="$options.i18n.deleteButtonLabel" + variant="default" + category="tertiary" + icon="remove" + class="delete-description-history" + data-testid="delete-description-version-button" + @click="deleteDescriptionVersion" + /> + </div> + <div + v-if="lines.length && showLines" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + > + <table + :class="$options.userColorSchemeClass" + class="code js-syntax-highlight" + data-testid="outdated-lines" + > + <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> + <td + :class="line.type" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" + > + {{ line.old_line }} + </td> + <td + :class="line.type" + class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.new_line }} + </td> + <td + :class="line.type" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" + v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" + ></td> + </tr> + </table> + </div> + <div v-else-if="showLines" class="mt-4"> + <gl-skeleton-loader /> + </div> + </div> + </div> + </timeline-entry-item> +</template> 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 4d6a27f61ac..c2980405a19 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -202,6 +202,7 @@ export default { if (!this.allowsMultipleAssignees) { this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; this.isEditing = false; + this.setAssignees(this.assigneeIds); return; } this.localAssignees = assignees; 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 57930951856..07da0279b41 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 } from '@gitlab/ui'; +import { GlAlert, 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'; @@ -19,6 +19,7 @@ import WorkItemDescriptionRendered from './work_item_description_rendered.vue'; export default { components: { EditedAt, + GlAlert, GlButton, GlFormGroup, MarkdownEditor, @@ -54,6 +55,7 @@ export default { isSubmittingWithKeydown: false, descriptionText: '', descriptionHtml: '', + conflictedDescription: '', }; }, apollo: { @@ -68,11 +70,17 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, result() { - this.descriptionText = this.workItemDescription?.description; - this.descriptionHtml = this.workItemDescription?.descriptionHtml; + if (this.isEditing) { + if (this.descriptionText !== this.workItemDescription?.description) { + this.conflictedDescription = this.workItemDescription?.description; + } + } else { + this.descriptionText = this.workItemDescription?.description; + this.descriptionHtml = this.workItemDescription?.descriptionHtml; + } }, error() { this.$emit('error', i18n.fetchError); @@ -94,6 +102,9 @@ export default { canEdit() { return this.workItem?.userPermissions?.updateWorkItem || false; }, + hasConflicts() { + return Boolean(this.conflictedDescription); + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -196,6 +207,7 @@ export default { this.isEditing = false; clearDraft(this.autosaveKey); + this.conflictedDescription = ''; } catch (error) { this.$emit('error', error.message); Sentry.captureException(error); @@ -224,7 +236,7 @@ export default { label-for="work-item-description" > <markdown-editor - v-if="glFeatures.workItemsMvc2" + v-if="glFeatures.workItemsMvc" class="gl-my-3 common-note-form" :value="descriptionText" :render-markdown-path="markdownPreviewPath" @@ -235,6 +247,7 @@ export default { form-field-name="work-item-description" enable-autocomplete init-on-autofocus + use-bottom-toolbar @input="setDescriptionText" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @@ -246,7 +259,7 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - class="gl-p-3 bordered-box gl-mt-5" + class="gl-px-3 bordered-box gl-mt-5" > <template #textarea> <textarea @@ -267,17 +280,59 @@ export default { </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> + <gl-alert + v-if="hasConflicts" + :dismissible="false" + variant="danger" + class="gl-w-full" + data-testid="work-item-description-conflicts" + > + <p> + {{ + s__( + "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.", + ) + }} + </p> + <details class="gl-mb-5"> + <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary> + <textarea + class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3" + readonly + :value="conflictedDescription" + ></textarea> + </details> + <template #actions> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ s__('WorkItem|Save and overwrite') }} + </gl-button> + <gl-button + category="secondary" + class="gl-ml-3" + data-testid="cancel" + @click="cancelEditing" + >{{ s__('WorkItem|Discard changes') }} + </gl-button> + </template> + </gl-alert> + <template v-else> + <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> + </template> </div> </gl-form-group> <work-item-description-rendered 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 index e6f8a301c5e..d58983c013d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -1,13 +1,14 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox'); export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, + GlTooltip: GlTooltipDirective, }, components: { GlButton, @@ -45,7 +46,7 @@ export default { async renderGFM() { await this.$nextTick(); - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canEdit) { this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); @@ -93,14 +94,16 @@ export default { <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"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-3"> <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button v-if="canEdit" + v-gl-tooltip class="gl-ml-auto" icon="pencil" data-testid="edit-description" :aria-label="__('Edit description')" + :title="__('Edit description')" @click="$emit('startEditing')" /> </div> @@ -111,6 +114,7 @@ export default { ref="gfm-content" v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8" + data-testid="work-item-description" @change="toggleCheckboxes" ></div> </div> 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 7e9fa24e3f5..cb45a05de89 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,5 +1,6 @@ <script> import { isEmpty } from 'lodash'; +import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -11,10 +12,11 @@ import { GlEmptyState, } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; 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'; import { i18n, @@ -23,10 +25,14 @@ import { WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, + WIDGET_TYPE_PROGRESS, WIDGET_TYPE_HIERARCHY, - WORK_ITEM_VIEWED_STORAGE_KEY, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ITERATION, + WIDGET_TYPE_HEALTH_STATUS, + WORK_ITEM_TYPE_VALUE_ISSUE, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_NOTES, } from '../constants'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; @@ -37,6 +43,7 @@ import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import { getWorkItemQuery } from '../utils'; +import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -45,7 +52,7 @@ 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'; +import WorkItemNotes from './work_item_notes.vue'; export default { i18n, @@ -68,11 +75,14 @@ export default { WorkItemTitle, WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), - WorkItemInformation, - LocalStorageSync, + WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemTypeIcon, WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemHealthStatus: () => + import('ee_component/work_items/components/work_item_health_status.vue'), WorkItemMilestone, + WorkItemTree, + WorkItemNotes, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -87,7 +97,7 @@ export default { required: false, default: null, }, - iid: { + workItemIid: { type: String, required: false, default: null, @@ -103,7 +113,6 @@ export default { error: undefined, updateError: undefined, workItem: {}, - showInfoBanner: true, updateInProgress: false, }; }, @@ -201,17 +210,31 @@ export default { fullPath() { return this.workItem?.project.fullPath; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, parentWorkItem() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, + parentWorkItemType() { + return this.parentWorkItem?.workItemType?.name; + }, + parentWorkItemIconName() { + return this.parentWorkItem?.workItemType?.iconName; + }, parentWorkItemConfidentiality() { return this.parentWorkItem?.confidential; }, parentUrl() { - return `../../issues/${this.parentWorkItem?.iid}`; + // Once more types are moved to have Work Items involved + // we need to handle this properly. + if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) { + return `../../issues/${this.parentWorkItem?.iid}`; + } + return this.parentWorkItem?.webUrl; }, workItemIconName() { return this.workItem?.workItemType?.iconName; @@ -234,41 +257,48 @@ export default { workItemWeight() { return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); }, + workItemProgress() { + return this.isWidgetPresent(WIDGET_TYPE_PROGRESS); + }, workItemHierarchy() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, workItemIteration() { return this.isWidgetPresent(WIDGET_TYPE_ITERATION); }, + workItemHealthStatus() { + return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); + }, workItemMilestone() { return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); }, + workItemNotes() { + return this.isWidgetPresent(WIDGET_TYPE_NOTES); + }, fetchByIid() { - return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path); + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, queryVariables() { return this.fetchByIid ? { fullPath: this.fullPath, - iid: this.iid, + iid: this.workItemIid, } : { id: this.workItemId, }; }, - }, - beforeDestroy() { - /** make sure that if the user has not even dismissed the alert , - * should no be able to see the information next time and update the local storage * */ - this.dismissBanner(); + children() { + const widgetHierarchy = this.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + return widgetHierarchy.children.nodes; + }, }, methods: { isWidgetPresent(type) { return this.workItem?.widgets?.find((widget) => widget.type === type); }, - dismissBanner() { - this.showInfoBanner = false; - }, toggleConfidentiality(confidentialStatus) { this.updateInProgress = true; let updateMutation = updateWorkItemMutation; @@ -321,8 +351,76 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, + addChild(child) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.unshift(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + try { + const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while undoing child removal.'); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + try { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while removing child.'); + Sentry.captureException(error); + } + }, }, - WORK_ITEM_VIEWED_STORAGE_KEY, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; </script> @@ -347,14 +445,14 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> <gl-button v-gl-tooltip.hover class="gl-text-truncate gl-max-w-full" - icon="issues" + :icon="parentWorkItemIconName" category="tertiary" :href="parentUrl" :title="parentWorkItem.title" @@ -411,16 +509,6 @@ export default { @click="$emit('close')" /> </div> - <local-storage-sync - v-model="showInfoBanner" - :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" - > - <work-item-information - 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" @@ -465,19 +553,17 @@ export default { :work-item-type="workItemType" @error="updateError = $event" /> - <template v-if="workItemsMvc2Enabled"> - <work-item-milestone - v-if="workItemMilestone" - :work-item-id="workItem.id" - :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" - /> - </template> + <work-item-milestone + v-if="workItemMilestone" + :work-item-id="workItem.id" + :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" + /> <work-item-weight v-if="workItemWeight" class="gl-mb-5" @@ -489,20 +575,38 @@ export default { :query-variables="queryVariables" @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" - :fetch-by-iid="fetchByIid" - :query-variables="queryVariables" - :full-path="fullPath" - @error="updateError = $event" - /> - </template> + <work-item-progress + v-if="workItemProgress" + class="gl-mb-5" + :can-update="canUpdate" + :progress="workItemProgress.progress" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + @error="updateError = $event" + /> + <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" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :full-path="fullPath" + @error="updateError = $event" + /> + <work-item-health-status + v-if="workItemHealthStatus" + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" @@ -512,6 +616,27 @@ export default { class="gl-pt-5" @error="updateError = $event" /> + <work-item-tree + v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :work-item-type="workItemType" + :work-item-id="workItem.id" + :children="children" + :can-update="canUpdate" + :project-path="fullPath" + @addWorkItemChild="addChild" + @removeChild="removeChild" + /> + <template v-if="workItemsMvc2Enabled"> + <work-item-notes + v-if="workItemNotes" + :work-item-id="workItem.id" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + class="gl-pt-5" + @error="updateError = $event" + /> + </template> <gl-empty-state v-if="error" :title="$options.i18n.fetchErrorTitle" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 39a662a6c54..e8726814aaf 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -20,6 +20,11 @@ export default { required: false, default: null, }, + workItemIid: { + type: String, + required: false, + default: null, + }, issueGid: { type: String, required: false, @@ -134,6 +139,7 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" + scrollable @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -144,6 +150,7 @@ export default { is-modal :work-item-parent-id="issueGid" :work-item-id="workItemId" + :work-item-iid="workItemIid" class="gl-p-5 gl-mt-n3" @close="hide" @deleteWorkItem="deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue deleted file mode 100644 index ce75cc98a75..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_information.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -export default { - i18n: { - learnTasksLinkText: s__('WorkItem|Learn about tasks.'), - tasksInformationTitle: s__('WorkItem|Introducing tasks'), - tasksInformationBody: s__( - 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}', - ), - }, - helpPageLinks: { - tasksDocLinkPath: helpPagePath('user/tasks'), - }, - components: { - GlAlert, - GlSprintf, - GlLink, - }, - props: { - showInfoBanner: { - type: Boolean, - required: false, - default: true, - }, - }, - emits: ['work-item-banner-dismissed'], -}; -</script> - -<template> - <section class="gl-display-block gl-mb-2"> - <gl-alert - v-if="showInfoBanner" - variant="tip" - :title="$options.i18n.tasksInformationTitle" - data-testid="work-item-information" - class="gl-mt-3" - @dismiss="$emit('work-item-banner-dismissed')" - > - <gl-sprintf :message="$options.i18n.tasksInformationBody"> - <template #learnMoreLink> - <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{ - $options.i18n.learnTasksLinkText - }}</gl-link> - </template> - ></gl-sprintf - > - </gl-alert> - </section> -</template> 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 22af3c653e9..45fb0f7f21a 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -3,8 +3,8 @@ import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; 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 labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; 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'; @@ -83,7 +83,7 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, error() { this.$emit('error', i18n.fetchError); 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 0251dcc33fa..edad0e9b616 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 @@ -17,6 +17,7 @@ export default function initWorkItemLinks() { wiHasIssueWeightsFeature, iid, wiHasIterationsFeature, + wiHasIssuableHealthStatusFeature, } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new @@ -33,6 +34,7 @@ export default function initWorkItemLinks() { fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, hasIterationsFeature: wiHasIterationsFeature, + hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue new file mode 100644 index 00000000000..dc5bcdc3dcc --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -0,0 +1,66 @@ +<script> +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +const objectiveActionItems = [ + { + title: s__('OKR|New objective'), + eventName: 'showCreateObjectiveForm', + }, + { + title: s__('OKR|Existing objective'), + eventName: 'showAddObjectiveForm', + }, +]; + +const keyResultActionItems = [ + { + title: s__('OKR|New key result'), + eventName: 'showCreateKeyResultForm', + }, + { + title: s__('OKR|Existing key result'), + eventName: 'showAddKeyResultForm', + }, +]; + +export default { + keyResultActionItems, + objectiveActionItems, + components: { + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlDropdownDivider, + }, + methods: { + change({ eventName }) { + this.$emit(eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown :text="__('Add')" size="small" right> + <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.objectiveActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.keyResultActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 34874908f9b..763f2f338a3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,19 +1,35 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import { STATE_OPEN } from '../../constants'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; +import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLink, GlButton, GlIcon, RichTimestampTooltip, + WorkItemLinkChildMetadata, WorkItemLinksMenu, + WorkItemTreeChildren, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,16 +51,48 @@ export default { type: Object, required: true, }, + hasIndirectChildren: { + type: Boolean, + required: false, + default: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isExpanded: false, + children: [], + isLoadingChildren: false, + }; }, computed: { + canHaveChildren() { + return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; + }, + allowsScopedLabels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels; + }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, - iconClass() { - return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + childItemType() { + return this.childItem.workItemType.name; }, iconName() { - return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; }, stateTimestamp() { return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; @@ -55,55 +103,161 @@ export default { childPath() { return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; }, + hasChildren() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren; + }, + chevronType() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + chevronTooltip() { + return this.isExpanded ? __('Collapse') : __('Expand'); + }, + hasMetadata() { + return this.milestone || this.assignees.length > 0 || this.labels.length > 0; + }, + milestone() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone; + }, + assignees() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || []; + }, + labels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || []; + }, + }, + methods: { + toggleItem() { + this.isExpanded = !this.isExpanded; + if (this.children.length === 0 && this.hasChildren) { + this.fetchChildren(); + } + }, + getWidgetByType(workItem, widgetType) { + return workItem?.widgets?.find((widget) => widget.type === widgetType); + }, + async fetchChildren() { + this.isLoadingChildren = true; + try { + const { data } = await this.$apollo.query({ + query: getWorkItemTreeQuery, + variables: { + id: this.childItem.id, + }, + }); + this.children = this.getWidgetByType(data?.workItem, WIDGET_TYPE_HIERARCHY).children.nodes; + } catch (error) { + this.isExpanded = !this.isExpanded; + createAlert({ + message: s__('Hierarchy|Something went wrong while fetching children.'), + captureError: true, + error, + }); + } finally { + this.isLoadingChildren = false; + } + }, }, }; </script> <template> - <div - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> - <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> - </span> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" - /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" - /> - <gl-button - :href="childPath" - category="tertiary" - variant="link" - class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" - @click="$emit('click', childItem.id, $event)" - @mouseover="$emit('mouseover', childItem.id, $event)" - @mouseout="$emit('mouseout', childItem.id, $event)" - > - {{ childItem.title }} - </gl-button> - </div> + <div> <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + class="gl-display-flex gl-align-items-flex-start gl-mb-3" + :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" > - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('remove', childItem.id)" + <gl-button + v-if="hasChildren" + v-gl-tooltip.viewport + :title="chevronTooltip" + :aria-label="chevronTooltip" + :icon="chevronType" + category="tertiary" + :loading="isLoadingChildren" + class="gl-px-0! gl-py-3! gl-mr-3" + data-testid="expand-child" + @click="toggleItem" /> + <div + class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <span + :id="`stateIcon-${childItem.id}`" + class="gl-mr-3" + :class="{ 'gl-display-flex': hasMetadata }" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <div + class="gl-display-flex gl-flex-grow-1" + :class="{ + 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, + 'gl-align-items-center': !hasMetadata, + }" + > + <div class="gl-display-flex"> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-link + :href="childPath" + class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :allows-scoped-labels="allowsScopedLabels" + :milestone="milestone" + :assignees="assignees" + :labels="labels" + class="gl-mt-3" + /> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('removeChild', childItem.id)" + /> + </div> + </div> </div> + <work-item-tree-children + v-if="isExpanded" + :project-path="projectPath" + :can-update="canUpdate" + :work-item-id="issuableGid" + :work-item-type="workItemType" + :children="children" + @removeChild="fetchChildren" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue new file mode 100644 index 00000000000..7be7e1f3496 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -0,0 +1,123 @@ +<script> +import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; + +import { s__, sprintf } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +import ItemMilestone from '~/issuable/components/issue_milestone.vue'; + +export default { + components: { + GlLabel, + GlAvatar, + GlAvatarLink, + GlAvatarsInline, + ItemMilestone, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + allowsScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + milestone: { + type: Object, + required: false, + default: null, + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + labels: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + assigneesCollapsedTooltip() { + if (this.assignees.length > 2) { + return sprintf(s__('WorkItem|%{count} more assignees'), { + count: this.assignees.length - 2, + }); + } + return ''; + }, + assigneesContainerClass() { + if (this.assignees.length === 2) { + return 'fixed-width-avatars-2'; + } else if (this.assignees.length > 2) { + return 'fixed-width-avatars-3'; + } + return ''; + }, + labelsContainerClass() { + if (this.milestone || this.assignees.length) { + return 'gl-sm-ml-5'; + } + return ''; + }, + }, + methods: { + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <item-milestone + v-if="milestone" + :milestone="milestone" + class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + /> + <gl-avatars-inline + v-if="assignees.length" + :avatars="assignees" + :collapsed="true" + :max-visible="2" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-sr-only-text="assigneesCollapsedTooltip" + :class="assigneesContainerClass" + > + <template #avatar="{ avatar }"> + <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> + <gl-avatar :src="avatar.avatarUrl" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" + /> + </div> + </div> +</template> + +<style scoped> +/** + * These overrides are needed to address https://gitlab.com/gitlab-org/gitlab-ui/-/issues/865 + */ +.fixed-width-avatars-2 { + width: 42px !important; +} + +.fixed-width-avatars-3 { + width: 67px !important; +} +</style> 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 3d469b790a1..faadb5fa6fa 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 @@ -9,13 +9,15 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { produce } from 'immer'; +import { isEmpty } from 'lodash'; 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; 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'; +import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { FORM_TYPES, @@ -26,6 +28,7 @@ import { 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'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; @@ -45,6 +48,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'iid'], props: { workItemId: { @@ -72,6 +76,18 @@ export default { error(e) { this.error = e.message || this.$options.i18n.fetchError; }, + async result() { + const { id, iid } = this.childUrlParams; + this.activeChild = this.fetchByIid + ? this.children.find((child) => child.iid === iid) ?? {} + : this.children.find((child) => child.id === id) ?? {}; + await this.$nextTick(); + if (!isEmpty(this.activeChild)) { + this.$refs.modal.show(); + return; + } + this.updateWorkItemIdUrlQuery(); + }, }, parentIssue: { query: getIssueDetailsQuery, @@ -90,7 +106,7 @@ export default { return { isShownAddForm: false, isOpen: true, - activeChildId: null, + activeChild: {}, activeToast: null, prefetchedWorkItem: null, error: undefined, @@ -139,6 +155,29 @@ export default { childrenCountLabel() { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } }, methods: { toggle() { @@ -159,29 +198,29 @@ export default { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(child, child.id, client); }, - openChild(childItemId, e) { + openChild(child, e) { if (isMetaKey(e)) { return; } e.preventDefault(); - this.activeChildId = childItemId; + this.activeChild = child; this.$refs.modal.show(); - this.updateWorkItemIdUrlQuery(childItemId); + this.updateWorkItemIdUrlQuery(child); }, - closeModal() { - this.activeChildId = null; - this.updateWorkItemIdUrlQuery(undefined); + async closeModal() { + this.activeChild = {}; + this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(childId) { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(null, childId, client); this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); }, - updateWorkItemIdUrlQuery(childItemId) { - updateHistory({ - url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), - replace: true, - }); + updateWorkItemIdUrlQuery({ id, iid } = {}) { + const params = this.fetchByIid + ? { work_item_iid: iid } + : { work_item_id: getIdFromGraphQLId(id) }; + updateHistory({ url: setUrlParams(params), replace: true }); }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ @@ -235,16 +274,31 @@ export default { }); } }, - prefetchWorkItem(id) { + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { this.prefetch = setTimeout( - () => - this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemQuery, - variables: { - id, - }, - update: (data) => data.workItem, - }), + () => this.addWorkItemQuery({ id, iid }), DEFAULT_DEBOUNCE_AND_THROTTLE_MS, ); }, @@ -355,16 +409,17 @@ export default { :can-update="canUpdate" :issuable-gid="issuableGid" :child-item="child" - @click="openChild" - @mouseover="prefetchWorkItem" + @click="openChild(child, $event)" + @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" - @remove="removeChild" + @removeChild="removeChild" /> <work-item-detail-modal ref="modal" - :work-item-id="activeChildId" + :work-item-id="activeChild.id" + :work-item-iid="activeChild.iid" @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChildId)" + @workItemDeleted="handleWorkItemDeleted(activeChild.id)" /> </template> </div> 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 095ea86e0d8..5cf0c4154bb 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 @@ -9,7 +9,16 @@ import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_ty 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 { FORM_TYPES, TASK_TYPE_NAME } from '../../constants'; +import { + FORM_TYPES, + WORK_ITEMS_TYPE_MAP, + WORK_ITEM_TYPE_ENUM_TASK, + I18N_WORK_ITEM_CREATE_BUTTON_LABEL, + I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + I18N_WORK_ITEM_ADD_BUTTON_LABEL, + I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, + sprintfWorkItem, +} from '../../constants'; export default { components: { @@ -52,6 +61,11 @@ export default { type: String, required: true, }, + childrenType: { + type: String, + required: false, + default: WORK_ITEM_TYPE_ENUM_TASK, + }, }, apollo: { workItemTypes: { @@ -71,7 +85,7 @@ export default { return { projectPath: this.projectPath, searchTerm: this.search?.title || this.search, - types: ['TASK'], + types: [this.childrenType], in: this.search ? 'TITLE' : undefined, }; }, @@ -79,7 +93,9 @@ export default { return !this.searchStarted; }, update(data) { - return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id)); + return data.workspace.workItems.nodes.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, + ); }, }, }, @@ -99,14 +115,14 @@ export default { let workItemInput = { title: this.search?.title || this.search, projectPath: this.projectPath, - workItemTypeId: this.taskWorkItemType, + workItemTypeId: this.childWorkItemType, hierarchyWidget: { parentId: this.issuableGid, }, confidential: this.parentConfidential, }; - if (this.associateMilestone) { + if (this.parentMilestoneId) { workItemInput = { ...workItemInput, milestoneWidget: { @@ -114,46 +130,62 @@ export default { }, }; } + + if (this.associateIteration) { + workItemInput = { + ...workItemInput, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }; + } + return workItemInput; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, isCreateForm() { return this.formType === FORM_TYPES.create; }, + childrenTypeName() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; + }, addOrCreateButtonLabel() { if (this.isCreateForm) { - return this.$options.i18n.createChildOptionLabel; + return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); } else if (this.workItemsToAdd.length > 1) { - return this.$options.i18n.addTasksButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName); } - return this.$options.i18n.addTaskButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); }, addOrCreateMethod() { return this.isCreateForm ? this.createChild : this.addChild; }, - taskWorkItemType() { - return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + childWorkItemType() { + return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id; }, parentIterationId() { return this.parentIteration?.id; }, associateIteration() { - return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled; + return this.parentIterationId && this.hasIterationsFeature; }, 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; }, + addInputPlaceholder() { + return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + }, }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); @@ -206,13 +238,6 @@ 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.associateIteration) { - this.addIterationToWorkItem(data.workItemCreate.workItem.id); - } } }) .catch(() => { @@ -223,19 +248,6 @@ export default { this.childToCreateTitle = null; }); }, - async addIterationToWorkItem(workItemId) { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: workItemId, - iterationWidget: { - iterationId: this.parentIterationId, - }, - }, - }, - }); - }, setSearchKey(value) { this.search = value; }, @@ -253,17 +265,13 @@ export default { }, 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.', ), - createChildOptionLabel: s__('WorkItem|Create task'), createChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to create a child. Please try again.', ), createPlaceholder: s__('WorkItem|Add a title'), - addPlaceholder: s__('WorkItem|Search existing tasks'), fieldValidationMessage: __('Maximum of 255 characters'), }, }; @@ -296,7 +304,7 @@ export default { v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" - :placeholder="$options.i18n.addPlaceholder" + :placeholder="addInputPlaceholder" menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" class="gl-mb-4" data-testid="work-item-token-select-input" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue new file mode 100644 index 00000000000..f06de2ca048 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -0,0 +1,244 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { + FORM_TYPES, + WIDGET_TYPE_HIERARCHY, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, +} from '../../constants'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import OkrActionsSplitButton from './okr_actions_split_button.vue'; +import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; + +export default { + FORM_TYPES, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + components: { + GlButton, + OkrActionsSplitButton, + WorkItemLinksForm, + WorkItemLinkChild, + }, + mixins: [glFeatureFlagMixin()], + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + isShownAddForm: false, + isOpen: true, + error: null, + formType: null, + childType: null, + prefetchedWorkItem: null, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childrenIds() { + return this.children.map((c) => c.id); + }, + hasIndirectChildren() { + return this.children + .map( + (child) => child.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) || {}, + ) + .some((hierarchy) => hierarchy.hasChildren); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } + }, + methods: { + toggle() { + this.isOpen = !this.isOpen; + }, + showAddForm(formType, childType) { + this.isOpen = true; + this.isShownAddForm = true; + this.formType = formType; + this.childType = childType; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; + }, + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { + if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) { + this.prefetch = setTimeout( + () => this.addWorkItemQuery({ id, iid }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + } + }, + clearPrefetching() { + if (this.prefetch) { + clearTimeout(this.prefetch); + } + }, + }, +}; +</script> + +<template> + <div + 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-tree" + > + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </h5> + </div> + <okr-actions-split-button + @showCreateObjectiveForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showAddObjectiveForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showCreateKeyResultForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + @showAddKeyResultForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + /> + <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" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-tree" + @click="toggle" + /> + </div> + </div> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="tree-body" + > + <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> + <p class="gl-mb-3"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} + </p> + </div> + <work-item-links-form + v-if="isShownAddForm" + ref="wiLinksForm" + data-testid="add-tree-form" + :issuable-gid="workItemId" + :form-type="formType" + :children-type="childType" + :children-ids="childrenIds" + @addWorkItemChild="$emit('addWorkItemChild', $event)" + @cancel="hideAddForm" + /> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + :has-indirect-children="hasIndirectChildren" + @mouseover="prefetchWorkItem(child)" + @mouseout="clearPrefetching" + @removeChild="$emit('removeChild', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue new file mode 100644 index 00000000000..911cac4de88 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -0,0 +1,68 @@ +<script> +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; + +export default { + components: { + WorkItemLinkChild: () => import('./work_item_link_child.vue'), + }, + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + methods: { + async updateWorkItem(childId) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, + }); + this.$emit('removeChild'); + } catch (error) { + createAlert({ + message: s__('Hierarchy|Something went wrong while removing a child item.'), + captureError: true, + error, + }); + } + }, + }, +}; +</script> + +<template> + <div class="gl-ml-6"> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + @removeChild="updateWorkItem" + /> + </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 index a8d3b57aae0..6ed230b8ad4 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -13,6 +13,7 @@ import { debounce } from 'lodash'; import Tracking from '~/tracking'; import { s__, __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { MILESTONE_STATE } from '~/sidebar/constants'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { @@ -118,6 +119,7 @@ export default { return { 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, 'is-not-focused': !this.isFocused, + 'gl-min-w-20': true, }; }, }, @@ -139,6 +141,7 @@ export default { return { fullPath: this.fullPath, title: this.searchTerm, + state: MILESTONE_STATE.ACTIVE, first: 20, }; }, @@ -214,9 +217,10 @@ export default { <template> <gl-form-group - class="work-item-dropdown" + class="work-item-dropdown gl-flex-nowrap" :label="$options.i18n.MILESTONE" - label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3" + label-for="milestone-value" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break" label-cols="3" label-cols-lg="2" > @@ -229,6 +233,8 @@ export default { </span> <gl-dropdown v-else + id="milestone-value" + class="gl-pl-0 gl-max-w-full" :toggle-class="dropdownClasses" :text="dropdownText" :loading="updateInProgress" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue new file mode 100644 index 00000000000..91e90589a93 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -0,0 +1,109 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SystemNote from '~/work_items/components/notes/system_note.vue'; +import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +export default { + i18n: { + ACTIVITY_LABEL: s__('WorkItem|Activity'), + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + components: { + SystemNote, + GlSkeletonLoader, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + areNotesLoading() { + return this.$apollo.queries.workItemNotes.loading; + }, + notes() { + return this.workItemNotes?.nodes; + }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, + }, + apollo: { + workItemNotes: { + query() { + return getWorkItemNotesQuery(this.fetchByIid); + }, + context: { + isSingleRequest: true, + }, + variables() { + return { + ...this.queryVariables, + pageSize: DEFAULT_PAGE_SIZE_NOTES, + }; + }, + update(data) { + const workItemWidgets = this.fetchByIid + ? data.workspace?.workItems?.nodes[0]?.widgets + : data.workItem?.widgets; + return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || []; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-border-t gl-mt-5"> + <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> + <div v-if="areNotesLoading" class="gl-mt-5"> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <circle cx="20" cy="20" r="16" /> + <rect width="500" x="45" y="15" height="10" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-else class="issuable-discussion gl-mb-5 work-item-notes"> + <template v-if="notes && notes.length"> + <ul class="notes main-notes-list timeline"> + <system-note + v-for="note in notes" + :key="note.notes.nodes[0].id" + :note="note.notes.nodes[0]" + /> + </ul> + </template> + </div> + </div> +</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 96a6493357c..32678e29fa4 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 @@ -33,6 +33,11 @@ export default { }, computed: { iconName() { + // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865 + // is merged and updated in GitLab repo. + if (this.workItemIconName === 'issue-type-keyresult') { + return 'issue-type-key-result'; + } return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' ); diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 8b47c24de7d..3cd17f4d360 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -16,17 +16,23 @@ export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; 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_PROGRESS = 'PROGRESS'; 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 WIDGET_TYPE_NOTES = 'NOTES'; +export const WIDGET_TYPE_HEALTH_STATUS = 'HEALTH_STATUS'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; +export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; +export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; + +export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue'; +export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; export const i18n = { fetchErrorTitle: s__('WorkItem|Work item not found'), @@ -61,6 +67,13 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( 'WorkItem|Something went wrong when fetching iterations. Please try again.', ); +export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); +export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); +export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); +export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( + 'WorkItem|Search existing %{workItemType}s', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( @@ -100,11 +113,45 @@ export const WORK_ITEMS_TYPE_MAP = { icon: `issue-type-requirements`, name: s__('WorkItem|Requirements'), }, + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-objective`, + name: s__('WorkItem|Objective'), + }, + [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Key Result'), + }, +}; + +export const WORK_ITEMS_TREE_TEXT_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: { + title: s__('WorkItem|Child objectives and key results'), + empty: s__('WorkItem|No objectives or key results are currently assigned.'), + }, + [WORK_ITEM_TYPE_VALUE_ISSUE]: { + title: s__('WorkItem|Tasks'), + empty: s__( + 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', + ), + }, +}; + +export const WORK_ITEM_NAME_TO_ICON_MAP = { + Issue: 'issue-type-issue', + Task: 'issue-type-task', + Objective: 'issue-type-objective', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Key Result': 'issue-type-key-result', }; export const FORM_TYPES = { create: 'create', add: 'add', + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Objective'), + }, }; export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; +export const DEFAULT_PAGE_SIZE_NOTES = 100; diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql new file mode 100644 index 00000000000..62ced6bdfea --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment Discussion on Note { + id + body + bodyHtml + systemNoteIconName + createdAt + author { + ...User + } +} diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql index 58140aff89e..5c93370aac9 100644 --- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql @@ -2,4 +2,7 @@ fragment MilestoneFragment on Milestone { expired id title + state + startDate + dueDate } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 7b63d9c7ca3..7fcf622cdb2 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -20,9 +20,12 @@ query workItemLinksQuery($id: WorkItemID!) { children { nodes { id + iid confidential workItemType { id + name + iconName } title state diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql new file mode 100644 index 00000000000..baefcdaea93 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -0,0 +1,29 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/work_items/graphql/milestone.fragment.graphql" + +fragment WorkItemMetadataWidgets on WorkItemWidget { + ... on WorkItemWidgetMilestone { + type + milestone { + ...MilestoneFragment + } + } + ... on WorkItemWidgetAssignees { + type + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetLabels { + type + allowsScopedLabels + labels { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql new file mode 100644 index 00000000000..9439f22f955 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { + workItem(id: $id) { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql new file mode 100644 index 00000000000..3e0960f3f54 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql @@ -0,0 +1,32 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql new file mode 100644 index 00000000000..006ca29e01c --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -0,0 +1,53 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" + +query workItemTreeQuery($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + name + iconName + } + title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + children { + nodes { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } + } + } + } + ...WorkItemMetadataWidgets + } + } +} 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 b9715c21c27..cf3374e1737 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,6 +1,7 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { @@ -38,15 +39,39 @@ fragment WorkItemWidgets on WorkItemWidget { } ... on WorkItemWidgetHierarchy { type + hasChildren parent { id iid title confidential + webUrl + workItemType { + id + name + iconName + } } children { nodes { id + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } } } } @@ -56,4 +81,7 @@ fragment WorkItemWidgets on WorkItemWidget { ...MilestoneFragment } } + ... on WorkItemWidgetNotes { + type + } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 4fbcdfe2b96..a056fde6928 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,14 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath, hasIterationsFeature } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + hasIterationsFeature, + hasOkrsFeature, + hasIssuableHealthStatusFeature, + } = el.dataset; return new Vue({ el, @@ -15,9 +22,12 @@ export const initWorkItemsRoot = () => { apolloProvider, provide: { fullPath, + projectPath: fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), issuesListPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 1c00bd16263..d04d4942253 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -70,6 +70,10 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" /> + <work-item-detail + :work-item-id="gid" + :work-item-iid="id" + @deleteWorkItem="deleteWorkItem($event)" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 17f9c882c2d..e58fd19ea31 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,6 +1,12 @@ import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; } + +export function getWorkItemNotesQuery(isFetchedByIid) { + return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; +} |