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