diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 08:17:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-18 08:17:02 +0000 |
commit | b39512ed755239198a9c294b6a45e65c05900235 (patch) | |
tree | d234a3efade1de67c46b9e5a38ce813627726aa7 /app/assets/javascripts/work_items | |
parent | d31474cf3b17ece37939d20082b07f6657cc79a9 (diff) | |
download | gitlab-ce-b39512ed755239198a9c294b6a45e65c05900235.tar.gz |
Add latest changes from gitlab-org/gitlab@15-3-stable-eev15.3.0-rc42
Diffstat (limited to 'app/assets/javascripts/work_items')
29 files changed, 816 insertions, 294 deletions
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 2dc8e3a1101..2a0913e380a 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -26,7 +26,7 @@ export default { type: String, required: true, }, - loading: { + disabled: { type: Boolean, required: false, default: false, @@ -61,15 +61,17 @@ export default { :id="$options.labelId" :value="state" :options="$options.states" - :disabled="loading" - class="gl-w-auto hide-select-decoration" + :disabled="disabled" + class="gl-w-auto hide-select-decoration gl-pl-3" + :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" @change="setState" /> </gl-form-group> </template> <style> -.hide-select-decoration:not(:focus, :hover) { +.hide-select-decoration:not(:focus, :hover), +.hide-select-decoration:disabled { background-image: none; box-shadow: none; } diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 1cdc9c28f05..551ebbadb21 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -36,7 +36,7 @@ export default { <template> <h2 class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full" - :class="{ 'gl-cursor-not-allowed': disabled }" + :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > <div @@ -46,7 +46,8 @@ export default { :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-pseudo-placeholder gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" + class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base" + :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @blur="handleBlur" @keyup="handleInput" @keydown.enter.exact="handleSubmit" 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 77002eeaf55..2753c3fa388 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -1,15 +1,24 @@ <script> -import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; export default { i18n: { deleteTask: s__('WorkItem|Delete task'), + enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), + disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), }, components: { GlDropdown, GlDropdownItem, + GlDropdownDivider, GlModal, }, directives: { @@ -22,14 +31,33 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, canDelete: { type: Boolean, required: false, default: false, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isParentConfidential: { + type: Boolean, + required: false, + default: false, + }, }, - emits: ['deleteWorkItem'], + emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], methods: { + handleToggleWorkItemConfidentiality() { + this.track('click_toggle_work_item_confidentiality'); + this.$emit('toggleWorkItemConfidentiality', !this.isConfidential); + }, handleDeleteWorkItem() { this.track('click_delete_work_item'); this.$emit('deleteWorkItem'); @@ -44,7 +72,7 @@ export default { </script> <template> - <div v-if="canDelete"> + <div> <gl-dropdown icon="ellipsis_v" text-sr-only @@ -53,9 +81,24 @@ export default { no-caret right > - <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{ - $options.i18n.deleteTask - }}</gl-dropdown-item> + <template v-if="canUpdate && !isParentConfidential"> + <gl-dropdown-item + data-testid="confidentiality-toggle-action" + @click="handleToggleWorkItemConfidentiality" + >{{ + isConfidential + ? $options.i18n.disableTaskConfidentiality + : $options.i18n.enableTaskConfidentiality + }}</gl-dropdown-item + > + <gl-dropdown-divider v-if="canDelete" /> + </template> + <gl-dropdown-item + v-if="canDelete" + v-gl-modal="'work-item-confirm-delete'" + data-testid="delete-action" + >{{ $options.i18n.deleteTask }}</gl-dropdown-item + > </gl-dropdown> <gl-modal modal-id="work-item-confirm-delete" 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 9ff424aa20f..7342f215b5e 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -18,11 +18,17 @@ import { n__, s__ } from '~/locale'; import Tracking from '~/tracking'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; function isTokenSelectorElement(el) { - return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); + return ( + el?.classList.contains('gl-token-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); } function addClass(el) { @@ -69,6 +75,11 @@ export default { required: false, default: false, }, + canInviteMembers: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -130,7 +141,7 @@ export default { if (this.searchUsers.some((user) => user.username === this.currentUser.username)) { return this.moveCurrentUserToStart(this.searchUsers); } - return [this.currentUser, ...this.searchUsers]; + return [addClass(this.currentUser), ...this.searchUsers]; } return this.searchUsers; }, @@ -138,16 +149,25 @@ export default { return this.searchKey.length === 0; }, addAssigneesText() { + if (!this.canUpdate) { + return s__('WorkItem|None'); + } return this.allowsMultipleAssignees ? s__('WorkItem|Add assignees') : s__('WorkItem|Add assignee'); }, + assigneeIds() { + return this.localAssignees.map(({ id }) => id); + }, }, watch: { - assignees(newVal) { - if (!this.isEditing) { - this.localAssignees = newVal.map(addClass); - } + assignees: { + handler(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + deep: true, }, }, created() { @@ -169,19 +189,33 @@ export default { handleBlur(e) { if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; - this.setAssignees(this.localAssignees); + this.setAssignees(this.assigneeIds); }, - setAssignees(assignees) { - this.$apollo.mutate({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - assignees, + async setAssignees(assigneeIds) { + try { + const { + data: { + workItemUpdate: { errors }, }, - }, - }); - this.track('updated_assignees'); + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneesWidget: { + assigneeIds, + }, + }, + }, + }); + if (errors.length > 0) { + this.throwUpdateError(); + return; + } + this.track('updated_assignees'); + } catch { + this.throwUpdateError(); + } }, handleFocus() { this.isEditing = true; @@ -205,13 +239,25 @@ export default { }, moveCurrentUserToStart(users = []) { if (this.currentUser) { - return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)]; + return [ + addClass(this.currentUser), + ...users.filter((user) => user.id !== this.currentUser.id), + ]; } return users; }, closeDropdown() { this.$refs.tokenSelector.closeDropdown(); }, + assignToCurrentUser() { + this.setAssignees([this.currentUser.id]); + this.localAssignees = [addClass(this.currentUser)]; + }, + throwUpdateError() { + this.$emit('error', i18n.updateError); + // If mutation is rejected, we're rolling back to initial state + this.localAssignees = this.assignees.map(addClass); + }, }, }; </script> @@ -227,11 +273,12 @@ export default { ref="tokenSelector" :selected-tokens="localAssignees" :container-class="containerClass" - class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!" :class="{ 'gl-hover-border-gray-200': canUpdate }" :dropdown-items="dropdownItems" :loading="isLoadingUsers" :view-only="!canUpdate" + :allow-clear-all="isEditing" + class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2" @input="handleAssigneesInput" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" @@ -241,7 +288,7 @@ export default { > <template #empty-placeholder> <div - class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-top-2" + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2" data-testid="empty-state" > <gl-icon name="profile" /> @@ -251,7 +298,7 @@ export default { size="small" class="assign-myself" data-testid="assign-self" - @click.stop="setAssignees([currentUser])" + @click.stop="assignToCurrentUser" >{{ __('Assign myself') }}</gl-button > </div> @@ -262,7 +309,7 @@ export default { :title="token.name" :data-user-id="getUserId(token.id)" data-placement="top" - class="gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" + class="gl-ml-n2 gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" > <gl-avatar :size="24" :src="token.avatarUrl" /> <span class="gl-pl-2">{{ token.name }}</span> @@ -279,7 +326,7 @@ export default { <rect width="280" height="20" x="10" y="130" rx="4" /> </gl-skeleton-loader> </template> - <template #dropdown-footer> + <template v-if="canInviteMembers" #dropdown-footer> <gl-dropdown-divider /> <gl-dropdown-item @click="closeDropdown"> <invite-members-trigger 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 90e3cd45cb4..cf59789ce2d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -172,7 +172,7 @@ export default { <template> <gl-form-group v-if="isEditing" - class="gl-my-5" + class="gl-my-5 gl-border-t gl-pt-6" :label="__('Description')" label-for="work-item-description" > @@ -182,7 +182,7 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - class="gl-p-3 bordered-box" + class="gl-p-3 bordered-box gl-mt-5" > <template #textarea> <textarea @@ -217,9 +217,9 @@ export default { }}</gl-button> </div> </gl-form-group> - <div v-else class="gl-mb-5"> - <div class="gl-display-flex gl-align-items-center gl-mb-5"> - <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3> + <div v-else class="gl-mb-5 gl-border-t"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-5"> + <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button v-if="canEdit" class="gl-ml-auto" 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 ad90fe88947..a5580c14a7a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,7 +1,16 @@ <script> -import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui'; +import { + GlAlert, + GlSkeletonLoader, + GlLoadingIcon, + GlIcon, + GlBadge, + GlButton, + GlTooltipDirective, +} from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { i18n, WIDGET_TYPE_ASSIGNEES, @@ -11,8 +20,12 @@ import { WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, } from '../constants'; + import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; + import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -24,9 +37,14 @@ import WorkItemInformation from './work_item_information.vue'; export default { i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, components: { GlAlert, + GlBadge, GlButton, + GlLoadingIcon, GlSkeletonLoader, GlIcon, WorkItemAssignees, @@ -38,6 +56,7 @@ export default { WorkItemWeight, WorkItemInformation, LocalStorageSync, + WorkItemTypeIcon, }, mixins: [glFeatureFlagMixin()], props: { @@ -62,6 +81,7 @@ export default { error: undefined, workItem: {}, showInfoBanner: true, + updateInProgress: false, }; }, apollo: { @@ -114,7 +134,7 @@ export default { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, workItemWeight() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, workItemHierarchy() { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); @@ -122,9 +142,15 @@ export default { parentWorkItem() { return this.workItemHierarchy?.parent; }, + parentWorkItemConfidentiality() { + return this.parentWorkItem?.confidential; + }, parentUrl() { return `../../issues/${this.parentWorkItem?.iid}`; }, + workItemIconName() { + return this.workItem?.workItemType?.iconName; + }, }, beforeDestroy() { /** make sure that if the user has not even dismissed the alert , @@ -135,6 +161,54 @@ export default { dismissBanner() { this.showInfoBanner = false; }, + toggleConfidentiality(confidentialStatus) { + this.updateInProgress = true; + let updateMutation = updateWorkItemMutation; + let inputVariables = { + id: this.workItemId, + confidential: confidentialStatus, + }; + + if (this.parentWorkItem) { + updateMutation = updateWorkItemTaskMutation; + inputVariables = { + id: this.parentWorkItem.id, + taskData: { + id: this.workItemId, + confidential: confidentialStatus, + }, + }; + } + + this.$apollo + .mutate({ + mutation: updateMutation, + variables: { + input: inputVariables, + }, + }) + .then( + ({ + data: { + workItemUpdate: { errors, workItem, task }, + }, + }) => { + if (errors?.length) { + throw new Error(errors[0]); + } + + this.$emit('workItemUpdated', { + confidential: workItem?.confidential || task?.confidential, + }); + }, + ) + .catch((error) => { + this.error = error.message; + }) + .finally(() => { + this.updateInProgress = false; + }); + }, }, WORK_ITEM_VIEWED_STORAGE_KEY, }; @@ -142,7 +216,7 @@ export default { <template> <section class="gl-pt-5"> - <gl-alert v-if="error" variant="danger" @dismiss="error = undefined"> + <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined"> {{ error }} </gl-alert> @@ -153,33 +227,61 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" data-testid="work-item-parent" > - <li class="gl-ml-n4"> - <gl-button icon="issues" category="tertiary" :href="parentUrl">{{ - parentWorkItem.title - }}</gl-button> - <gl-icon name="chevron-right" :size="16" /> + <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> + <gl-button + v-gl-tooltip.hover + class="gl-text-truncate gl-max-w-full" + icon="issues" + category="tertiary" + :href="parentUrl" + :title="parentWorkItem.title" + >{{ parentWorkItem.title }}</gl-button + > + <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" /> </li> - <li class="gl-px-4 gl-py-3 gl-line-height-0"> - <gl-icon name="task-done" /> + <li + class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0" + > + <work-item-type-icon + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + /> {{ workItemType }} </li> </ul> - <span + <work-item-type-icon v-else + :work-item-icon-name="workItemIconName" + :work-item-type="workItemType && workItemType.toUpperCase()" + show-text class="gl-font-weight-bold gl-text-secondary gl-mr-auto" data-testid="work-item-type" - >{{ workItemType }}</span + /> + <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> + <gl-badge + v-if="workItem.confidential" + v-gl-tooltip.bottom + :title="$options.i18n.confidentialTooltip" + variant="warning" + icon="eye-slash" + class="gl-mr-3 gl-cursor-help" + >{{ __('Confidential') }}</gl-badge > <work-item-actions + v-if="canUpdate || canDelete" :work-item-id="workItem.id" :can-delete="canDelete" + :can-update="canUpdate" + :is-confidential="workItem.confidential" + :is-parent-confidential="parentWorkItemConfidentiality" @deleteWorkItem="$emit('deleteWorkItem')" + @toggleWorkItemConfidentiality="toggleConfidentiality" @error="error = $event" /> <gl-button @@ -206,11 +308,13 @@ export default { :work-item-title="workItem.title" :work-item-type="workItemType" :work-item-parent-id="workItemParentId" + :can-update="canUpdate" @error="error = $event" /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" + :can-update="canUpdate" @error="error = $event" /> <template v-if="workItemsMvc2Enabled"> @@ -221,6 +325,7 @@ export default { :assignees="workItemAssignees.assignees.nodes" :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" :work-item-type="workItemType" + :can-invite-members="workItemAssignees.canInviteMembers" @error="error = $event" /> <work-item-labels @@ -229,15 +334,16 @@ export default { :can-update="canUpdate" @error="error = $event" /> - <work-item-weight - v-if="workItemWeight" - class="gl-mb-5" - :can-update="canUpdate" - :weight="workItemWeight.weight" - :work-item-id="workItem.id" - :work-item-type="workItemType" - /> </template> + <work-item-weight + v-if="workItemWeight" + class="gl-mb-5" + :can-update="canUpdate" + :weight="workItemWeight.weight" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="error = $event" + /> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index df7c6cab7ef..39a662a6c54 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -2,9 +2,13 @@ import { GlAlert, GlModal } from '@gitlab/ui'; import { s__ } from '~/locale'; import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql'; +import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; import WorkItemDetail from './work_item_detail.vue'; export default { + i18n: { + errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'), + }, components: { GlAlert, GlModal, @@ -45,6 +49,13 @@ export default { }, methods: { deleteWorkItem() { + if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) { + this.deleteWorkItemWithTaskData(); + } else { + this.deleteWorkItemWithoutTaskData(); + } + }, + deleteWorkItemWithTaskData() { this.$apollo .mutate({ mutation: deleteWorkItemFromTaskMutation, @@ -70,17 +81,33 @@ export default { }, }) => { if (errors?.length) { - throw new Error(errors[0].message); + throw new Error(errors[0]); } this.$emit('workItemDeleted', descriptionHtml); - this.$refs.modal.hide(); + this.hide(); }, ) - .catch((e) => { - this.error = - e.message || - s__('WorkItem|Something went wrong when deleting the task. Please try again.'); + .catch((error) => { + this.setErrorMessage(error.message); + }); + }, + deleteWorkItemWithoutTaskData() { + this.$apollo + .mutate({ + mutation: deleteWorkItemMutation, + variables: { input: { id: this.workItemId } }, + }) + .then(({ data }) => { + if (data.workItemDelete.errors?.length) { + throw new Error(data.workItemDelete.errors[0]); + } + + this.$emit('workItemDeleted', this.workItemId); + this.hide(); + }) + .catch((error) => { + this.setErrorMessage(error.message); }); }, closeModal() { @@ -91,7 +118,7 @@ export default { this.$refs.modal.hide(); }, setErrorMessage(message) { - this.error = message; + this.error = message || this.$options.i18n.errorMessage; }, show() { this.$refs.modal.show(); 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 78ed67998d7..e73488bbd70 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -202,7 +202,8 @@ export default { :dropdown-items="searchLabels" :loading="isLoading" :view-only="!canUpdate" - class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + :class="{ 'gl-hover-border-gray-200': canUpdate }" @input="focusTokenSelector" @text-input="debouncedSearchKeyUpdate" @focus="handleFocus" diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 176f84f6c1a..86f03583ea3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -1,16 +1,10 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; import { GlToast } from '@gitlab/ui'; -import createDefaultClient from '~/lib/graphql'; +import { createApolloProvider } from '../../graphql/provider'; import WorkItemLinks from './work_item_links.vue'; -Vue.use(VueApollo); Vue.use(GlToast); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - export default function initWorkItemLinks() { if (!window.gon.features.workItemsHierarchy) { return; @@ -22,16 +16,20 @@ export default function initWorkItemLinks() { return; } + const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset; + // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, name: 'WorkItemLinksRoot', - apolloProvider, + apolloProvider: createApolloProvider(), components: { workItemLinks: WorkItemLinks, }, provide: { - projectPath: workItemLinksRoot.dataset.projectPath, + projectPath, + fullPath: projectPath, + hasIssueWeightsFeature: wiHasIssueWeightsFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 89f086cfca5..534ebabee08 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 @@ -1,8 +1,14 @@ <script> -import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { produce } from 'immer'; import { s__ } from '~/locale'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { isMetaKey } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import SidebarEventHub from '~/sidebar/event_hub'; + import { STATE_OPEN, WIDGET_ICONS, @@ -10,18 +16,26 @@ import { WIDGET_TYPE_HIERARCHY, } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { GlButton, - GlBadge, GlIcon, + GlAlert, GlLoadingIcon, WorkItemLinksForm, WorkItemLinksMenu, + WorkItemDetailModal, + }, + directives: { + GlTooltip: GlTooltipDirective, }, + inject: ['projectPath'], props: { workItemId: { type: String, @@ -35,32 +49,44 @@ export default { }, }, apollo: { - children: { + workItem: { query: getWorkItemLinksQuery, variables() { return { id: this.issuableGid, }; }, - update(data) { - return ( - data.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children - .nodes ?? [] - ); - }, skip() { return !this.issuableId; }, + error(e) { + this.error = e.message || this.$options.i18n.fetchError; + }, }, }, data() { return { isShownAddForm: false, isOpen: true, - children: [], + activeChildId: null, + activeToast: null, + prefetchedWorkItem: null, + error: undefined, }; }, computed: { + children() { + return ( + this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children + .nodes ?? [] + ); + }, + canUpdate() { + return this.workItem?.userPermissions.updateWorkItem || false; + }, + confidential() { + return this.workItem?.confidential || false; + }, // Only used for children for now but should be extended later to support parents and siblings isChildrenEmpty() { return this.children?.length === 0; @@ -77,28 +103,149 @@ export default { return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; }, isLoading() { - return this.$apollo.queries.children.loading; + return this.$apollo.queries.workItem.loading; }, childrenIds() { return this.children.map((c) => c.id); }, + childrenCountLabel() { + return this.isLoading && this.children.length === 0 ? '...' : this.children.length; + }, + }, + mounted() { + SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems); + }, + destroyed() { + SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems); }, methods: { - badgeVariant(state) { - return state === STATE_OPEN ? 'success' : 'info'; + refetchWorkItems() { + this.$apollo.queries.workItem.refetch(); + }, + iconClass(state) { + return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500'; + }, + iconName(state) { + return state === STATE_OPEN ? 'issue-open-m' : 'issue-close'; }, toggle() { this.isOpen = !this.isOpen; }, - toggleAddForm() { - this.isShownAddForm = !this.isShownAddForm; + showAddForm() { + this.isOpen = true; + this.isShownAddForm = true; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; }, addChild(child) { - this.children = [child, ...this.children]; + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + openChild(childItemId, e) { + if (isMetaKey(e)) { + return; + } + e.preventDefault(); + this.activeChildId = childItemId; + this.$refs.modal.show(); + this.updateWorkItemIdUrlQuery(childItemId); + }, + closeModal() { + this.activeChildId = null; + this.updateWorkItemIdUrlQuery(undefined); + }, + handleWorkItemDeleted(childId) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(null, childId, client); + this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); + }, + updateWorkItemIdUrlQuery(childItemId) { + updateHistory({ + url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), + replace: true, + }); + }, + childPath(childItemId) { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.push(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.issuableGid }, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + }, + prefetchWorkItem(id) { + this.prefetch = setTimeout( + () => + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query: workItemQuery, + variables: { + id, + }, + update: (data) => data.workItem, + }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + }, + clearPrefetching() { + clearTimeout(this.prefetch); }, }, i18n: { title: s__('WorkItem|Child items'), + fetchError: s__( + 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.', + ), emptyStateMessage: s__( 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!', ), @@ -112,21 +259,32 @@ export default { <template> <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10"> <div - class="gl-p-4 gl-display-flex gl-justify-content-space-between" + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" > - <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5> + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5> + <span + class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3" + data-testid="children-count" + > + <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" /> + {{ childrenCountLabel }} + </span> + </div> <gl-button - v-if="!isShownAddForm" + v-if="canUpdate" category="secondary" + size="small" data-testid="toggle-add-form" - @click="toggleAddForm" + @click="showAddForm" > {{ $options.i18n.addChildButtonLabel }} </gl-button> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3"> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> <gl-button category="tertiary" + size="small" :icon="toggleIcon" :aria-label="toggleLabel" data-testid="toggle-links" @@ -134,48 +292,81 @@ export default { /> </div> </div> + <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined"> + {{ error }} + </gl-alert> <div v-if="isOpen" - class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" data-testid="links-body" > <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> - <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty"> - <p class="gl-my-3"> + <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty"> + <p class="gl-mt-3 gl-mb-4"> {{ $options.i18n.emptyStateMessage }} </p> </div> <work-item-links-form v-if="isShownAddForm" + ref="wiLinksForm" data-testid="add-links-form" :issuable-gid="issuableGid" :children-ids="childrenIds" - @cancel="toggleAddForm" + :parent-confidential="confidential" + @cancel="hideAddForm" @addWorkItemChild="addChild" /> <div v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" data-testid="links-child" > - <div> - <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" /> - <span class="gl-word-break-all">{{ child.title }}</span> + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> + <gl-icon + :name="iconName(child.state)" + class="gl-mr-3" + :class="iconClass(child.state)" + /> + <gl-icon + v-if="child.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :title="__('Confidential')" + /> + <gl-button + :href="childPath(child.id)" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="openChild(child.id, $event)" + @mouseover="prefetchWorkItem(child.id)" + @mouseout="clearPrefetching" + > + {{ child.title }} + </gl-button> </div> - <div - class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center" - > - <gl-badge :variant="badgeVariant(child.state)"> - <span class="gl-sm-display-block">{{ - $options.WORK_ITEM_STATUS_TEXT[child.state] - }}</span> - </gl-badge> - <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" /> + <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"> + <work-item-links-menu + v-if="canUpdate" + :work-item-id="child.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="removeChild(child.id)" + /> </div> </div> + <work-item-detail-modal + ref="modal" + :work-item-id="activeChildId" + @close="closeModal" + @workItemDeleted="handleWorkItemDeleted(activeChildId)" + /> </template> </div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index fadba0753db..8b848995d44 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 @@ -1,9 +1,11 @@ <script> -import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui'; +import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __, s__ } from '~/locale'; -import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; +import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; +import { TASK_TYPE_NAME } from '../../constants'; export default { components: { @@ -11,6 +13,8 @@ export default { GlForm, GlFormCombobox, GlButton, + GlFormGroup, + GlFormInput, }, inject: ['projectPath'], props: { @@ -24,24 +28,22 @@ export default { required: false, default: () => [], }, + parentConfidential: { + type: Boolean, + required: false, + default: false, + }, }, apollo: { - availableWorkItems: { - query: projectWorkItemsQuery, + workItemTypes: { + query: projectWorkItemTypesQuery, variables() { return { - projectPath: this.projectPath, - searchTerm: this.search?.title || this.search, - types: ['TASK'], + fullPath: this.projectPath, }; }, - skip() { - return this.search.length === 0; - }, update(data) { - return data.workspace.workItems.edges - .filter((wi) => !this.childrenIds.includes(wi.node.id)) - .map((wi) => wi.node); + return data.workspace?.workItemTypes?.nodes; }, }, }, @@ -50,8 +52,32 @@ export default { availableWorkItems: [], search: '', error: null, + childToCreateTitle: null, }; }, + computed: { + actionsList() { + return [ + { + label: this.$options.i18n.createChildOptionLabel, + fn: () => { + this.childToCreateTitle = this.search?.title || this.search; + }, + }, + ]; + }, + addOrCreateButtonLabel() { + return this.childToCreateTitle + ? this.$options.i18n.createChildOptionLabel + : this.$options.i18n.addTaskButtonLabel; + }, + addOrCreateMethod() { + return this.childToCreateTitle ? this.createChild : this.addChild; + }, + taskWorkItemType() { + return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + }, + }, methods: { getIdFromGraphQLId, unsetError() { @@ -79,35 +105,78 @@ export default { } }) .catch(() => { - this.error = this.$options.i18n.errorMessage; + this.error = this.$options.i18n.addChildErrorMessage; }) .finally(() => { this.search = ''; }); }, + createChild() { + this.$apollo + .mutate({ + mutation: createWorkItemMutation, + variables: { + input: { + title: this.search?.title || this.search, + projectPath: this.projectPath, + workItemTypeId: this.taskWorkItemType, + hierarchyWidget: { + parentId: this.issuableGid, + }, + confidential: this.parentConfidential, + }, + }, + }) + .then(({ data }) => { + if (data.workItemCreate?.errors?.length) { + [this.error] = data.workItemCreate.errors; + } else { + this.unsetError(); + this.$emit('addWorkItemChild', data.workItemCreate.workItem); + } + }) + .catch(() => { + this.error = this.$options.i18n.createChildErrorMessage; + }) + .finally(() => { + this.search = ''; + this.childToCreateTitle = null; + }); + }, }, i18n: { - inputLabel: __('Children'), - errorMessage: s__( + inputLabel: __('Title'), + addTaskButtonLabel: s__('WorkItem|Add task'), + addChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to add a child. Please try again.', ), + createChildOptionLabel: s__('WorkItem|Create task'), + createChildErrorMessage: s__( + 'WorkItem|Something went wrong when trying to create a child. Please try again.', + ), + placeholder: s__('WorkItem|Add a title'), + fieldValidationMessage: __('Maximum of 255 characters'), }, }; </script> <template> <gl-form - class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base" + class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base" + @submit.prevent="createChild" > <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> {{ error }} </gl-alert> + <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 --> <gl-form-combobox + v-if="false" v-model="search" :token-list="availableWorkItems" match-value-to-attr="title" class="gl-mb-4" :label-text="$options.i18n.inputLabel" + :action-list="actionsList" label-sr-only autofocus > @@ -117,11 +186,35 @@ export default { <div>{{ item.title }}</div> </div> </template> + <template #action="{ item }"> + <span class="gl-text-blue-500">{{ item.label }}</span> + </template> </gl-form-combobox> - <gl-button category="secondary" data-testid="add-child-button" @click="addChild"> - {{ s__('WorkItem|Add task') }} + <gl-form-group + :label="$options.i18n.inputLabel" + :description="$options.i18n.fieldValidationMessage" + > + <gl-form-input + ref="wiTitleInput" + v-model="search" + :placeholder="$options.i18n.placeholder" + maxlength="255" + class="gl-mb-3" + autofocus + /> + </gl-form-group> + <gl-button + category="primary" + variant="confirm" + size="small" + type="submit" + :disabled="search.length === 0" + data-testid="add-child-button" + class="gl-mr-2" + > + {{ $options.i18n.createChildOptionLabel }} </gl-button> - <gl-button category="tertiary" @click="$emit('cancel')"> + <gl-button category="secondary" size="small" @click="$emit('cancel')"> {{ s__('WorkItem|Cancel') }} </gl-button> </gl-form> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue index 6deb87c5dca..1aa4a433a58 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -1,10 +1,5 @@ <script> import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { produce } from 'immer'; -import { s__ } from '~/locale'; -import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql'; -import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; -import { WIDGET_TYPE_HIERARCHY } from '../../constants'; export default { components: { @@ -12,78 +7,6 @@ export default { GlDropdown, GlIcon, }, - props: { - workItemId: { - type: String, - required: true, - }, - parentWorkItemId: { - type: String, - required: true, - }, - }, - data() { - return { - activeToast: null, - }; - }, - methods: { - toggleChildFromCache(data, store) { - const sourceData = store.readQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - }); - - const newData = produce(sourceData, (draftState) => { - const widgetHierarchy = draftState.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_HIERARCHY, - ); - - const index = widgetHierarchy.children.nodes.findIndex( - (child) => child.id === this.workItemId, - ); - - if (index >= 0) { - widgetHierarchy.children.nodes.splice(index, 1); - } else { - widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem); - } - }); - - store.writeQuery({ - query: getWorkItemLinksQuery, - variables: { id: this.parentWorkItemId }, - data: newData, - }); - }, - async addChild(data) { - const { data: resp } = await this.$apollo.mutate({ - mutation: changeWorkItemParentMutation, - variables: { id: this.workItemId, parentId: this.parentWorkItemId }, - update: this.toggleChildFromCache.bind(this, data), - }); - - if (resp.workItemUpdate.errors.length === 0) { - this.activeToast?.hide(); - } - }, - async removeChild() { - const { data } = await this.$apollo.mutate({ - mutation: changeWorkItemParentMutation, - variables: { id: this.workItemId, parentId: null }, - update: this.toggleChildFromCache.bind(this, null), - }); - - if (data.workItemUpdate.errors.length === 0) { - this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { - action: { - text: s__('WorkItem|Undo'), - onClick: this.addChild.bind(this, data), - }, - }); - } - }, - }, }; </script> @@ -93,7 +16,7 @@ export default { <template #button-content> <gl-icon name="ellipsis_v" :size="14" /> </template> - <gl-dropdown-item @click="removeChild"> + <gl-dropdown-item @click="$emit('removeChild')"> {{ s__('WorkItem|Remove') }} </gl-dropdown-item> </gl-dropdown> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue index 87f4a8822b1..080d4025cc3 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -27,6 +27,11 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -102,7 +107,7 @@ export default { <item-state v-if="workItem.state" :state="workItem.state" - :loading="updateInProgress" + :disabled="updateInProgress || !canUpdate" @changed="updateWorkItemState" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index b4c13037038..cd5cc3894f6 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -31,6 +31,11 @@ export default { required: false, default: null, }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, computed: { tracking() { @@ -84,5 +89,5 @@ export default { </script> <template> - <item-title :title="workItemTitle" @title-changed="updateTitle" /> + <item-title :title="workItemTitle" :disabled="!canUpdate" @title-changed="updateTitle" /> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue new file mode 100644 index 00000000000..fd914fa350b --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -0,0 +1,44 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { WORK_ITEMS_TYPE_MAP } from '../constants'; + +export default { + components: { + GlIcon, + }, + props: { + workItemType: { + type: String, + required: false, + default: '', + }, + showText: { + type: Boolean, + required: false, + default: false, + }, + workItemIconName: { + type: String, + required: false, + default: '', + }, + }, + computed: { + iconName() { + return ( + this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' + ); + }, + workItemTypeName() { + return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; + }, + }, +}; +</script> + +<template> + <span> + <gl-icon :name="iconName" class="gl-mr-2" /> + <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span> + </span> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue index 30e2c1e56b8..b0ad7c97bb1 100644 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ b/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -1,9 +1,10 @@ <script> import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { __ } from '~/locale'; import Tracking from '~/tracking'; -import { TRACKING_CATEGORY_SHOW } from '../constants'; -import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; /* eslint-disable @gitlab/require-i18n-strings */ const allowedKeys = [ @@ -97,17 +98,36 @@ export default { } }, updateWeight(event) { + if (!this.canUpdate) return; this.isEditing = false; + + const weight = Number(event.target.value); + if (this.weight === weight) { + return; + } + this.track('updated_weight'); - this.$apollo.mutate({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - weight: event.target.value === '' ? null : Number(event.target.value), + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + weightWidget: { + weight: event.target.value === '' ? null : weight, + }, + }, }, - }, - }); + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('\n')); + } + }) + .catch((error) => { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + }); }, }, }; diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2140b418e6d..a2aea3cd327 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -8,11 +8,6 @@ export const STATE_EVENT_CLOSE = 'CLOSE'; export const TRACKING_CATEGORY_SHOW = 'workItems:show'; -export const i18n = { - fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), - updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), -}; - export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; @@ -22,13 +17,48 @@ export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; -export const WIDGET_TYPE_TASK_ICON = 'task-done'; +export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; +export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; +export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; +export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; +export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; + +export const i18n = { + fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), + updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), + confidentialTooltip: s__( + 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', + ), +}; export const WIDGET_ICONS = { - TASK: 'task-done', + TASK: 'issue-type-task', }; export const WORK_ITEM_STATUS_TEXT = { CLOSED: s__('WorkItem|Closed'), OPEN: s__('WorkItem|Open'), }; + +export const WORK_ITEMS_TYPE_MAP = { + [WORK_ITEM_TYPE_ENUM_INCIDENT]: { + icon: `issue-type-incident`, + name: s__('WorkItem|Incident'), + }, + [WORK_ITEM_TYPE_ENUM_ISSUE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Issue'), + }, + [WORK_ITEM_TYPE_ENUM_TASK]: { + icon: `issue-type-task`, + name: s__('WorkItem|Task'), + }, + [WORK_ITEM_TYPE_ENUM_TEST_CASE]: { + icon: `issue-type-test-case`, + name: s__('WorkItem|Test case'), + }, + [WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: { + icon: `issue-type-requirements`, + name: s__('WorkItem|Requirements'), + }, +}; diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql deleted file mode 100644 index dc5286174d8..00000000000 --- a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql +++ /dev/null @@ -1,13 +0,0 @@ -mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) { - workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) { - workItem { - id - workItemType { - id - } - title - state - } - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql index 7f9aaf43068..4cc23fa0071 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -1,9 +1,10 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation createWorkItem($input: WorkItemCreateInput!) { workItemCreate(input: $input) { workItem { ...WorkItem } + errors } } diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql index ccfe62cc585..1f98cd4fa2b 100644 --- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { workItemCreateFromTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql index 43c92cf89ec..790b8e60b6a 100644 --- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) { localUpdateWorkItem(input: $input) @client { diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 8788ad21e7b..b70c06fddea 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants'; +import { WIDGET_TYPE_LABELS } from '../constants'; import typeDefs from './typedefs.graphql'; import workItemQuery from './work_item.query.graphql'; @@ -10,7 +10,7 @@ export const temporaryConfig = { typeDefs, cacheConfig: { possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'], + LocalWorkItemWidget: ['LocalWorkItemLabels'], }, typePolicies: { WorkItem: { @@ -25,15 +25,15 @@ export const temporaryConfig = { allowScopedLabels: true, nodes: [], }, - { - __typename: 'LocalWorkItemWeight', - type: 'WEIGHT', - weight: null, - }, ] ); }, }, + widgets: { + merge(_, incoming) { + return incoming; + }, + }, }, }, }, @@ -49,20 +49,6 @@ export const resolvers = { }); const data = produce(sourceData, (draftData) => { - if (input.assignees) { - const assigneesWidget = draftData.workItem.widgets.find( - (widget) => widget.type === WIDGET_TYPE_ASSIGNEES, - ); - assigneesWidget.assignees.nodes = [...input.assignees]; - } - - if (input.weight != null) { - const weightWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_WEIGHT, - ); - weightWidget.weight = input.weight; - } - if (input.labels) { const labelsWidget = draftData.workItem.mockWidgets.find( (widget) => widget.type === WIDGET_TYPE_LABELS, diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 48228b15a53..36ffba8a540 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,7 +1,6 @@ enum LocalWidgetType { ASSIGNEES LABELS - WEIGHT } interface LocalWorkItemWidget { @@ -19,20 +18,29 @@ type LocalWorkItemLabels implements LocalWorkItemWidget { nodes: [Label!] } -type LocalWorkItemWeight implements LocalWorkItemWidget { - type: LocalWidgetType! - weight: Int -} - extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } +input LocalUserInput { + id: ID! + name: String + username: String + webUrl: String + avatarUrl: String +} + +input LocalLabelInput { + id: ID! + title: String! + color: String + description: String +} + input LocalUpdateWorkItemInput { id: WorkItemID! - assignees: [UserCore!] - labels: [Label] - weight: Int + assignees: [LocalUserInput!] + labels: [LocalLabelInput] } type LocalWorkItemPayload { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql index 25eb8099251..0a887fcfc00 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdate($input: WorkItemUpdateInput!) { workItemUpdate(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql index ad861a60d15..fad5a9fa5bc 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql index 148b340b439..6a94c96b347 100644 --- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql +++ b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql @@ -1,4 +1,4 @@ -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) { workItemUpdateWidgets(input: $input) { 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 5f64eda96aa..e8ef27ec778 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -5,9 +5,11 @@ fragment WorkItem on WorkItem { title state description + confidential workItemType { id name + iconName } userPermissions { deleteWorkItem @@ -22,6 +24,7 @@ fragment WorkItem on WorkItem { ... on WorkItemWidgetAssignees { type allowsMultipleAssignees + canInviteMembers assignees { nodes { ...User @@ -34,12 +37,11 @@ fragment WorkItem on WorkItem { id iid title + confidential } children { - edges { - node { - id - } + nodes { + id } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 61cb8802187..a9f7b714551 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,5 +1,5 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" -#import "./work_item.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { @@ -12,10 +12,6 @@ query workItem($id: WorkItemID!) { ...Label } } - ... on LocalWorkItemWeight { - type - weight - } } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index c2496f53cc8..df62ca1c143 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -5,6 +5,11 @@ query workItemQuery($id: WorkItemID!) { id } title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential widgets { type ... on WorkItemWidgetHierarchy { @@ -15,6 +20,7 @@ query workItemQuery($id: WorkItemID!) { children { nodes { id + confidential workItemType { id } |