summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/work_items/components
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-12-20 14:22:11 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-12-20 14:22:11 +0000
commit0c872e02b2c822e3397515ec324051ff540f0cd5 (patch)
treece2fb6ce7030e4dad0f4118d21ab6453e5938cdd /app/assets/javascripts/work_items/components
parentf7e05a6853b12f02911494c4b3fe53d9540d74fc (diff)
downloadgitlab-ce-0c872e02b2c822e3397515ec324051ff540f0cd5.tar.gz
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/work_items/components')
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue229
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue89
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue16
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue241
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_information.vue53
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue66
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue248
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue123
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue111
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue86
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue244
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue68
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue109
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue5
19 files changed, 1461 insertions, 253 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
+ >
+ &middot;
+ <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'
);