diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 18:40:28 +0300 |
commit | b595cb0c1dec83de5bdee18284abe86614bed33b (patch) | |
tree | 8c3d4540f193c5ff98019352f554e921b3a41a72 /app/assets/javascripts/work_items | |
parent | 2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff) |
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/work_items')
27 files changed, 1170 insertions, 203 deletions
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 69670d3471c..2dc8e3a1101 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -54,7 +54,7 @@ export default { :label-for="$options.labelId" label-cols="3" label-cols-lg="2" - label-class="gl-pb-0!" + label-class="gl-pb-0! gl-overflow-wrap-break" class="gl-align-items-center" > <gl-form-select diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index ce2fa158596..1cdc9c28f05 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -1,5 +1,4 @@ <script> -import { escape } from 'lodash'; import { __ } from '~/locale'; export default { @@ -21,15 +20,11 @@ export default { }, }, methods: { - getSanitizedTitle(inputEl) { - const { innerText } = inputEl; - return escape(innerText); - }, handleBlur({ target }) { - this.$emit('title-changed', this.getSanitizedTitle(target)); + this.$emit('title-changed', target.innerText); }, handleInput({ target }) { - this.$emit('title-input', this.getSanitizedTitle(target)); + this.$emit('title-input', target.innerText); }, handleSubmit() { this.$refs.titleEl.blur(); @@ -40,7 +35,7 @@ export default { <template> <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full" + 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 }" aria-labelledby="item-title" > 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 31e4a932c5a..77002eeaf55 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -5,7 +5,7 @@ import Tracking from '~/tracking'; export default { i18n: { - deleteWorkItem: s__('WorkItem|Delete work item'), + deleteTask: s__('WorkItem|Delete task'), }, components: { GlDropdown, @@ -54,7 +54,7 @@ export default { right > <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{ - $options.i18n.deleteWorkItem + $options.i18n.deleteTask }}</gl-dropdown-item> </gl-dropdown> <gl-modal @@ -66,9 +66,7 @@ export default { @hide="handleCancelDeleteWorkItem" > {{ - s__( - 'WorkItem|Are you sure you want to delete the work item? This action cannot be reversed.', - ) + s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.') }} </gl-modal> </div> 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 4d1c171772e..9ff424aa20f 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -1,10 +1,35 @@ <script> -import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; +import { + GlTokenSelector, + GlIcon, + GlAvatar, + GlLink, + GlSkeletonLoader, + GlButton, + GlDropdownItem, + GlDropdownDivider, +} from '@gitlab/ui'; +import { debounce } 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 InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +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 { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -function isClosingIcon(el) { - return el?.classList.contains('gl-token-close'); +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; } export default { @@ -13,7 +38,15 @@ export default { GlIcon, GlAvatar, GlLink, + GlSkeletonLoader, + GlButton, + SidebarParticipant, + InviteMembersTrigger, + GlDropdownItem, + GlDropdownDivider, }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], props: { workItemId: { type: String, @@ -23,67 +56,188 @@ export default { type: Array, required: true, }, + allowsMultipleAssignees: { + type: Boolean, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { isEditing: false, - localAssignees: this.assignees.map((assignee) => ({ - ...assignee, - class: 'gl-bg-transparent!', - })), + searchStarted: false, + localAssignees: this.assignees.map(addClass), + searchKey: '', + searchUsers: [], + currentUser: null, }; }, + apollo: { + searchUsers: { + query() { + return userSearchQuery; + }, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + currentUser: { + query: currentUserQuery, + }, + }, computed: { - assigneeIds() { - return this.localAssignees.map((assignee) => assignee.id); + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_assignees', + property: `type_${this.workItemType}`, + }; }, assigneeListEmpty() { return this.assignees.length === 0; }, containerClass() { - return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : ''; + return !this.isEditing ? 'gl-shadow-none!' : ''; + }, + isLoadingUsers() { + return this.$apollo.queries.searchUsers.loading; + }, + assigneeText() { + return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); + }, + dropdownItems() { + if (this.currentUser && this.searchEmpty) { + if (this.searchUsers.some((user) => user.username === this.currentUser.username)) { + return this.moveCurrentUserToStart(this.searchUsers); + } + return [this.currentUser, ...this.searchUsers]; + } + return this.searchUsers; + }, + searchEmpty() { + return this.searchKey.length === 0; + }, + addAssigneesText() { + return this.allowsMultipleAssignees + ? s__('WorkItem|Add assignees') + : s__('WorkItem|Add assignee'); }, }, + watch: { + assignees(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, methods: { getUserId(id) { return getIdFromGraphQLId(id); }, - setAssignees(e) { - if (isClosingIcon(e.relatedTarget) || !this.isEditing) return; + handleAssigneesInput(assignees) { + if (!this.allowsMultipleAssignees) { + this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; + this.isEditing = false; + return; + } + this.localAssignees = assignees; + this.focusTokenSelector(); + }, + handleBlur(e) { + if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; + this.setAssignees(this.localAssignees); + }, + setAssignees(assignees) { this.$apollo.mutate({ mutation: localUpdateWorkItemMutation, variables: { input: { id: this.workItemId, - assigneeIds: this.assigneeIds, + assignees, }, }, }); + this.track('updated_assignees'); }, - async focusTokenSelector() { + handleFocus() { this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector() { + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + moveCurrentUserToStart(users = []) { + if (this.currentUser) { + return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)]; + } + return users; + }, + closeDropdown() { + this.$refs.tokenSelector.closeDropdown(); + }, }, }; </script> <template> - <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative"> - <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{ - __('Assignee(s)') - }}</span> + <div class="form-row gl-mb-5 work-item-assignees gl-relative"> + <span + class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + data-testid="assignees-title" + >{{ assigneeText }}</span + > <gl-token-selector ref="tokenSelector" - v-model="localAssignees" - hide-dropdown-with-no-items + :selected-tokens="localAssignees" :container-class="containerClass" - class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" - @token-remove="focusTokenSelector" - @focus="isEditing = true" - @blur="setAssignees" + 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" + @input="handleAssigneesInput" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="handleBlur" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div @@ -91,7 +245,15 @@ export default { data-testid="empty-state" > <gl-icon name="profile" /> - <span class="gl-ml-2">{{ __('Add assignees') }}</span> + <span class="gl-ml-2 gl-mr-4">{{ addAssigneesText }}</span> + <gl-button + v-if="currentUser" + size="small" + class="assign-myself" + data-testid="assign-self" + @click.stop="setAssignees([currentUser])" + >{{ __('Assign myself') }}</gl-button + > </div> </template> <template #token-content="{ token }"> @@ -106,6 +268,29 @@ export default { <span class="gl-pl-2">{{ token.name }}</span> </gl-link> </template> + <template #dropdown-item-content="{ dropdownItem }"> + <sidebar-participant :user="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + <template #dropdown-footer> + <gl-dropdown-divider /> + <gl-dropdown-item @click="closeDropdown"> + <invite-members-trigger + :display-text="__('Invite members')" + trigger-element="side-nav" + icon="plus" + trigger-source="work-item-assignees-dropdown" + classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2" + /> + </gl-dropdown-item> + </template> </gl-token-selector> </div> </template> 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 5a85fcdd7ac..90e3cd45cb4 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -35,7 +35,7 @@ export default { isEditing: false, isSubmitting: false, isSubmittingWithKeydown: false, - desc: '', + descriptionText: '', }; }, apollo: { @@ -71,16 +71,17 @@ export default { descriptionHtml() { return this.workItemDescription?.descriptionHtml; }, - descriptionText: { - get() { - return this.desc; - }, - set(desc) { - this.desc = desc; - }, + descriptionEmpty() { + return this.descriptionHtml?.trim() === ''; }, workItemDescription() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + const descriptionWidget = this.workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_DESCRIPTION, + ); + return { + ...descriptionWidget, + description: descriptionWidget?.description || '', + }; }, workItemType() { return this.workItem?.workItemType?.name; @@ -95,14 +96,14 @@ export default { async startEditing() { this.isEditing = true; - this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || ''; + this.descriptionText = getDraft(this.autosaveKey) || this.workItemDescription?.description; await this.$nextTick(); this.$refs.textarea.focus(); }, async cancelEditing() { - const isDirty = this.desc !== this.workItemDescription?.description; + const isDirty = this.descriptionText !== this.workItemDescription?.description; if (isDirty) { const msg = s__('WorkItem|Are you sure you want to cancel editing?'); @@ -125,7 +126,7 @@ export default { return; } - updateDraft(this.autosaveKey, this.desc); + updateDraft(this.autosaveKey, this.descriptionText); }, async updateWorkItem(event) { if (event.key) { @@ -171,25 +172,10 @@ export default { <template> <gl-form-group v-if="isEditing" - class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b" + class="gl-my-5" :label="__('Description')" label-for="work-item-description" - label-class="gl-float-left" > - <div class="gl-display-flex gl-justify-content-flex-end"> - <gl-button class="gl-ml-auto" data-testid="cancel" @click="cancelEditing">{{ - __('Cancel') - }}</gl-button> - <gl-button - class="js-no-auto-disable gl-ml-4" - category="primary" - variant="confirm" - :loading="isSubmitting" - data-testid="save-description" - @click="updateWorkItem" - >{{ __('Save') }}</gl-button - > - </div> <markdown-field can-attach-file :textarea-value="descriptionText" @@ -216,19 +202,35 @@ export default { ></textarea> </template> </markdown-field> - </gl-form-group> - <div v-else class="gl-pt-5 gl-mb-5 gl-border-t gl-border-b"> + <div class="gl-display-flex"> - <h3 class="gl-font-base gl-mt-0">{{ __('Description') }}</h3> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ __('Save') }}</gl-button + > + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{ + __('Cancel') + }}</gl-button> + </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> <gl-button v-if="canEdit" class="gl-ml-auto" icon="pencil" data-testid="edit-description" + :aria-label="__('Edit')" @click="startEditing" - >{{ __('Edit') }}</gl-button - > + /> </div> - <div v-safe-html="descriptionHtml" class="md gl-mb-5"></div> + + <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div> + <div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div> </div> </template> 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 5272df2d53f..ad90fe88947 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,11 +1,15 @@ <script> -import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { i18n, - WIDGET_TYPE_ASSIGNEE, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_WEIGHT, + 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'; @@ -14,22 +18,34 @@ import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; import WorkItemDescription from './work_item_description.vue'; import WorkItemAssignees from './work_item_assignees.vue'; +import WorkItemLabels from './work_item_labels.vue'; import WorkItemWeight from './work_item_weight.vue'; +import WorkItemInformation from './work_item_information.vue'; export default { i18n, components: { GlAlert, + GlButton, GlSkeletonLoader, + GlIcon, WorkItemAssignees, WorkItemActions, WorkItemDescription, + WorkItemLabels, WorkItemTitle, WorkItemState, WorkItemWeight, + WorkItemInformation, + LocalStorageSync, }, mixins: [glFeatureFlagMixin()], props: { + isModal: { + type: Boolean, + required: false, + default: false, + }, workItemId: { type: String, required: false, @@ -45,6 +61,7 @@ export default { return { error: undefined, workItem: {}, + showInfoBanner: true, }; }, apollo: { @@ -91,17 +108,40 @@ export default { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); }, workItemAssignees() { - return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE); + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); + }, + workItemLabels() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, workItemWeight() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, + workItemHierarchy() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); + }, + parentWorkItem() { + return this.workItemHierarchy?.parent; + }, + parentUrl() { + return `../../issues/${this.parentWorkItem?.iid}`; + }, + }, + beforeDestroy() { + /** make sure that if the user has not even dismissed the alert , + * should no be able to see the information next time and update the local storage * */ + this.dismissBanner(); }, + methods: { + dismissBanner() { + this.showInfoBanner = false; + }, + }, + WORK_ITEM_VIEWED_STORAGE_KEY, }; </script> <template> - <section> + <section class="gl-pt-5"> <gl-alert v-if="error" variant="danger" @dismiss="error = undefined"> {{ error }} </gl-alert> @@ -113,39 +153,95 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex gl-align-items-start"> - <work-item-title - :work-item-id="workItem.id" - :work-item-title="workItem.title" - :work-item-type="workItemType" - :work-item-parent-id="workItemParentId" - class="gl-mr-5" - @error="error = $event" - /> + <div class="gl-display-flex gl-align-items-center"> + <ul + v-if="parentWorkItem" + class="list-unstyled gl-display-flex gl-mr-auto" + 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> + <li class="gl-px-4 gl-py-3 gl-line-height-0"> + <gl-icon name="task-done" /> + {{ workItemType }} + </li> + </ul> + <span + v-else + class="gl-font-weight-bold gl-text-secondary gl-mr-auto" + data-testid="work-item-type" + >{{ workItemType }}</span + > <work-item-actions :work-item-id="workItem.id" :can-delete="canDelete" - class="gl-ml-auto gl-mt-6" @deleteWorkItem="$emit('deleteWorkItem')" @error="error = $event" /> + <gl-button + v-if="isModal" + category="tertiary" + data-testid="work-item-close" + icon="close" + :aria-label="__('Close')" + @click="$emit('close')" + /> </div> - <template v-if="workItemsMvc2Enabled"> - <work-item-assignees - v-if="workItemAssignees" - :work-item-id="workItem.id" - :assignees="workItemAssignees.nodes" + <local-storage-sync + v-model="showInfoBanner" + :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" + > + <work-item-information + v-if="showInfoBanner" + :show-info-banner="showInfoBanner" + @work-item-banner-dismissed="dismissBanner" /> - <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" /> - </template> + </local-storage-sync> + <work-item-title + :work-item-id="workItem.id" + :work-item-title="workItem.title" + :work-item-type="workItemType" + :work-item-parent-id="workItemParentId" + @error="error = $event" + /> <work-item-state :work-item="workItem" :work-item-parent-id="workItemParentId" @error="error = $event" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-assignees + v-if="workItemAssignees" + :can-update="canUpdate" + :work-item-id="workItem.id" + :assignees="workItemAssignees.assignees.nodes" + :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" + :work-item-type="workItemType" + @error="error = $event" + /> + <work-item-labels + v-if="workItemLabels" + :work-item-id="workItem.id" + :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-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" + class="gl-pt-5" @error="error = $event" /> </template> 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 d1c8022ac57..df7c6cab7ef 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 @@ -80,13 +80,16 @@ export default { .catch((e) => { this.error = e.message || - s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + s__('WorkItem|Something went wrong when deleting the task. Please try again.'); }); }, closeModal() { this.error = ''; this.$emit('close'); }, + hide() { + this.$refs.modal.hide(); + }, setErrorMessage(message) { this.error = message; }, @@ -104,7 +107,6 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" - body-class="gl-pb-6!" @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -112,9 +114,11 @@ export default { </gl-alert> <work-item-detail + is-modal :work-item-parent-id="issueGid" :work-item-id="workItemId" class="gl-p-5 gl-mt-n3" + @close="hide" @deleteWorkItem="deleteWorkItem" /> </gl-modal> diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue new file mode 100644 index 00000000000..2ff7ba169ea --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_information.vue @@ -0,0 +1,57 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + i18n: { + learnTasksButtonText: s__('WorkItem|Learn about tasks'), + workItemsText: s__('WorkItem|work items'), + tasksInformationTitle: s__('WorkItem|Introducing tasks'), + tasksInformationBody: s__( + 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.', + ), + }, + helpPageLinks: { + tasksDocLinkPath: helpPagePath('user/tasks'), + workItemsLinkPath: helpPagePath(`development/work_items`), + }, + components: { + GlAlert, + GlSprintf, + GlLink, + }, + props: { + showInfoBanner: { + type: Boolean, + required: false, + default: true, + }, + }, + emits: ['work-item-banner-dismissed'], +}; +</script> + +<template> + <section class="gl-display-block gl-mb-2"> + <gl-alert + v-if="showInfoBanner" + variant="tip" + :title="$options.i18n.tasksInformationTitle" + :primary-button-link="$options.helpPageLinks.tasksDocLinkPath" + :primary-button-text="$options.i18n.learnTasksButtonText" + data-testid="work-item-information" + class="gl-mt-3" + @dismiss="$emit('work-item-banner-dismissed')" + > + <gl-sprintf :message="$options.i18n.tasksInformationBody"> + <template #workItemsLink> + <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{ + $options.i18n.workItemsText + }}</gl-link> + </template> + ></gl-sprintf + > + </gl-alert> + </section> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue new file mode 100644 index 00000000000..78ed67998d7 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -0,0 +1,246 @@ +<script> +import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import Tracking from '~/tracking'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import workItemQuery from '../graphql/work_item.query.graphql'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants'; + +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; +} + +export default { + components: { + GlTokenSelector, + GlLabel, + GlSkeletonLoader, + LabelItem, + }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], + props: { + workItemId: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isEditing: false, + searchStarted: false, + localLabels: [], + searchKey: '', + searchLabels: [], + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + searchLabels: { + query: labelSearchQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_labels', + property: `type_${this.workItem.workItemType?.name}`, + }; + }, + allowScopedLabels() { + return this.labelsWidget.allowScopedLabels; + }, + listEmpty() { + return this.labels.length === 0; + }, + containerClass() { + return !this.isEditing ? 'gl-shadow-none!' : ''; + }, + isLoading() { + return this.$apollo.queries.searchLabels.loading; + }, + labelsWidget() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + }, + labels() { + return this.labelsWidget?.nodes || []; + }, + }, + watch: { + labels(newVal) { + if (!this.isEditing) { + this.localLabels = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getId(id) { + return getIdFromGraphQLId(id); + }, + removeLabel({ id }) { + this.localLabels = this.localLabels.filter((label) => label.id !== id); + }, + setLabels(event) { + this.searchKey = ''; + if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; + this.isEditing = false; + this.$apollo + .mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + labels: this.localLabels, + }, + }, + }) + .catch((e) => { + this.$emit('error', e); + }); + this.track('updated_labels'); + }, + handleFocus() { + this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector(labels) { + if (this.allowScopedLabels) { + const newLabel = labels[labels.length - 1]; + const existingLabels = labels.slice(0, labels.length - 1); + + const newLabelKey = scopedLabelKey(newLabel); + + const removeLabelsWithSameScope = existingLabels.filter((label) => { + const sameKey = newLabelKey === scopedLabelKey(label); + return !sameKey; + }); + + this.localLabels = [...removeLabelsWithSameScope, newLabel]; + } + this.handleFocus(); + await this.$nextTick(); + this.$refs.tokenSelector.focusTextInput(); + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + scopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <div class="form-row gl-mb-5 work-item-labels gl-relative"> + <span + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + data-testid="labels-title" + >{{ __('Labels') }}</span + > + <gl-token-selector + ref="tokenSelector" + v-model="localLabels" + :container-class="containerClass" + :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!" + @input="focusTokenSelector" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="setLabels" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #empty-placeholder> + <div + class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" + data-testid="empty-state" + > + <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span> + <span v-else class="gl-ml-2">{{ __('None') }}</span> + </div> + </template> + <template #token-content="{ token }"> + <gl-label + :data-qa-label-name="token.title" + :title="token.title" + :description="token.description" + :background-color="token.color" + :scoped="scopedLabel(token)" + :show-close-button="canUpdate" + @close="removeLabel(token)" + /> + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <label-item :label="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + </gl-token-selector> + </div> +</template> 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 320a4a213e3..176f84f6c1a 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,9 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; import createDefaultClient from '~/lib/graphql'; import WorkItemLinks from './work_item_links.vue'; Vue.use(VueApollo); +Vue.use(GlToast); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -19,6 +21,7 @@ export default function initWorkItemLinks() { if (!workItemLinksRoot) { return; } + // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, @@ -27,6 +30,9 @@ export default function initWorkItemLinks() { components: { workItemLinks: WorkItemLinks, }, + provide: { + projectPath: workItemLinksRoot.dataset.projectPath, + }, render: (createElement) => createElement('work-item-links', { props: { 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 bdfff100333..89f086cfca5 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 @@ -11,6 +11,7 @@ import { } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -19,6 +20,7 @@ export default { GlIcon, GlLoadingIcon, WorkItemLinksForm, + WorkItemLinksMenu, }, props: { workItemId: { @@ -77,6 +79,9 @@ export default { isLoading() { return this.$apollo.queries.children.loading; }, + childrenIds() { + return this.children.map((c) => c.id); + }, }, methods: { badgeVariant(state) { @@ -88,13 +93,16 @@ export default { toggleAddForm() { this.isShownAddForm = !this.isShownAddForm; }, + addChild(child) { + this.children = [child, ...this.children]; + }, }, i18n: { title: s__('WorkItem|Child items'), 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!', ), - addChildButtonLabel: s__('WorkItem|Add a child'), + addChildButtonLabel: s__('WorkItem|Add a task'), }, WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, WORK_ITEM_STATUS_TEXT, @@ -107,8 +115,16 @@ export default { class="gl-p-4 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">{{ $options.i18n.title }}</h5> - <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4"> + <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5> + <gl-button + v-if="!isShownAddForm" + category="secondary" + data-testid="toggle-add-form" + @click="toggleAddForm" + > + {{ $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"> <gl-button category="tertiary" :icon="toggleIcon" @@ -126,37 +142,38 @@ export default { <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" /> <template v-else> - <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty"> - <p> + <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty"> + <p class="gl-my-3"> {{ $options.i18n.emptyStateMessage }} </p> - <gl-button - v-if="!isShownAddForm" - category="secondary" - variant="confirm" - data-testid="toggle-add-form" - @click="toggleAddForm" - > - {{ $options.i18n.addChildButtonLabel }} - </gl-button> - <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" /> </div> + <work-item-links-form + v-if="isShownAddForm" + data-testid="add-links-form" + :issuable-gid="issuableGid" + :children-ids="childrenIds" + @cancel="toggleAddForm" + @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" + 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" 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> - <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0"> + <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> </div> </template> 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 22728f58026..fadba0753db 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,27 +1,127 @@ <script> -import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; export default { components: { + GlAlert, GlForm, - GlFormInput, + GlFormCombobox, GlButton, }, + inject: ['projectPath'], + props: { + issuableGid: { + type: String, + required: false, + default: null, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + }, + apollo: { + availableWorkItems: { + query: projectWorkItemsQuery, + variables() { + return { + projectPath: this.projectPath, + searchTerm: this.search?.title || this.search, + types: ['TASK'], + }; + }, + 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); + }, + }, + }, data() { return { - relatedWorkItem: '', + availableWorkItems: [], + search: '', + error: null, }; }, + methods: { + getIdFromGraphQLId, + unsetError() { + this.error = null; + }, + addChild() { + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.issuableGid, + hierarchyWidget: { + childrenIds: [this.search.id], + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate?.errors?.length) { + [this.error] = data.workItemUpdate.errors; + } else { + this.unsetError(); + this.$emit('addWorkItemChild', this.search); + } + }) + .catch(() => { + this.error = this.$options.i18n.errorMessage; + }) + .finally(() => { + this.search = ''; + }); + }, + }, + i18n: { + inputLabel: __('Children'), + errorMessage: s__( + 'WorkItem|Something went wrong when trying to add a child. Please try again.', + ), + }, }; </script> <template> - <gl-form @submit.prevent> - <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" /> - <gl-button type="submit" category="secondary" variant="confirm"> - {{ s__('WorkItem|Add') }} + <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" + > + <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> + {{ error }} + </gl-alert> + <gl-form-combobox + v-model="search" + :token-list="availableWorkItems" + match-value-to-attr="title" + class="gl-mb-4" + :label-text="$options.i18n.inputLabel" + label-sr-only + autofocus + > + <template #result="{ item }"> + <div class="gl-display-flex"> + <div class="gl-text-gray-400 gl-mr-4">{{ getIdFromGraphQLId(item.id) }}</div> + <div>{{ item.title }}</div> + </div> + </template> + </gl-form-combobox> + <gl-button category="secondary" data-testid="add-child-button" @click="addChild"> + {{ s__('WorkItem|Add task') }} </gl-button> - <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')"> + <gl-button category="tertiary" @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 new file mode 100644 index 00000000000..6deb87c5dca --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -0,0 +1,101 @@ +<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: { + GlDropdownItem, + 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> + +<template> + <span class="gl-ml-2"> + <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true"> + <template #button-content> + <gl-icon name="ellipsis_v" :size="14" /> + </template> + <gl-dropdown-item @click="removeChild"> + {{ s__('WorkItem|Remove') }} + </gl-dropdown-item> + </gl-dropdown> + </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 b0f2b3aa14a..30e2c1e56b8 100644 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ b/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -1,26 +1,142 @@ <script> +import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { TRACKING_CATEGORY_SHOW } from '../constants'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +/* eslint-disable @gitlab/require-i18n-strings */ +const allowedKeys = [ + 'Alt', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'Backspace', + 'Control', + 'Delete', + 'End', + 'Enter', + 'Home', + 'Meta', + 'PageDown', + 'PageUp', + 'Tab', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', +]; +/* eslint-enable @gitlab/require-i18n-strings */ export default { + inputId: 'weight-widget-input', + components: { + GlForm, + GlFormGroup, + GlFormInput, + }, + mixins: [Tracking.mixin()], inject: ['hasIssueWeightsFeature'], props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, weight: { type: Number, required: false, default: undefined, }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + isEditing: false, + }; }, computed: { - weightText() { - return this.weight ?? __('None'); + placeholder() { + return this.canUpdate && this.isEditing ? __('Enter a number') : __('None'); + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_weight', + property: `type_${this.workItemType}`, + }; + }, + type() { + return this.canUpdate && this.isEditing ? 'number' : 'text'; + }, + }, + methods: { + blurInput() { + this.$refs.input.$el.blur(); + }, + handleFocus() { + this.isEditing = true; + }, + handleKeydown(event) { + if (!allowedKeys.includes(event.key)) { + event.preventDefault(); + } + }, + updateWeight(event) { + this.isEditing = false; + this.track('updated_weight'); + this.$apollo.mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + weight: event.target.value === '' ? null : Number(event.target.value), + }, + }, + }); }, }, }; </script> <template> - <div v-if="hasIssueWeightsFeature" class="gl-mb-5"> - <span class="gl-display-inline-block gl-font-weight-bold gl-w-15">{{ __('Weight') }}</span> - {{ weightText }} - </div> + <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput"> + <gl-form-group + class="gl-align-items-center" + :label="__('Weight')" + :label-for="$options.inputId" + label-class="gl-pb-0! gl-overflow-wrap-break" + label-cols="3" + label-cols-lg="2" + > + <gl-form-input + :id="$options.inputId" + ref="input" + min="0" + :placeholder="placeholder" + :readonly="!canUpdate" + size="sm" + :type="type" + :value="weight" + @blur="updateWeight" + @focus="handleFocus" + @keydown="handleKeydown" + @keydown.exact.esc.stop="blurInput" + /> + </gl-form-group> + </gl-form> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2df4978a319..2140b418e6d 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -13,12 +13,14 @@ export const i18n = { updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), }; -export const DEFAULT_MODAL_TYPE = 'Task'; +export const TASK_TYPE_NAME = 'Task'; -export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES'; +export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_LABELS = 'LABELS'; 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'; 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 new file mode 100644 index 00000000000..dc5286174d8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql @@ -0,0 +1,13 @@ +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_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql index b25210f5c74..ccfe62cc585 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,8 +1,12 @@ +#import "./work_item.fragment.graphql" + mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) { workItemCreateFromTask(input: $input) { workItem { - id - descriptionHtml + ...WorkItem + } + newWorkItem { + ...WorkItem } errors } 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 0d31ecef6f8..43c92cf89ec 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,6 +1,6 @@ #import "./work_item.fragment.graphql" -mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) { +mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) { localUpdateWorkItem(input: $input) @client { workItem { ...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 new file mode 100644 index 00000000000..7d38d203b84 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -0,0 +1,14 @@ +query projectWorkItems($searchTerm: String, $projectPath: ID!, $types: [IssueType!]) { + workspace: project(fullPath: $projectPath) { + id + workItems(search: $searchTerm, types: $types) { + edges { + node { + id + title + state + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 09d929faae2..8788ad21e7b 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_ASSIGNEE } from '../constants'; +import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } 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: ['LocalWorkItemAssignees'], + LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'], }, typePolicies: { WorkItem: { @@ -20,33 +20,15 @@ export const temporaryConfig = { return ( widgets || [ { - __typename: 'LocalWorkItemAssignees', - type: 'ASSIGNEES', - nodes: [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Marcus Rutherford', - username: 'ruthfull', - }, - ], + __typename: 'LocalWorkItemLabels', + type: WIDGET_TYPE_LABELS, + allowScopedLabels: true, + nodes: [], }, { __typename: 'LocalWorkItemWeight', type: 'WEIGHT', - weight: 0, + weight: null, }, ] ); @@ -67,12 +49,26 @@ export const resolvers = { }); const data = produce(sourceData, (draftData) => { - const assigneesWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_ASSIGNEE, - ); - assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) => - input.assigneeIds.includes(assignee.id), - ); + 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, + ); + labelsWidget.nodes = [...input.labels]; + } }); cache.writeQuery({ diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index bfe2f0fe0ce..48228b15a53 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,5 +1,6 @@ enum LocalWidgetType { ASSIGNEES + LABELS WEIGHT } @@ -12,6 +13,12 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget { nodes: [UserCore] } +type LocalWorkItemLabels implements LocalWorkItemWidget { + type: LocalWidgetType! + allowScopedLabels: Boolean! + nodes: [Label!] +} + type LocalWorkItemWeight implements LocalWorkItemWidget { type: LocalWidgetType! weight: Int @@ -21,9 +28,11 @@ extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } -type LocalWorkItemAssigneesInput { +input LocalUpdateWorkItemInput { id: WorkItemID! - assigneeIds: [ID!] + assignees: [UserCore!] + labels: [Label] + weight: Int } type LocalWorkItemPayload { @@ -32,5 +41,5 @@ type LocalWorkItemPayload { } extend type Mutation { - localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload + localUpdateWorkItem(input: LocalUpdateWorkItemInput!): 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 c0b6e856411..25eb8099251 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 @@ -5,5 +5,6 @@ mutation workItemUpdate($input: WorkItemUpdateInput!) { workItem { ...WorkItem } + errors } } 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 470de060ee3..ad861a60d15 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,8 +1,13 @@ +#import "./work_item.fragment.graphql" + mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { workItemUpdate: workItemUpdateTask(input: $input) { workItem { id descriptionHtml } + task { + ...WorkItem + } } } 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 04701f6899e..5f64eda96aa 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment WorkItem on WorkItem { id title @@ -17,5 +19,29 @@ fragment WorkItem on WorkItem { description descriptionHtml } + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetHierarchy { + type + parent { + id + iid + title + } + children { + edges { + node { + 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 30bc61f5c59..61cb8802187 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,17 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem mockWidgets @client { - ... on LocalWorkItemAssignees { + ... on LocalWorkItemLabels { type + allowScopedLabels nodes { - id - avatarUrl - name - username - webUrl + ...Label } } ... on LocalWorkItemWeight { diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 33e28831b54..6437df597b4 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => { return new Vue({ el, + name: 'WorkItemsRoot', router: createRouter(el.dataset.fullPath), apolloProvider: createApolloProvider(), provide: { 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 04c6a61689c..482da5419c6 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -6,12 +6,11 @@ import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; -import { DEFAULT_MODAL_TYPE } from '../constants'; import ItemTitle from '../components/item_title.vue'; export default { - createErrorText: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'), fetchTypesErrorText: s__( 'WorkItem|Something went wrong when fetching work item types. Please try again', ), @@ -24,11 +23,6 @@ export default { }, inject: ['fullPath'], props: { - isModal: { - type: Boolean, - required: false, - default: false, - }, initialTitle: { type: String, required: false, @@ -78,13 +72,6 @@ export default { text: node.name, })); }, - result() { - if (!this.selectedWorkItemType && this.isModal) { - this.selectedWorkItemType = this.formOptions.find( - (options) => options.text === DEFAULT_MODAL_TYPE, - )?.value; - } - }, error() { this.error = this.$options.fetchTypesErrorText; }, @@ -104,11 +91,7 @@ export default { methods: { async createWorkItem() { this.loading = true; - if (this.isModal) { - await this.createWorkItemFromTask(); - } else { - await this.createStandaloneWorkItem(); - } + await this.createStandaloneWorkItem(); this.loading = false; }, async createStandaloneWorkItem() { @@ -174,11 +157,7 @@ export default { this.title = title; }, handleCancelClick() { - if (!this.isModal) { - this.$router.go(-1); - return; - } - this.$emit('closeModal'); + this.$router.go(-1); }, }, }; @@ -187,7 +166,7 @@ export default { <template> <form @submit.prevent="createWorkItem"> <gl-alert v-if="error" variant="danger" @dismiss="error = null">{{ error }}</gl-alert> - <div :class="{ 'gl-px-5': isModal }" data-testid="content"> + <div data-testid="content"> <item-title :title="initialTitle" data-testid="title-input" @title-input="handleTitleInput" /> <div> <gl-loading-icon @@ -203,14 +182,11 @@ export default { /> </div> </div> - <div - class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4" - :class="{ 'gl-display-flex gl-justify-content-end': isModal }" - > + <div class="gl-bg-gray-10 gl-py-5 gl-px-6 gl-mt-4"> <gl-button variant="confirm" :disabled="isButtonDisabled" - :class="{ 'gl-mr-3': !isModal }" + class="gl-mr-3" :loading="loading" data-testid="create-button" type="submit" @@ -221,7 +197,6 @@ export default { type="button" data-testid="cancel-button" class="gl-order-n1" - :class="{ 'gl-mr-3': isModal }" @click="handleCancelClick" > {{ __('Cancel') }} |