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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/work_items')
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue16
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue1
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue9
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue27
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue5
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue5
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue20
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue1
-rw-r--r--app/assets/javascripts/work_items/components/shared/work_item_token_input.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue89
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue35
-rw-r--r--app/assets/javascripts/work_items/components/work_item_created_updated.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue29
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue17
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_parent.vue249
-rw-r--r--app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue249
-rw-r--r--app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue142
-rw-r--r--app/assets/javascripts/work_items/components/work_item_todos.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue5
-rw-r--r--app/assets/javascripts/work_items/constants.js28
-rw-r--r--app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js15
-rw-r--r--app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql12
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql15
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/index.js19
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue5
-rw-r--r--app/assets/javascripts/work_items/utils.js5
44 files changed, 1005 insertions, 195 deletions
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index 57faed61280..c867e53dc30 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -5,6 +5,7 @@ import { ASC } from '~/notes/constants';
import { __ } from '~/locale';
import { clearDraft } from '~/lib/utils/autosave';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
+import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
@@ -21,8 +22,12 @@ export default {
WorkItemCommentForm,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -90,7 +95,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
@@ -109,6 +116,9 @@ export default {
},
},
computed: {
+ isLoading() {
+ return this.$apollo.queries.workItem.loading;
+ },
signedIn() {
return Boolean(window.gon.current_user_id);
},
@@ -248,7 +258,7 @@ export default {
<li :class="timelineEntryClass">
<work-item-note-signed-out v-if="!signedIn" />
<work-item-comment-locked
- v-else-if="!canCreateNote"
+ v-else-if="!isLoading && !canCreateNote"
:work-item-type="workItemType"
:is-project-archived="isProjectArchived"
/>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index a79169bde1e..c7d8a50f402 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -35,7 +35,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
index fd8842aa01a..fed21a1c277 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -18,8 +18,11 @@ export default {
DiscussionNotesRepliesWrapper,
WorkItemNoteReplying,
},
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -154,6 +157,7 @@ export default {
:is-first-note="true"
:note="note"
:discussion-id="discussionId"
+ :full-path="fullPath"
:has-replies="hasReplies"
:work-item-type="workItemType"
:is-modal="isModal"
@@ -180,6 +184,7 @@ export default {
:is-first-note="true"
:note="note"
:discussion-id="discussionId"
+ :full-path="fullPath"
:has-replies="hasReplies"
:work-item-type="workItemType"
:is-modal="isModal"
@@ -207,6 +212,7 @@ export default {
<work-item-note
:key="threadKey(reply)"
:discussion-id="discussionId"
+ :full-path="fullPath"
:note="reply"
:work-item-type="workItemType"
:is-modal="isModal"
@@ -231,6 +237,7 @@ export default {
v-if="shouldShowReplyForm"
:notes-form="false"
:autofocus="autofocus"
+ :full-path="fullPath"
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:discussion-id="discussionId"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index b5e3ea68725..f4c654f054c 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -3,7 +3,6 @@ import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
-import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import Tracking from '~/tracking';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
@@ -11,15 +10,17 @@ import { getLocationHash } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
-import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
-import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../../constants';
+import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import { isAssigneesWidget } from '../../utils';
import WorkItemCommentForm from './work_item_comment_form.vue';
+import NoteActions from './work_item_note_actions.vue';
import WorkItemNoteAwardsList from './work_item_note_awards_list.vue';
+import NoteBody from './work_item_note_body.vue';
export default {
name: 'WorkItemNoteThread',
@@ -35,8 +36,12 @@ export default {
EditedAt,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -169,7 +174,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
@@ -335,6 +342,7 @@ export default {
</note-header>
<div class="gl-display-inline-flex">
<note-actions
+ :full-path="fullPath"
:show-award-emoji="hasAwardEmojiPermission"
:work-item-iid="workItemIid"
:note="note"
@@ -372,7 +380,12 @@ export default {
/>
</div>
<div class="note-awards" :class="isFirstNote ? '' : 'gl-pl-7'">
- <work-item-note-awards-list :note="note" :work-item-iid="workItemIid" :is-modal="isModal" />
+ <work-item-note-awards-list
+ :full-path="fullPath"
+ :note="note"
+ :work-item-iid="workItemIid"
+ :is-modal="isModal"
+ />
</div>
</div>
</timeline-entry-item>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index e5da3d346ae..2cdf8b5ea9d 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -33,8 +33,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemIid: {
type: String,
required: true,
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue
index 3c30c204ab6..17d22e66530 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue
@@ -8,8 +8,11 @@ export default {
components: {
AwardsList,
},
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemIid: {
type: String,
required: true,
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
index f50cfac90f7..49813edf6fc 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -43,9 +43,14 @@ export default {
type: Boolean,
required: true,
},
- childPath: {
- type: String,
- required: true,
+ /*
+ This flag is added to manage between two different work items; Task and Objective/Key result.
+ Status icon is shown on the task while the actual task icon is shown on any Objective/Key result.
+ */
+ showTaskIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
},
},
computed: {
@@ -69,7 +74,7 @@ export default {
return this.childItem.state === STATE_OPEN;
},
iconName() {
- if (this.childItemType === TASK_TYPE_NAME) {
+ if (this.childItemType === TASK_TYPE_NAME && !this.showTaskIcon) {
return this.isChildItemOpen ? 'issue-open-m' : 'issue-close';
}
return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType];
@@ -78,7 +83,7 @@ export default {
return this.childItem.workItemType.name;
},
iconClass() {
- if (this.childItemType === TASK_TYPE_NAME) {
+ if (this.childItemType === TASK_TYPE_NAME && !this.showTaskIcon) {
return this.isChildItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500';
}
return '';
@@ -148,9 +153,8 @@ export default {
/>
</span>
<gl-link
- :href="childPath"
- class="gl-text-truncate gl-font-weight-semibold"
- data-testid="item-title"
+ :href="childItem.webUrl"
+ class="gl-overflow-break-word gl-font-weight-semibold"
@click="$emit('click', $event)"
@mouseover="$emit('mouseover')"
@mouseout="$emit('mouseout')"
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue
index 38d8d239a7e..c0e87f0bb6e 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_metadata.vue
@@ -69,6 +69,7 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
+ class="gl-white-space-nowrap"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
index 7b38e838033..3595ab631df 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue
@@ -7,7 +7,6 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import {
WORK_ITEMS_TYPE_MAP,
- WORK_ITEM_TYPE_ENUM_TASK,
I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
sprintfWorkItem,
} from '../../constants';
@@ -29,7 +28,7 @@ export default {
childrenType: {
type: String,
required: false,
- default: WORK_ITEM_TYPE_ENUM_TASK,
+ default: '',
},
childrenIds: {
type: Array,
@@ -53,7 +52,7 @@ export default {
return {
fullPath: this.fullPath,
searchTerm: this.search?.title || this.search,
- types: [this.childrenType],
+ types: this.childrenType ? [this.childrenType] : [],
in: this.search ? 'TITLE' : undefined,
};
},
@@ -106,6 +105,7 @@ export default {
},
handleFocus() {
this.searchStarted = true;
+ this.$emit('searching', true);
},
handleMouseOver() {
this.timeout = setTimeout(() => {
@@ -115,11 +115,22 @@ export default {
handleMouseOut() {
clearTimeout(this.timeout);
},
+ handleBlur() {
+ this.$emit('searching', false);
+ },
+ focusInputText() {
+ this.$nextTick(() => {
+ if (this.areWorkItemsToAddValid) {
+ this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus();
+ }
+ });
+ },
},
};
</script>
<template>
<gl-token-selector
+ ref="tokenSelector"
v-model="workItemsToAdd"
:dropdown-items="availableWorkItems"
:loading="isLoading"
@@ -131,13 +142,14 @@ export default {
@focus="handleFocus"
@mouseover.native="handleMouseOver"
@mouseout.native="handleMouseOut"
+ @token-add="focusInputText"
+ @token-remove="focusInputText"
+ @blur="handleBlur"
>
- <template #token-content="{ token }">
- {{ token.title }}
- </template>
+ <template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template>
<template #dropdown-item-content="{ dropdownItem }">
<div class="gl-display-flex">
- <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
+ <div class="gl-text-secondary gl-font-sm gl-mr-4">{{ dropdownItem.iid }}</div>
<div class="gl-text-truncate">{{ dropdownItem.title }}</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 18aa4d55086..02d2ea24ca0 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -7,7 +7,6 @@ import {
GlModalDirective,
GlToggle,
} from '@gitlab/ui';
-import { produce } from 'immer';
import * as Sentry from '@sentry/browser';
@@ -15,7 +14,6 @@ import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import toast from '~/vue_shared/plugins/global_toast';
import { isLoggedIn } from '~/lib/utils/common_utils';
-import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
sprintfWorkItem,
@@ -28,7 +26,6 @@ import {
TEST_ID_PROMOTE_ACTION,
TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
TEST_ID_COPY_REFERENCE_ACTION,
- WIDGET_TYPE_NOTIFICATIONS,
I18N_WORK_ITEM_ERROR_CONVERTING,
WORK_ITEM_TYPE_VALUE_KEY_RESULT,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
@@ -70,8 +67,12 @@ export default {
copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
deleteActionTestId: TEST_ID_DELETE_ACTION,
promoteActionTestId: TEST_ID_PROMOTE_ACTION,
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: false,
@@ -127,10 +128,6 @@ export default {
required: false,
default: false,
},
- workItemIid: {
- type: String,
- required: true,
- },
},
apollo: {
workItemTypes: {
@@ -199,80 +196,31 @@ export default {
}
},
toggleNotifications(subscribed) {
- const inputVariables = {
- projectPath: this.fullPath,
- iid: this.workItemIid,
- subscribedState: subscribed,
- };
this.$apollo
.mutate({
mutation: updateWorkItemNotificationsMutation,
variables: {
- input: inputVariables,
- },
- optimisticResponse: {
- updateWorkItemNotificationsSubscription: {
- issue: {
- id: this.workItemId,
- subscribed,
- },
- errors: [],
- },
- },
- update: (
- cache,
- {
- data: {
- updateWorkItemNotificationsSubscription: { issue = {} },
- },
+ input: {
+ id: this.workItemId,
+ subscribed,
},
- ) => {
- // As the mutation and the query both are different,
- // overwrite the subscribed value in the cache
- this.updateWorkItemNotificationsWidgetCache({
- cache,
- issue,
- });
},
})
- .then(
- ({
- data: {
- updateWorkItemNotificationsSubscription: { errors },
- },
- }) => {
- if (errors?.length) {
- throw new Error(errors[0]);
- }
- toast(
- subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
- );
- },
- )
+ .then(({ data }) => {
+ const { errors } = data.workItemSubscribe;
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+
+ toast(
+ subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
+ );
+ })
.catch((error) => {
this.$emit('error', error.message);
Sentry.captureException(error);
});
},
- updateWorkItemNotificationsWidgetCache({ cache, issue }) {
- const query = {
- query: workItemByIidQuery,
- variables: { fullPath: this.fullPath, iid: this.workItemIid },
- };
- // Read the work item object
- const sourceData = cache.readQuery(query);
-
- const newData = produce(sourceData, (draftState) => {
- const { widgets } = draftState.workspace.workItems.nodes[0];
-
- const widgetNotifications = widgets.find(({ type }) => type === WIDGET_TYPE_NOTIFICATIONS);
- // overwrite the subscribed value
- widgetNotifications.subscribed = issue.subscribed;
- });
-
- // write to the cache
- cache.writeQuery({ ...query, data: newData });
- },
throwConvertError() {
this.$emit('error', this.i18n.convertError);
},
@@ -337,7 +285,6 @@ export default {
:data-testid="$options.notificationsToggleTestId"
class="work-item-notification-toggle"
label-position="left"
- label-id="notifications-toggle"
@change="toggleNotifications($event)"
/>
</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 f9527884adc..a9aafbb3d84 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -13,7 +13,8 @@ import {
import { debounce, uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
-import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import groupUsersSearchQuery from '~/graphql_shared/queries/group_users_search.query.graphql';
+import usersSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { n__, s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -54,8 +55,12 @@ export default {
GlIntersectionObserver,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -99,7 +104,7 @@ export default {
apollo: {
users: {
query() {
- return userSearchQuery;
+ return this.isGroup ? groupUsersSearchQuery : usersSearchQuery;
},
variables() {
return {
diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
index 139f0f7919c..fd01d855782 100644
--- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -4,17 +4,21 @@ import {
sprintfWorkItem,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_HEALTH_STATUS,
+ WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_ITERATION,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_PROGRESS,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
+ WORK_ITEM_TYPE_VALUE_KEY_RESULT,
+ WORK_ITEM_TYPE_VALUE_OBJECTIVE,
} from '../constants';
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 WorkItemParent from './work_item_parent.vue';
export default {
components: {
@@ -22,6 +26,7 @@ export default {
WorkItemMilestone,
WorkItemAssignees,
WorkItemDueDate,
+ WorkItemParent,
WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'),
WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
@@ -29,8 +34,11 @@ export default {
import('ee_component/work_items/components/work_item_health_status.vue'),
},
mixins: [glFeatureFlagMixin()],
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItem: {
type: Object,
required: true,
@@ -81,9 +89,21 @@ export default {
workItemHealthStatus() {
return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS);
},
+ workItemHierarchy() {
+ return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY);
+ },
workItemMilestone() {
return this.isWidgetPresent(WIDGET_TYPE_MILESTONE);
},
+ showWorkItemParent() {
+ return (
+ this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE ||
+ this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT
+ );
+ },
+ workItemParent() {
+ return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent;
+ },
},
methods: {
isWidgetPresent(type) {
@@ -98,6 +118,7 @@ export default {
<work-item-assignees
v-if="workItemAssignees"
:can-update="canUpdate"
+ :full-path="fullPath"
:work-item-id="workItem.id"
:assignees="workItemAssignees.assignees.nodes"
:allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
@@ -108,6 +129,7 @@ export default {
<work-item-labels
v-if="workItemLabels"
:can-update="canUpdate"
+ :full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
@error="$emit('error', $event)"
@@ -123,6 +145,7 @@ export default {
/>
<work-item-milestone
v-if="workItemMilestone"
+ :full-path="fullPath"
:work-item-id="workItem.id"
:work-item-milestone="workItemMilestone.milestone"
:work-item-type="workItemType"
@@ -151,6 +174,7 @@ export default {
<work-item-iteration
v-if="workItemIteration"
class="gl-mb-5"
+ :full-path="fullPath"
:iteration="workItemIteration.iteration"
:can-update="canUpdate"
:work-item-id="workItem.id"
@@ -168,5 +192,14 @@ export default {
:work-item-type="workItemType"
@error="$emit('error', $event)"
/>
+ <work-item-parent
+ v-if="showWorkItemParent"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :parent="workItemParent"
+ @error="$emit('error', $event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
index 14e55134048..460b5d35187 100644
--- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue
+++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
@@ -3,10 +3,11 @@ import { GlAvatarLink, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { WORKSPACE_PROJECT } from '~/issues/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
-import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import WorkItemStateBadge from './work_item_state_badge.vue';
+import WorkItemTypeIcon from './work_item_type_icon.vue';
export default {
components: {
@@ -18,8 +19,12 @@ export default {
ConfidentialityBadge,
GlLoadingIcon,
},
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemIid: {
type: String,
required: false,
@@ -59,7 +64,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
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 58bf524f450..b7f3ac93cdb 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -10,6 +10,7 @@ import Tracking from '~/tracking';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { autocompleteDataSources, markdownPreviewPath } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
@@ -25,8 +26,12 @@ export default {
WorkItemDescriptionRendered,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -55,7 +60,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
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 edecd7addcc..53929775684 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -16,7 +16,6 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
-import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
import { WORKSPACE_PROJECT } from '~/issues/constants';
@@ -37,6 +36,7 @@ import {
import workItemUpdatedSubscription from '../graphql/work_item_updated.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { findHierarchyWidgetChildren } from '../utils';
@@ -52,6 +52,7 @@ import WorkItemDetailModal from './work_item_detail_modal.vue';
import WorkItemAwardEmoji from './work_item_award_emoji.vue';
import WorkItemStateToggleButton from './work_item_state_toggle_button.vue';
import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue';
+import WorkItemTypeIcon from './work_item_type_icon.vue';
export default {
i18n,
@@ -84,7 +85,7 @@ export default {
WorkItemRelationships,
},
mixins: [glFeatureFlagMixin()],
- inject: ['fullPath', 'reportAbusePath'],
+ inject: ['fullPath', 'isGroup', 'reportAbusePath'],
props: {
isModal: {
type: Boolean,
@@ -118,7 +119,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
@@ -189,8 +192,8 @@ export default {
canAssignUnassignUser() {
return this.workItemAssignees && this.canSetWorkItemMetadata;
},
- fullPath() {
- return this.workItem?.project.fullPath;
+ projectFullPath() {
+ return this.workItem?.project?.fullPath;
},
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
@@ -460,11 +463,12 @@ export default {
v-if="showWorkItemCurrentUserTodos"
:work-item-id="workItem.id"
:work-item-iid="workItemIid"
- :work-item-fullpath="workItem.project.fullPath"
+ :work-item-fullpath="projectFullPath"
:current-user-todos="currentUserTodos"
@error="updateError = $event"
/>
<work-item-actions
+ :full-path="fullPath"
:work-item-id="workItem.id"
:subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
@@ -476,7 +480,6 @@ export default {
:work-item-reference="workItem.reference"
:work-item-create-note-email="workItem.createNoteEmail"
:is-modal="isModal"
- :work-item-iid="workItemIid"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
@@ -503,6 +506,7 @@ export default {
@error="updateError = $event"
/>
<work-item-created-updated
+ :full-path="fullPath"
:work-item-iid="workItemIid"
:update-in-progress="updateInProgress"
/>
@@ -535,11 +539,12 @@ export default {
v-if="showWorkItemCurrentUserTodos"
:work-item-id="workItem.id"
:work-item-iid="workItemIid"
- :work-item-fullpath="workItem.project.fullPath"
+ :work-item-fullpath="projectFullPath"
:current-user-todos="currentUserTodos"
@error="updateError = $event"
/>
<work-item-actions
+ :full-path="fullPath"
:work-item-id="workItem.id"
:subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
@@ -551,7 +556,6 @@ export default {
:work-item-reference="workItem.reference"
:work-item-create-note-email="workItem.createNoteEmail"
:is-modal="isModal"
- :work-item-iid="workItemIid"
@deleteWorkItem="
$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })
"
@@ -571,12 +575,14 @@ export default {
<work-item-attributes-wrapper
:class="{ 'gl-md-display-none!': workItemsMvc2Enabled }"
class="gl-border-b"
+ :full-path="fullPath"
:work-item="workItem"
:work-item-parent-id="workItemParentId"
@error="updateError = $event"
/>
<work-item-description
v-if="hasDescriptionWidget"
+ :full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
class="gl-pt-5"
@@ -585,7 +591,7 @@ export default {
<work-item-award-emoji
v-if="workItemAwardEmoji"
:work-item-id="workItem.id"
- :work-item-fullpath="workItem.project.fullPath"
+ :work-item-fullpath="projectFullPath"
:award-emoji="workItemAwardEmoji.awardEmoji"
:work-item-iid="workItemIid"
@error="updateError = $event"
@@ -593,6 +599,7 @@ export default {
/>
<work-item-tree
v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ :full-path="fullPath"
:work-item-type="workItemType"
:parent-work-item-type="workItem.workItemType.name"
:work-item-id="workItem.id"
@@ -605,12 +612,15 @@ export default {
/>
<work-item-relationships
v-if="showWorkItemLinkedItems"
+ :work-item-id="workItem.id"
:work-item-iid="workItemIid"
- :work-item-full-path="workItem.project.fullPath"
+ :work-item-full-path="projectFullPath"
+ :work-item-type="workItem.workItemType.name"
@showModal="openInModal"
/>
<work-item-notes
v-if="workItemNotes"
+ :full-path="fullPath"
:work-item-id="workItem.id"
:work-item-iid="workItem.iid"
:work-item-type="workItemType"
@@ -629,6 +639,7 @@ export default {
:title="$options.i18n.fetchErrorTitle"
:description="error"
:svg-path="noAccessSvgPath"
+ :svg-height="null"
/>
</section>
<aside
@@ -638,6 +649,7 @@ export default {
:class="{ 'is-modal': isModal }"
>
<work-item-attributes-wrapper
+ :full-path="fullPath"
:work-item="workItem"
:work-item-parent-id="workItemParentId"
@error="updateError = $event"
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 1405a12a101..3cdbf816421 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -8,6 +8,7 @@ import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_it
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants';
import { isLabelsWidget } from '../utils';
@@ -37,8 +38,12 @@ export default {
LabelItem,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -65,7 +70,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
index 9d9414b5399..f4de7c1dddc 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
@@ -13,6 +13,7 @@ import { findHierarchyWidgets } from '../../utils';
import { addHierarchyChild, removeHierarchyChild } from '../../graphql/cache_utils';
import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WorkItemLinkChild from './work_item_link_child.vue';
@@ -20,8 +21,12 @@ export default {
components: {
WorkItemLinkChild,
},
- inject: ['fullPath'],
+ inject: ['isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemType: {
type: String,
required: false,
@@ -83,7 +88,14 @@ export default {
const { data } = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: { input: { id: child.id, hierarchyWidget: { parentId: null } } },
- update: (cache) => removeHierarchyChild(cache, this.fullPath, this.workItemIid, child),
+ update: (cache) =>
+ removeHierarchyChild({
+ cache,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ isGroup: this.isGroup,
+ workItem: child,
+ }),
});
if (data.workItemUpdate.errors.length) {
@@ -109,7 +121,14 @@ export default {
const { data } = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } },
- update: (cache) => addHierarchyChild(cache, this.fullPath, this.workItemIid, child),
+ update: (cache) =>
+ addHierarchyChild({
+ cache,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ isGroup: this.isGroup,
+ workItem: child,
+ }),
});
if (data.workItemUpdate.errors.length) {
@@ -124,7 +143,7 @@ export default {
},
addWorkItemQuery({ iid }) {
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query: workItemByIidQuery,
+ query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
variables: {
fullPath: this.fullPath,
iid,
@@ -206,7 +225,7 @@ export default {
update: (store) => {
store.updateQuery(
{
- query: workItemByIidQuery,
+ query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
variables: { fullPath: this.fullPath, iid: this.workItemIid },
},
(sourceData) =>
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 679287338c8..847a3585ac4 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
@@ -13,7 +13,6 @@ import {
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
-import { workItemPath } from '../../utils';
import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue';
import WorkItemTreeChildren from './work_item_tree_children.vue';
@@ -27,7 +26,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['fullPath'],
props: {
canUpdate: {
type: Boolean,
@@ -90,9 +88,6 @@ export default {
stateTimestampTypeText() {
return this.isItemOpen ? __('Created') : __('Closed');
},
- childPath() {
- return workItemPath(this.fullPath, this.childItem.iid);
- },
chevronType() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
@@ -236,7 +231,6 @@ export default {
:can-update="canUpdate"
:parent-work-item-id="issuableGid"
:work-item-type="workItemType"
- :child-path="childPath"
@click="$emit('click', $event)"
@removeChild="$emit('removeChild', childItem)"
/>
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 eb836007e75..7fa6ac2c57f 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
@@ -18,6 +18,7 @@ import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_sel
import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants';
import { findHierarchyWidgetChildren } from '../../utils';
import { removeHierarchyChild } from '../../graphql/cache_utils';
+import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
@@ -39,7 +40,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['fullPath', 'reportAbusePath'],
+ inject: ['fullPath', 'isGroup', 'reportAbusePath'],
props: {
issuableId: {
type: Number,
@@ -52,7 +53,9 @@ export default {
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.fullPath,
@@ -171,7 +174,13 @@ export default {
},
handleWorkItemDeleted(child) {
const { defaultClient: cache } = this.$apollo.provider.clients;
- removeHierarchyChild(cache, this.fullPath, this.iid, child);
+ removeHierarchyChild({
+ cache,
+ fullPath: this.fullPath,
+ iid: this.iid,
+ isGroup: this.isGroup,
+ workItem: child,
+ });
this.$toast.show(s__('WorkItem|Task deleted'));
},
updateWorkItemIdUrlQuery({ iid } = {}) {
@@ -256,6 +265,7 @@ export default {
v-if="isShownAddForm"
ref="wiLinksForm"
data-testid="add-links-form"
+ :full-path="fullPath"
:issuable-gid="issuableGid"
:work-item-iid="iid"
:children-ids="childrenIds"
@@ -269,6 +279,7 @@ export default {
<work-item-children-wrapper
:children="children"
:can-update="canUpdate"
+ :full-path="fullPath"
:work-item-id="issuableGid"
:work-item-iid="iid"
@error="error = $event"
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 55440e1603c..f24b56cac36 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
@@ -37,8 +37,12 @@ export default {
GlTooltip,
WorkItemTokenInput,
},
- inject: ['fullPath', 'hasIterationsFeature'],
+ inject: ['hasIterationsFeature', 'isGroup'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
issuableGid: {
type: String,
required: false,
@@ -225,7 +229,6 @@ export default {
this.error = null;
},
addChild() {
- this.searchStarted = false;
this.$apollo
.mutate({
mutation: updateWorkItemMutation,
@@ -261,7 +264,13 @@ export default {
input: this.workItemInput,
},
update: (cache, { data }) =>
- addHierarchyChild(cache, this.fullPath, this.workItemIid, data.workItemCreate.workItem),
+ addHierarchyChild({
+ cache,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ isGroup: this.isGroup,
+ workItem: data.workItemCreate.workItem,
+ }),
})
.then(({ data }) => {
if (data.workItemCreate?.errors?.length) {
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
index bc3f5201fb8..b61b3b2e0d3 100644
--- 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
@@ -22,8 +22,11 @@ export default {
WorkItemLinksForm,
WorkItemChildrenWrapper,
},
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemType: {
type: String,
required: true,
@@ -139,6 +142,7 @@ export default {
v-if="isShownAddForm"
ref="wiLinksForm"
data-testid="add-tree-form"
+ :full-path="fullPath"
:issuable-gid="workItemId"
:work-item-iid="workItemIid"
:form-type="formType"
@@ -152,6 +156,7 @@ export default {
<work-item-children-wrapper
:children="children"
:can-update="canUpdate"
+ :full-path="fullPath"
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="workItemType"
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
index 2cabf489bc6..401223c3593 100644
--- 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
@@ -3,7 +3,6 @@ export default {
components: {
WorkItemLinkChild: () => import('./work_item_link_child.vue'),
},
- inject: ['fullPath'],
props: {
workItemType: {
type: String,
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 6cc61ed4756..a2cbb7f7598 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -46,8 +46,11 @@ export default {
GlDropdownText,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 256f8ed53d1..fe8aea99f53 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -46,8 +46,11 @@ export default {
WorkItemNotesActivityHeader,
WorkItemHistoryOnlyFilterNote,
},
- inject: ['fullPath'],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
workItemId: {
type: String,
required: true,
@@ -364,6 +367,7 @@ export default {
<work-item-discussion
:key="getDiscussionKey(discussion)"
:discussion="discussion.notes.nodes"
+ :full-path="fullPath"
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="workItemType"
diff --git a/app/assets/javascripts/work_items/components/work_item_parent.vue b/app/assets/javascripts/work_items/components/work_item_parent.vue
new file mode 100644
index 00000000000..e16299f482f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_parent.vue
@@ -0,0 +1,249 @@
+<script>
+import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { debounce } from 'lodash';
+
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+
+import projectWorkItemsQuery from '../graphql/project_work_items.query.graphql';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ WORK_ITEM_TYPE_ENUM_OBJECTIVE,
+} from '../constants';
+
+export default {
+ i18n: {
+ assignParentLabel: s__('WorkItem|Assign parent'),
+ parentLabel: s__('WorkItem|Parent'),
+ none: s__('WorkItem|None'),
+ noMatchingResults: s__('WorkItem|No matching results'),
+ unAssign: s__('WorkItem|Unassign'),
+ workItemsFetchError: s__(
+ 'WorkItem|Something went wrong while fetching items. Please try again.',
+ ),
+ },
+ components: {
+ GlFormGroup,
+ GlCollapsibleListbox,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ parent: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ search: '',
+ updateInProgress: false,
+ searchStarted: false,
+ availableWorkItems: [],
+ localSelectedItem: this.parent?.id,
+ isNotFocused: true,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.availableWorkItems.loading;
+ },
+ listboxText() {
+ return (
+ this.workItems.filter((item) => this.localSelectedItem === item.value)?.[0]?.text ||
+ this.parent?.title ||
+ this.$options.i18n.none
+ );
+ },
+ workItemsMvc2Enabled() {
+ return this.glFeatures.workItemsMvc2;
+ },
+ workItems() {
+ return this.availableWorkItems.map(({ id, title }) => ({ text: title, value: id }));
+ },
+ listboxCategory() {
+ return this.searchStarted ? 'secondary' : 'tertiary';
+ },
+ listboxClasses() {
+ return {
+ 'is-not-focused': this.isNotFocused && !this.searchStarted,
+ };
+ },
+ },
+ watch: {
+ parent: {
+ handler(newVal) {
+ this.localSelectedItem = newVal?.id;
+ },
+ },
+ },
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
+ apollo: {
+ availableWorkItems: {
+ query: projectWorkItemsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ searchTerm: this.search,
+ types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE],
+ in: this.search ? 'TITLE' : undefined,
+ };
+ },
+ skip() {
+ return !this.searchStarted;
+ },
+ update(data) {
+ return data.workspace.workItems.nodes.filter((wi) => this.workItemId !== wi.id) || [];
+ },
+ error() {
+ this.$emit('error', this.$options.i18n.workItemsFetchError);
+ },
+ },
+ },
+ methods: {
+ setSearchKey(value) {
+ this.search = value;
+ },
+ async updateParent() {
+ if (this.parent?.id === this.localSelectedItem) {
+ return;
+ }
+ this.updateInProgress = true;
+ try {
+ const {
+ data: {
+ workItemUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ hierarchyWidget: {
+ parentId:
+ this.localSelectedItem === 'no-work-item-id' ? null : this.localSelectedItem,
+ },
+ },
+ },
+ });
+
+ if (errors.length) {
+ this.$emit('error', errors.join('\n'));
+ this.localSelectedItem = this.parent?.id || 'no-work-item-id';
+ }
+ } catch (error) {
+ this.$emit('error', sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType));
+ Sentry.captureException(error);
+ } finally {
+ this.updateInProgress = false;
+ }
+ },
+ handleItemClick(item) {
+ this.localSelectedItem = item;
+ this.searchStarted = false;
+ this.search = '';
+ this.updateParent();
+ },
+ unAssignParent() {
+ this.localSelectedItem = 'no-work-item-id';
+ this.updateParent();
+ },
+ onListboxShown() {
+ this.searchStarted = true;
+ this.isNotFocused = false;
+ },
+ onListboxHide() {
+ this.searchStarted = false;
+ this.search = '';
+ this.isNotFocused = true;
+ },
+ setListboxFocused() {
+ // This is to match the caret behaviour of parent listbox
+ // to the other dropdown fields of work items
+ if (document.activeElement.parentElement.id !== 'work-item-parent-listbox-value') {
+ this.isNotFocused = true;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="work-item-dropdown gl-flex-nowrap"
+ data-testid="work-item-parent-form"
+ :label="$options.i18n.parentLabel"
+ label-for="work-item-parent-listbox-value"
+ label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break work-item-field-label"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <span
+ v-if="!canUpdate"
+ class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal work-item-field-value"
+ data-testid="disabled-text"
+ >
+ {{ listboxText }}
+ </span>
+ <div
+ v-else
+ :class="{ 'gl-max-w-max-content': !workItemsMvc2Enabled }"
+ @mouseover="isNotFocused = false"
+ @mouseleave="setListboxFocused"
+ @focusout="isNotFocused = true"
+ @focusin="isNotFocused = false"
+ >
+ <gl-collapsible-listbox
+ id="work-item-parent-listbox-value"
+ class="gl-max-w-max-content"
+ data-testid="work-item-parent-listbox"
+ block
+ searchable
+ :no-caret="isNotFocused && !searchStarted"
+ is-check-centered
+ :category="listboxCategory"
+ :searching="isLoading"
+ :header-text="$options.i18n.assignParentLabel"
+ :no-results-text="$options.i18n.noMatchingResults"
+ :loading="updateInProgress"
+ :items="workItems"
+ :toggle-text="listboxText"
+ :toggle-class="listboxClasses"
+ :selected="localSelectedItem"
+ :reset-button-label="$options.i18n.unAssign"
+ @reset="unAssignParent"
+ @search="debouncedSearchKeyUpdate"
+ @select="handleItemClick"
+ @shown="onListboxShown"
+ @hidden="onListboxHide"
+ >
+ <template #list-item="{ item }">
+ <div @click="handleItemClick(item.value, $event)">
+ {{ item.text }}
+ </div>
+ </template>
+ </gl-collapsible-listbox>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue
new file mode 100644
index 00000000000..d242db95896
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue
@@ -0,0 +1,249 @@
+<script>
+import { produce } from 'immer';
+import { GlFormGroup, GlForm, GlFormRadioGroup, GlButton, GlAlert } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import WorkItemTokenInput from '../shared/work_item_token_input.vue';
+import addLinkedItemsMutation from '../../graphql/add_linked_items.mutation.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import {
+ LINK_ITEM_FORM_HEADER_LABEL,
+ WIDGET_TYPE_LINKED_ITEMS,
+ LINKED_ITEM_TYPE_VALUE,
+ MAX_WORK_ITEMS,
+ I18N_MAX_WORK_ITEMS_ERROR_MESSAGE,
+ I18N_MAX_WORK_ITEMS_NOTE_LABEL,
+} from '../../constants';
+
+export default {
+ components: {
+ GlForm,
+ GlButton,
+ GlFormGroup,
+ GlFormRadioGroup,
+ GlAlert,
+ WorkItemTokenInput,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemFullPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ childrenIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ linkedItemType: LINKED_ITEM_TYPE_VALUE.RELATED,
+ linkedItemTypes: [
+ {
+ text: this.$options.i18n.relatedToLabel,
+ value: LINKED_ITEM_TYPE_VALUE.RELATED,
+ },
+ {
+ text: this.$options.i18n.blockingLabel,
+ value: LINKED_ITEM_TYPE_VALUE.BLOCKS,
+ },
+ {
+ text: this.$options.i18n.blockedByLabel,
+ value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY,
+ },
+ ],
+ workItemsToAdd: [],
+ error: null,
+ showWorkItemsToAddInvalidMessage: false,
+ isSubmitting: false,
+ searchInProgress: false,
+ maxWorkItems: MAX_WORK_ITEMS,
+ };
+ },
+ computed: {
+ linkItemFormHeaderLabel() {
+ return LINK_ITEM_FORM_HEADER_LABEL[this.workItemType];
+ },
+ workItemsToAddInvalidMessage() {
+ return this.$options.i18n.addChildErrorMessage;
+ },
+ isSubmitButtonDisabled() {
+ return this.workItemsToAdd.length <= 0 || !this.areWorkItemsToAddValid;
+ },
+ areWorkItemsToAddValid() {
+ return this.workItemsToAdd.length <= this.maxWorkItems;
+ },
+ errorMessage() {
+ return !this.areWorkItemsToAddValid ? this.$options.i18n.maxItemsErrorMessage : '';
+ },
+ },
+ methods: {
+ async linkWorkItem() {
+ try {
+ if (this.searchInProgress) {
+ return;
+ }
+ this.isSubmitting = true;
+ const {
+ data: {
+ workItemAddLinkedItems: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: addLinkedItemsMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ linkType: this.linkedItemType,
+ workItemsIds: this.workItemsToAdd.map((wi) => wi.id),
+ },
+ },
+ update: (
+ cache,
+ {
+ data: {
+ workItemAddLinkedItems: { workItem },
+ },
+ },
+ ) => {
+ const queryArgs = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.workItemFullPath, iid: this.workItemIid },
+ };
+ const sourceData = cache.readQuery(queryArgs);
+
+ if (!sourceData) {
+ return;
+ }
+
+ cache.writeQuery({
+ ...queryArgs,
+ data: produce(sourceData, (draftState) => {
+ const linkedItemsWidget = draftState.workspace.workItems.nodes[0].widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS,
+ );
+
+ linkedItemsWidget.linkedItems = workItem.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS,
+ ).linkedItems;
+ }),
+ });
+ },
+ });
+
+ if (errors.length > 0) {
+ [this.error] = errors;
+ return;
+ }
+
+ this.workItemsToAdd = [];
+ this.unsetError();
+ this.showWorkItemsToAddInvalidMessage = false;
+ this.linkedItemType = LINKED_ITEM_TYPE_VALUE.RELATED;
+ this.$emit('submitted');
+ } catch (e) {
+ this.error = this.$options.i18n.addLinkedItemErrorMessage;
+ } finally {
+ this.isSubmitting = false;
+ }
+ },
+ unsetError() {
+ this.error = null;
+ },
+ },
+ i18n: {
+ addButtonLabel: __('Add'),
+ relatedToLabel: s__('WorkItem|relates to'),
+ blockingLabel: s__('WorkItem|blocks'),
+ blockedByLabel: s__('WorkItem|is blocked by'),
+ linkItemInputLabel: s__('WorkItem|the following item(s)'),
+ addLinkedItemErrorMessage: s__(
+ 'WorkItem|Something went wrong when trying to link a item. Please try again.',
+ ),
+ maxItemsNoteLabel: I18N_MAX_WORK_ITEMS_NOTE_LABEL,
+ maxItemsErrorMessage: I18N_MAX_WORK_ITEMS_ERROR_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <gl-form
+ class="gl-new-card-add-form"
+ data-testid="link-work-item-form"
+ @submit.stop.prevent="linkWorkItem"
+ >
+ <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
+ {{ error }}
+ </gl-alert>
+ <gl-form-group
+ :label="linkItemFormHeaderLabel"
+ label-for="linked-item-type-radio"
+ label-class="label-bold"
+ class="gl-mb-3"
+ >
+ <gl-form-radio-group
+ id="linked-item-type-radio"
+ v-model="linkedItemType"
+ :options="linkedItemTypes"
+ :checked="linkedItemType"
+ />
+ </gl-form-group>
+ <p class="gl-font-weight-bold gl-mb-2">
+ {{ $options.i18n.linkItemInputLabel }}
+ </p>
+ <div class="gl-mb-5">
+ <work-item-token-input
+ v-model="workItemsToAdd"
+ class="gl-mb-2"
+ :parent-work-item-id="workItemId"
+ :children-ids="childrenIds"
+ :are-work-items-to-add-valid="areWorkItemsToAddValid"
+ :full-path="workItemFullPath"
+ :max-selection-limit="maxWorkItems"
+ @searching="searchInProgress = $event"
+ />
+ <div v-if="errorMessage" class="gl-mb-2 gl-text-red-500">
+ {{ $options.i18n.maxItemsErrorMessage }}
+ </div>
+ <div v-if="!errorMessage" data-testid="max-work-item-note" class="gl-text-gray-500">
+ {{ $options.i18n.maxItemsNoteLabel }}
+ </div>
+ <div
+ v-if="showWorkItemsToAddInvalidMessage"
+ class="gl-text-red-500"
+ data-testid="work-items-invalid"
+ >
+ {{ workItemsToAddInvalidMessage }}
+ </div>
+ </div>
+ <gl-button
+ data-testid="link-work-item-button"
+ category="primary"
+ variant="confirm"
+ size="small"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="isSubmitting"
+ class="gl-mr-2"
+ >
+ {{ $options.i18n.addButtonLabel }}
+ </gl-button>
+ <gl-button category="secondary" size="small" @click="$emit('cancel')">
+ {{ s__('WorkItem|Cancel') }}
+ </gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue
index cbe830f9565..002c1786044 100644
--- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue
+++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue
@@ -1,6 +1,5 @@
<script>
import WorkItemLinkChildContents from '../shared/work_item_link_child_contents.vue';
-import { workItemPath } from '../../utils';
export default {
components: {
@@ -20,20 +19,11 @@ export default {
type: Boolean,
required: true,
},
- workItemFullPath: {
- type: String,
- required: true,
- },
- },
- methods: {
- linkedItemPath(fullPath, id) {
- return workItemPath(fullPath, id);
- },
},
};
</script>
<template>
- <div>
+ <div data-testid="work-item-linked-items-list">
<h4
v-if="heading"
data-testid="work-items-list-heading"
@@ -51,8 +41,9 @@ export default {
<work-item-link-child-contents
:child-item="linkedItem.workItem"
:can-update="canUpdate"
- :child-path="linkedItemPath(workItemFullPath, linkedItem.workItem.iid)"
+ :show-task-icon="true"
@click="$emit('showModal', { event: $event, child: linkedItem.workItem })"
+ @removeChild="$emit('removeLinkedItem', linkedItem.workItem)"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue
index 4f6879e9605..20427fe96c4 100644
--- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue
+++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue
@@ -1,23 +1,37 @@
<script>
-import { GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui';
+import { produce } from 'immer';
+import { GlLoadingIcon, GlIcon, GlButton, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import removeLinkedItemsMutation from '../../graphql/remove_linked_items.mutation.graphql';
import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemRelationshipList from './work_item_relationship_list.vue';
+import WorkItemAddRelationshipForm from './work_item_add_relationship_form.vue';
export default {
+ helpPath: helpPagePath('/user/okrs.md#linked-items-in-okrs'),
components: {
GlLoadingIcon,
GlIcon,
GlButton,
+ GlLink,
WidgetWrapper,
WorkItemRelationshipList,
+ WorkItemAddRelationshipForm,
},
+ inject: ['isGroup'],
props: {
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
workItemIid: {
type: String,
required: true,
@@ -26,10 +40,17 @@ export default {
type: String,
required: true,
},
+ workItemType: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
apollo: {
workItem: {
- query: workItemByIidQuery,
+ query() {
+ return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+ },
variables() {
return {
fullPath: this.workItemFullPath,
@@ -74,13 +95,13 @@ export default {
linksRelatesTo: [],
linksIsBlockedBy: [],
linksBlocks: [],
+ isShownLinkItemForm: false,
widgetName: 'linkeditems',
};
},
computed: {
- canUpdate() {
- // This will be false untill we implement remove item mutation
- return false;
+ canAdminWorkItemLink() {
+ return this.workItem?.userPermissions?.adminWorkItemLink;
},
isLoading() {
return this.$apollo.queries.workItem.loading;
@@ -91,18 +112,88 @@ export default {
linkedWorkItems() {
return this.linkedWorkItemsWidget?.linkedItems?.nodes || [];
},
+ childrenIds() {
+ return this.linkedWorkItems.map((item) => item.workItem.id);
+ },
linkedWorkItemsCount() {
return this.linkedWorkItems.length;
},
isEmptyRelatedWorkItems() {
- return !this.error && this.linkedWorkItems.length === 0;
+ return !this.isShownLinkItemForm && !this.error && this.linkedWorkItems.length === 0;
+ },
+ },
+ methods: {
+ showLinkItemForm() {
+ this.isShownLinkItemForm = true;
+ },
+ hideLinkItemForm() {
+ this.isShownLinkItemForm = false;
+ },
+ async removeLinkedItem(linkedItem) {
+ try {
+ const {
+ data: {
+ workItemRemoveLinkedItems: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: removeLinkedItemsMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ workItemsIds: [linkedItem.id],
+ },
+ },
+ update: (cache, { data: { workItemRemoveLinkedItems } }) => {
+ const errorMessages = workItemRemoveLinkedItems?.errors;
+ if (errorMessages && errorMessages.length > 0) {
+ [this.error] = errorMessages;
+ return;
+ }
+ const queryArgs = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.workItemFullPath, iid: this.workItemIid },
+ };
+ const sourceData = cache.readQuery(queryArgs);
+
+ if (!sourceData) {
+ return;
+ }
+
+ cache.writeQuery({
+ ...queryArgs,
+ data: produce(sourceData, (draftState) => {
+ const linkedItems =
+ draftState.workspace.workItems.nodes[0].widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS,
+ )?.linkedItems?.nodes || [];
+ const index = linkedItems.findIndex((item) => {
+ return item.workItem.id === linkedItem.id;
+ });
+ linkedItems.splice(index, 1);
+ }),
+ });
+ },
+ });
+
+ if (errors.length > 0) {
+ [this.error] = errors;
+ return;
+ }
+
+ this.$toast.show(s__('WorkItem|Linked item removed'));
+ } catch {
+ this.error = this.$options.i18n.removeLinkedItemErrorMessage;
+ }
},
},
i18n: {
title: s__('WorkItem|Linked Items'),
- fetchError: s__('WorkItem|Something went wrong when fetching tasks. Please refresh this page.'),
+ fetchError: s__('WorkItem|Something went wrong when fetching items. Please refresh this page.'),
emptyStateMessage: s__(
- "WorkItem|Link work items together to show that they're related or that one is blocking others.",
+ "WorkItem|Link items together to show that they're related or that one is blocking others.",
+ ),
+ removeLinkedItemErrorMessage: s__(
+ 'WorkItem|Something went wrong when removing item. Please refresh this page.',
),
addChildButtonLabel: s__('WorkItem|Add'),
relatedToTitle: s__('WorkItem|Related to'),
@@ -131,17 +222,36 @@ export default {
</div>
</template>
<template #header-right>
- <gl-button size="small" class="gl-ml-3">
+ <gl-button
+ v-if="canAdminWorkItemLink"
+ data-testid="link-item-add-button"
+ size="small"
+ class="gl-ml-3"
+ @click="showLinkItemForm"
+ >
<slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot>
</gl-button>
</template>
<template #body>
<div class="gl-new-card-content">
+ <work-item-add-relationship-form
+ v-if="isShownLinkItemForm"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
+ :work-item-full-path="workItemFullPath"
+ :children-ids="childrenIds"
+ :work-item-type="workItemType"
+ @submitted="hideLinkItemForm"
+ @cancel="hideLinkItemForm"
+ />
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
<template v-else>
- <div v-if="isEmptyRelatedWorkItems" data-testid="links-empty">
+ <div v-if="!isShownLinkItemForm && isEmptyRelatedWorkItems" data-testid="links-empty">
<p class="gl-new-card-empty">
{{ $options.i18n.emptyStateMessage }}
+ <gl-link :href="$options.helpPath" data-testid="help-link">
+ {{ __('Learn more.') }}
+ </gl-link>
</p>
</div>
<template v-else>
@@ -153,9 +263,9 @@ export default {
}"
:linked-items="linksBlocks"
:heading="$options.i18n.blockingTitle"
- :work-item-full-path="workItemFullPath"
- :can-update="canUpdate"
+ :can-update="canAdminWorkItemLink"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
+ @removeLinkedItem="removeLinkedItem"
/>
<work-item-relationship-list
v-if="linksIsBlockedBy.length"
@@ -165,17 +275,17 @@ export default {
}"
:linked-items="linksIsBlockedBy"
:heading="$options.i18n.blockedByTitle"
- :work-item-full-path="workItemFullPath"
- :can-update="canUpdate"
+ :can-update="canAdminWorkItemLink"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
+ @removeLinkedItem="removeLinkedItem"
/>
<work-item-relationship-list
v-if="linksRelatesTo.length"
:linked-items="linksRelatesTo"
:heading="$options.i18n.relatedToTitle"
- :work-item-full-path="workItemFullPath"
- :can-update="canUpdate"
+ :can-update="canAdminWorkItemLink"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
+ @removeLinkedItem="removeLinkedItem"
/>
</template>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue
index b21abf21be5..e6d7f2067ba 100644
--- a/app/assets/javascripts/work_items/components/work_item_todos.vue
+++ b/app/assets/javascripts/work_items/components/work_item_todos.vue
@@ -4,9 +4,10 @@ import { produce } from 'immer';
import { s__ } from '~/locale';
import { updateGlobalTodoCount } from '~/sidebar/utils';
-import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import createWorkItemTodosMutation from '~/work_items/graphql/create_work_item_todos.mutation.graphql';
-import markDoneWorkItemTodosMutation from '~/work_items/graphql/mark_done_work_item_todos.mutation.graphql';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import createWorkItemTodosMutation from '../graphql/create_work_item_todos.mutation.graphql';
+import markDoneWorkItemTodosMutation from '../graphql/mark_done_work_item_todos.mutation.graphql';
import {
TODO_ADD_ICON,
@@ -28,6 +29,7 @@ export default {
GlIcon,
GlButton,
},
+ inject: ['isGroup'],
props: {
workItemId: {
type: String,
@@ -148,7 +150,7 @@ export default {
},
updateWorkItemCurrentTodosWidgetCache({ cache, todos }) {
const query = {
- query: workItemByIidQuery,
+ query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
variables: { fullPath: this.workItemFullpath, iid: this.workItemIid },
};
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 5426f3965b3..76a73093206 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
@@ -36,6 +36,11 @@ export default {
return this.workItemType.toUpperCase().split(' ').join('_');
},
iconName() {
+ // TODO Delete this conditional once we have an `issue-type-epic` icon
+ if (this.workItemIconName === 'issue-type-epic') {
+ return 'epic';
+ }
+
return (
this.workItemIconName ||
WORK_ITEMS_TYPE_MAP[this.workItemTypeUppercase]?.icon ||
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 2b118247426..a64172acff4 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -112,8 +112,19 @@ export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__(
'WorkItem|Copy %{workItemType} email address',
);
+export const MAX_WORK_ITEMS = 10;
+
+export const I18N_MAX_WORK_ITEMS_ERROR_MESSAGE = sprintf(
+ s__('WorkItem|Only %{MAX_WORK_ITEMS} items can be added at a time.'),
+ { MAX_WORK_ITEMS },
+);
+export const I18N_MAX_WORK_ITEMS_NOTE_LABEL = sprintf(
+ s__('WorkItem|Add a maximum of %{MAX_WORK_ITEMS} items at a time.'),
+ { MAX_WORK_ITEMS },
+);
+
export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
- const workItemType = workItemTypeArg || s__('WorkItem|Work item');
+ const workItemType = workItemTypeArg || s__('WorkItem|item');
return capitalizeFirstCharacter(
sprintf(msg, {
workItemType: workItemType.toLocaleLowerCase(),
@@ -186,8 +197,11 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = {
Issue: 'issue-type-issue',
Task: 'issue-type-task',
Objective: 'issue-type-objective',
+ Incident: 'issue-type-incident',
// eslint-disable-next-line @gitlab/require-i18n-strings
'Key Result': 'issue-type-keyresult',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'Test Case': 'issue-type-test-case',
};
export const FORM_TYPES = {
@@ -262,3 +276,15 @@ export const LINKED_CATEGORIES_MAP = {
IS_BLOCKED_BY: 'is_blocked_by',
BLOCKS: 'blocks',
};
+
+export const LINKED_ITEM_TYPE_VALUE = {
+ RELATED: 'RELATED',
+ BLOCKED_BY: 'BLOCKED_BY',
+ BLOCKS: 'BLOCKS',
+};
+
+export const LINK_ITEM_FORM_HEADER_LABEL = {
+ [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: s__('WorkItem|The current objective'),
+ [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: s__('WorkItem|The current key result'),
+ [WORK_ITEM_TYPE_VALUE_TASK]: s__('WorkItem|The current task'),
+};
diff --git a/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql
new file mode 100644
index 00000000000..ba12c7f9b51
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./work_item.fragment.graphql"
+
+mutation addLinkedItems($input: WorkItemAddLinkedItemsInput!) {
+ workItemAddLinkedItems(input: $input) {
+ workItem {
+ ...WorkItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 14eedf5cdd8..aeeffea24e7 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -1,5 +1,6 @@
import { produce } from 'immer';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
+import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import { findHierarchyWidgetChildren } from '~/work_items/utils';
@@ -127,8 +128,11 @@ export const updateCacheAfterRemovingAwardEmojiFromNote = (currentNotes, note) =
});
};
-export const addHierarchyChild = (cache, fullPath, iid, workItem) => {
- const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } };
+export const addHierarchyChild = ({ cache, fullPath, iid, isGroup, workItem }) => {
+ const queryArgs = {
+ query: isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
+ variables: { fullPath, iid },
+ };
const sourceData = cache.readQuery(queryArgs);
if (!sourceData) {
@@ -143,8 +147,11 @@ export const addHierarchyChild = (cache, fullPath, iid, workItem) => {
});
};
-export const removeHierarchyChild = (cache, fullPath, iid, workItem) => {
- const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } };
+export const removeHierarchyChild = ({ cache, fullPath, iid, isGroup, workItem }) => {
+ const queryArgs = {
+ query: isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
+ variables: { fullPath, iid },
+ };
const sourceData = cache.readQuery(queryArgs);
if (!sourceData) {
diff --git a/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql
new file mode 100644
index 00000000000..f23bafa20c3
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/group_work_item_by_iid.query.graphql
@@ -0,0 +1,12 @@
+#import "./work_item.fragment.graphql"
+
+query groupWorkItemByIid($fullPath: ID!, $iid: String) {
+ workspace: group(fullPath: $fullPath) @persist {
+ id
+ workItems(iid: $iid) {
+ nodes {
+ ...WorkItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
index 7d63af448d4..2be436aa8c2 100644
--- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -9,6 +9,7 @@ query projectWorkItems(
workItems(search: $searchTerm, types: $types, in: $in) {
nodes {
id
+ iid
title
state
confidential
diff --git a/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql
new file mode 100644
index 00000000000..f83f5474606
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/remove_linked_items.mutation.graphql
@@ -0,0 +1,6 @@
+mutation removeLinkedItems($input: WorkItemRemoveLinkedItemsInput!) {
+ workItemRemoveLinkedItems(input: $input) {
+ errors
+ message
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
index f28317b79b5..9d71d452430 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -1,9 +1,14 @@
-mutation updateWorkItemNotificationsWidget($input: IssueSetSubscriptionInput!) {
- updateWorkItemNotificationsSubscription: issueSetSubscription(input: $input) {
- issue {
+mutation workItemSubscribe($input: WorkItemSubscribeInput!) {
+ workItemSubscribe(input: $input) {
+ errors
+ workItem {
id
- subscribed
+ widgets {
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ }
}
- errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index 1ae5617f04d..fac99310890 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -33,6 +33,7 @@ fragment WorkItem on WorkItem {
adminParentLink
setWorkItemMetadata
createNote
+ adminWorkItemLink
}
widgets {
...WorkItemWidgets
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
index f303a797e9c..d15e3086560 100644
--- 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
@@ -52,4 +52,12 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetAwardEmoji {
type
}
+
+ ... on WorkItemWidgetLinkedItems {
+ type
+ }
+
+ ... on WorkItemWidgetHierarchy {
+ type
+ }
}
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
index b4fb83b24c2..5c797367903 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
@@ -37,6 +37,7 @@ query workItemTreeQuery($id: WorkItemID!) {
state
createdAt
closedAt
+ webUrl
widgets {
... on WorkItemWidgetHierarchy {
type
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 ffc9fe2f7f7..b357e765d16 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
@@ -66,6 +66,7 @@ fragment WorkItemWidgets on WorkItemWidget {
state
createdAt
closedAt
+ webUrl
widgets {
... on WorkItemWidgetHierarchy {
type
@@ -120,6 +121,7 @@ fragment WorkItemWidgets on WorkItemWidget {
state
createdAt
closedAt
+ webUrl
widgets {
...WorkItemMetadataWidgets
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 70bda7d3783..0b7f9290d6e 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,17 +1,25 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { WORKSPACE_GROUP } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import App from './components/app.vue';
+import WorkItemRoot from './pages/work_item_root.vue';
import { createRouter } from './router';
Vue.use(VueApollo);
-export const initWorkItemsRoot = () => {
+export const initWorkItemsRoot = (workspace) => {
const el = document.querySelector('#js-work-items');
+
+ if (!el) {
+ return undefined;
+ }
+
const {
fullPath,
hasIssueWeightsFeature,
+ iid,
issuesListPath,
registerPath,
signInPath,
@@ -22,6 +30,8 @@ export const initWorkItemsRoot = () => {
reportAbusePath,
} = el.dataset;
+ const Component = workspace === WORKSPACE_GROUP ? WorkItemRoot : App;
+
return new Vue({
el,
name: 'WorkItemsRoot',
@@ -29,6 +39,7 @@ export const initWorkItemsRoot = () => {
apolloProvider,
provide: {
fullPath,
+ isGroup: workspace === WORKSPACE_GROUP,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
@@ -40,7 +51,11 @@ export const initWorkItemsRoot = () => {
reportAbusePath,
},
render(createElement) {
- return createElement(App);
+ return createElement(Component, {
+ props: {
+ iid: workspace === WORKSPACE_GROUP ? iid : undefined,
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index b5705b21b5a..31e790254d9 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -10,6 +10,7 @@ import {
} from '../constants';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import ItemTitle from '../components/item_title.vue';
@@ -22,7 +23,7 @@ export default {
ItemTitle,
GlFormSelect,
},
- inject: ['fullPath'],
+ inject: ['fullPath', 'isGroup'],
props: {
initialTitle: {
type: String,
@@ -94,7 +95,7 @@ export default {
const { workItem } = workItemCreate;
store.writeQuery({
- query: workItemByIidQuery,
+ query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery,
variables: {
fullPath: this.fullPath,
iid: workItem.iid,
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index 1443e4b509d..ac5d8b32fad 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,4 +1,3 @@
-import { joinPaths } from '~/lib/utils/url_utility';
import {
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_HEALTH_STATUS,
@@ -43,7 +42,3 @@ export const markdownPreviewPath = (fullPath, iid) =>
`${
gon.relative_url_root || ''
}/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;
-
-export const workItemPath = (fullPath, workItemIid) => {
- return joinPaths(gon?.relative_url_root || '/', fullPath, '-', 'work_items', workItemIid);
-};