diff options
Diffstat (limited to 'app/assets/javascripts/work_items')
31 files changed, 738 insertions, 462 deletions
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 551ebbadb21..b2c8b7ae1db 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -39,14 +39,14 @@ export default { :class="{ 'gl-cursor-text': disabled }" aria-labelledby="item-title" > - <div + <span id="item-title" ref="titleEl" role="textbox" :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base" + class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block" :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }" @blur="handleBlur" @keyup="handleInput" @@ -55,8 +55,7 @@ export default { @keydown.meta.u.prevent @keydown.ctrl.b.prevent @keydown.meta.b.prevent + >{{ title }}</span > - {{ title }} - </div> </h2> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 2753c3fa388..9f9d94ec3c2 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,10 +8,14 @@ import { } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_DELETE, + I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, +} from '../constants'; export default { i18n: { - deleteTask: s__('WorkItem|Delete task'), enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), }, @@ -31,6 +35,11 @@ export default { required: false, default: null, }, + workItemType: { + type: String, + required: false, + default: null, + }, canUpdate: { type: Boolean, required: false, @@ -53,6 +62,14 @@ export default { }, }, emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'], + computed: { + i18n() { + return { + deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType), + areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType), + }; + }, + }, methods: { handleToggleWorkItemConfidentiality() { this.track('click_toggle_work_item_confidentiality'); @@ -75,6 +92,7 @@ export default { <div> <gl-dropdown icon="ellipsis_v" + data-testid="work-item-actions-dropdown" text-sr-only :text="__('More actions')" category="tertiary" @@ -97,20 +115,18 @@ export default { v-if="canDelete" v-gl-modal="'work-item-confirm-delete'" data-testid="delete-action" - >{{ $options.i18n.deleteTask }}</gl-dropdown-item + >{{ i18n.deleteWorkItem }}</gl-dropdown-item > </gl-dropdown> <gl-modal modal-id="work-item-confirm-delete" - :title="$options.i18n.deleteWorkItem" - :ok-title="$options.i18n.deleteWorkItem" + :title="i18n.deleteWorkItem" + :ok-title="i18n.deleteWorkItem" ok-variant="danger" @ok="handleDeleteWorkItem" @hide="handleCancelDeleteWorkItem" > - {{ - s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.') - }} + {{ i18n.areYouSureDelete }} </gl-modal> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 7342f215b5e..4585426edaa 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -8,6 +8,7 @@ import { GlButton, GlDropdownItem, GlDropdownDivider, + GlIntersectionObserver, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -19,7 +20,7 @@ import Tracking from '~/tracking'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants'; function isTokenSelectorElement(el) { return ( @@ -50,9 +51,9 @@ export default { InviteMembersTrigger, GlDropdownItem, GlDropdownDivider, + GlIntersectionObserver, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, @@ -80,6 +81,10 @@ export default { required: false, default: false, }, + fullPath: { + type: String, + required: true, + }, }, data() { return { @@ -87,12 +92,15 @@ export default { searchStarted: false, localAssignees: this.assignees.map(addClass), searchKey: '', - searchUsers: [], + users: { + nodes: [], + }, currentUser: null, + isLoadingMore: false, }; }, apollo: { - searchUsers: { + users: { query() { return userSearchQuery; }, @@ -100,13 +108,14 @@ export default { return { fullPath: this.fullPath, search: this.searchKey, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, }; }, skip() { return !this.searchStarted; }, update(data) { - return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + return data.workspace?.users; }, error() { this.$emit('error', i18n.fetchError); @@ -117,6 +126,12 @@ export default { }, }, computed: { + searchUsers() { + return this.users.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + pageInfo() { + return this.users.pageInfo; + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -131,7 +146,7 @@ export default { return !this.isEditing ? 'gl-shadow-none!' : ''; }, isLoadingUsers() { - return this.$apollo.queries.searchUsers.loading; + return this.$apollo.queries.users.loading; }, assigneeText() { return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); @@ -159,6 +174,12 @@ export default { assigneeIds() { return this.localAssignees.map(({ id }) => id); }, + hasNextPage() { + return this.pageInfo?.hasNextPage; + }, + showIntersectionSkeletonLoader() { + return this.isLoadingMore && this.dropdownItems.length; + }, }, watch: { assignees: { @@ -221,6 +242,16 @@ export default { this.isEditing = true; this.searchStarted = true; }, + async fetchMoreAssignees() { + this.isLoadingMore = true; + await this.$apollo.queries.users.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + first: DEFAULT_PAGE_SIZE_ASSIGNEES, + }, + }); + this.isLoadingMore = false; + }, async focusTokenSelector() { this.handleFocus(); await this.$nextTick(); @@ -263,7 +294,7 @@ export default { </script> <template> - <div class="form-row gl-mb-5 work-item-assignees gl-relative"> + <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap"> <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" @@ -275,7 +306,7 @@ export default { :container-class="containerClass" :class="{ 'gl-hover-border-gray-200': canUpdate }" :dropdown-items="dropdownItems" - :loading="isLoadingUsers" + :loading="isLoadingUsers && !isLoadingMore" :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" @@ -326,17 +357,32 @@ export default { <rect width="280" height="20" x="10" y="130" rx="4" /> </gl-skeleton-loader> </template> - <template v-if="canInviteMembers" #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 #dropdown-footer> + <gl-intersection-observer + v-if="hasNextPage && !isLoadingUsers" + @appear="fetchMoreAssignees" + /> + <gl-skeleton-loader + v-if="showIntersectionSkeletonLoader" + :height="100" + data-testid="next-page-loading" + class="gl-text-center gl-py-3" + > + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + </gl-skeleton-loader> + <div v-if="canInviteMembers"> + <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> + </div> </template> </gl-token-selector> </div> 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 cf59789ce2d..c2e4a50fe31 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -8,7 +8,7 @@ import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import workItemQuery from '../graphql/work_item.query.graphql'; -import updateWorkItemWidgetsMutation from '../graphql/update_work_item_widgets.mutation.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; export default { @@ -21,12 +21,15 @@ export default { MarkdownField, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, required: true, }, + fullPath: { + type: String, + required: true, + }, }, markdownDocsPath: helpPagePath('user/markdown'), data() { @@ -139,9 +142,9 @@ export default { this.track('updated_description'); const { - data: { workItemUpdateWidgets }, + data: { workItemUpdate }, } = await this.$apollo.mutate({ - mutation: updateWorkItemWidgetsMutation, + mutation: updateWorkItemMutation, variables: { input: { id: this.workItem.id, @@ -152,8 +155,8 @@ export default { }, }); - if (workItemUpdateWidgets.errors?.length) { - throw new Error(workItemUpdateWidgets.errors[0]); + if (workItemUpdate.errors?.length) { + throw new Error(workItemUpdate.errors[0]); } this.isEditing = false; 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 a5580c14a7a..3d25df9fcb8 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -16,12 +16,14 @@ import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_DESCRIPTION, + WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, WIDGET_TYPE_HIERARCHY, WORK_ITEM_VIEWED_STORAGE_KEY, } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; +import workItemDatesSubscription from '../graphql/work_item_dates.subscription.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'; @@ -30,9 +32,9 @@ import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; import WorkItemDescription from './work_item_description.vue'; +import WorkItemDueDate from './work_item_due_date.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 { @@ -50,10 +52,11 @@ export default { WorkItemAssignees, WorkItemActions, WorkItemDescription, + WorkItemDueDate, WorkItemLabels, WorkItemTitle, WorkItemState, - WorkItemWeight, + WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemInformation, LocalStorageSync, WorkItemTypeIcon, @@ -98,14 +101,36 @@ export default { error() { this.error = this.$options.i18n.fetchError; }, - subscribeToMore: { - document: workItemTitleSubscription, - variables() { - return { - issuableId: this.workItemId, - }; - }, + result() { + if (!this.isModal) { + const path = this.workItem.project?.fullPath + ? ` · ${this.workItem.project.fullPath}` + : ''; + + document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`; + } }, + subscribeToMore: [ + { + document: workItemTitleSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + }, + { + document: workItemDatesSubscription, + variables() { + return { + issuableId: this.workItemId, + }; + }, + skip() { + return !this.workItemDueDate; + }, + }, + ], }, }, computed: { @@ -121,6 +146,9 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + fullPath() { + return this.workItem?.project.fullPath; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, @@ -133,6 +161,11 @@ export default { workItemLabels() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); }, + workItemDueDate() { + return this.workItem?.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE, + ); + }, workItemWeight() { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, @@ -276,11 +309,12 @@ export default { <work-item-actions v-if="canUpdate || canDelete" :work-item-id="workItem.id" + :work-item-type="workItemType" :can-delete="canDelete" :can-update="canUpdate" :is-confidential="workItem.confidential" :is-parent-confidential="parentWorkItemConfidentiality" - @deleteWorkItem="$emit('deleteWorkItem')" + @deleteWorkItem="$emit('deleteWorkItem', workItemType)" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="error = $event" /> @@ -317,21 +351,32 @@ export default { :can-update="canUpdate" @error="error = $event" /> + <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" + :can-invite-members="workItemAssignees.canInviteMembers" + :full-path="fullPath" + @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" - :can-invite-members="workItemAssignees.canInviteMembers" - @error="error = $event" - /> <work-item-labels v-if="workItemLabels" :work-item-id="workItem.id" :can-update="canUpdate" + :full-path="fullPath" + @error="error = $event" + /> + <work-item-due-date + v-if="workItemDueDate" + :can-update="canUpdate" + :due-date="workItemDueDate.dueDate" + :start-date="workItemDueDate.startDate" + :work-item-id="workItem.id" + :work-item-type="workItemType" @error="error = $event" /> </template> @@ -347,6 +392,7 @@ export default { <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" + :full-path="fullPath" class="gl-pt-5" @error="error = $event" /> diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue new file mode 100644 index 00000000000..05f8fa8f5e1 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -0,0 +1,257 @@ +<script> +import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + TRACKING_CATEGORY_SHOW, +} from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; + +const nullObjectDate = new Date(0); + +export default { + i18n: { + addDueDate: s__('WorkItem|Add due date'), + addStartDate: s__('WorkItem|Add start date'), + dates: s__('WorkItem|Dates'), + dueDate: s__('WorkItem|Due date'), + none: s__('WorkItem|None'), + startDate: s__('WorkItem|Start date'), + }, + dueDateInputId: 'due-date-input', + startDateInputId: 'start-date-input', + components: { + GlButton, + GlDatepicker, + GlFormGroup, + }, + mixins: [Tracking.mixin()], + props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + dueDate: { + type: String, + required: false, + default: null, + }, + startDate: { + type: String, + required: false, + default: null, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, + required: true, + }, + }, + data() { + return { + dirtyDueDate: null, + dirtyStartDate: null, + isUpdating: false, + showDueDateInput: false, + showStartDateInput: false, + }; + }, + computed: { + datesUnchanged() { + const dirtyDueDate = this.dirtyDueDate || nullObjectDate; + const dirtyStartDate = this.dirtyStartDate || nullObjectDate; + const dueDate = this.dueDate ? newDateAsLocaleTime(this.dueDate) : nullObjectDate; + const startDate = this.startDate ? newDateAsLocaleTime(this.startDate) : nullObjectDate; + return ( + dirtyDueDate.getTime() === dueDate.getTime() && + dirtyStartDate.getTime() === startDate.getTime() + ); + }, + isDatepickerDisabled() { + return !this.canUpdate || this.isUpdating; + }, + isReadonlyWithOnlyDueDate() { + return !this.canUpdate && this.dueDate && !this.startDate; + }, + isReadonlyWithOnlyStartDate() { + return !this.canUpdate && !this.dueDate && this.startDate; + }, + isReadonlyWithNoDates() { + return !this.canUpdate && !this.dueDate && !this.startDate; + }, + labelClass() { + return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!'; + }, + showDueDateButton() { + return this.canUpdate && !this.showDueDateInput; + }, + showStartDateButton() { + return this.canUpdate && !this.showStartDateInput; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_dates', + property: `type_${this.workItemType}`, + }; + }, + }, + watch: { + dueDate: { + handler(newDueDate) { + this.dirtyDueDate = newDateAsLocaleTime(newDueDate); + this.showDueDateInput = Boolean(newDueDate); + }, + immediate: true, + }, + startDate: { + handler(newStartDate) { + this.dirtyStartDate = newDateAsLocaleTime(newStartDate); + this.showStartDateInput = Boolean(newStartDate); + }, + immediate: true, + }, + }, + methods: { + clearDueDatePicker() { + this.dirtyDueDate = null; + this.showDueDateInput = false; + this.updateDates(); + }, + clearStartDatePicker() { + this.dirtyStartDate = null; + this.showStartDateInput = false; + this.updateDates(); + }, + async clickShowDueDate() { + this.showDueDateInput = true; + await this.$nextTick(); + this.$refs.dueDatePicker.calendar.show(); + }, + async clickShowStartDate() { + this.showStartDateInput = true; + await this.$nextTick(); + this.$refs.startDatePicker.calendar.show(); + }, + handleStartDateInput() { + if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) { + this.dirtyDueDate = this.dirtyStartDate; + this.clickShowDueDate(); + return; + } + + this.updateDates(); + }, + updateDates() { + if (!this.canUpdate || this.datesUnchanged) { + return; + } + + this.track('updated_dates'); + + this.isUpdating = true; + + this.$apollo + .mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + startAndDueDateWidget: { + dueDate: getDateWithUTC(this.dirtyDueDate), + startDate: getDateWithUTC(this.dirtyStartDate), + }, + }, + }, + }) + .then(({ data }) => { + if (data.workItemUpdate.errors.length) { + throw new Error(data.workItemUpdate.errors.join('; ')); + } + }) + .catch((error) => { + const message = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', message); + Sentry.captureException(error); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-due-date" + :label="$options.i18n.dates" + :label-class="labelClass" + label-cols="3" + label-cols-lg="2" + > + <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4"> + {{ $options.i18n.none }} + </span> + <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5"> + <gl-form-group + class="gl-display-flex gl-align-items-center gl-m-0" + :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }" + :label="$options.i18n.startDate" + :label-for="$options.startDateInputId" + :label-sr-only="!showStartDateInput" + label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3" + > + <gl-datepicker + v-if="showStartDateInput" + ref="startDatePicker" + v-model="dirtyStartDate" + container="body" + :disabled="isDatepickerDisabled" + :input-id="$options.startDateInputId" + show-clear-button + :target="null" + @clear="clearStartDatePicker" + @close="handleStartDateInput" + /> + <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate"> + {{ $options.i18n.addStartDate }} + </gl-button> + </gl-form-group> + <gl-form-group + v-if="!isReadonlyWithOnlyStartDate" + class="gl-display-flex gl-align-items-center gl-m-0" + :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }" + :label="$options.i18n.dueDate" + :label-for="$options.dueDateInputId" + :label-sr-only="!showDueDateInput" + label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3" + > + <gl-datepicker + v-if="showDueDateInput" + ref="dueDatePicker" + v-model="dirtyDueDate" + container="body" + :disabled="isDatepickerDisabled" + :input-id="$options.dueDateInputId" + :min-date="dirtyStartDate" + show-clear-button + :target="null" + @clear="clearDueDatePicker" + @close="updateDates" + /> + <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate"> + {{ $options.i18n.addDueDate }} + </gl-button> + </gl-form-group> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue index 2ff7ba169ea..ce75cc98a75 100644 --- a/app/assets/javascripts/work_items/components/work_item_information.vue +++ b/app/assets/javascripts/work_items/components/work_item_information.vue @@ -5,16 +5,14 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export default { i18n: { - learnTasksButtonText: s__('WorkItem|Learn about tasks'), - workItemsText: s__('WorkItem|work items'), + learnTasksLinkText: s__('WorkItem|Learn about tasks.'), 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.', + 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}', ), }, helpPageLinks: { tasksDocLinkPath: helpPagePath('user/tasks'), - workItemsLinkPath: helpPagePath(`development/work_items`), }, components: { GlAlert, @@ -38,16 +36,14 @@ export default { 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 + <template #learnMoreLink> + <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{ + $options.i18n.learnTasksLinkText }}</gl-link> </template> ></gl-sprintf 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 e73488bbd70..b8b5198be57 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -31,7 +31,6 @@ export default { LabelItem, }, mixins: [Tracking.mixin()], - inject: ['fullPath'], props: { workItemId: { type: String, @@ -41,6 +40,10 @@ export default { type: Boolean, required: true, }, + fullPath: { + type: String, + required: true, + }, }, data() { return { @@ -189,7 +192,7 @@ export default { </script> <template> - <div class="form-row gl-mb-5 work-item-labels gl-relative"> + <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap"> <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" @@ -216,7 +219,7 @@ export default { 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-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span> <span v-else class="gl-ml-2">{{ __('None') }}</span> </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 86f03583ea3..8f31b07b6a3 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,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import { createApolloProvider } from '../../graphql/provider'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import WorkItemLinks from './work_item_links.vue'; Vue.use(GlToast); @@ -16,18 +16,19 @@ export default function initWorkItemLinks() { return; } - const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset; + const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new new Vue({ el: workItemLinksRoot, name: 'WorkItemLinksRoot', - apolloProvider: createApolloProvider(), + apolloProvider, components: { - workItemLinks: WorkItemLinks, + WorkItemLinks, }, provide: { projectPath, + iid, fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, }, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue new file mode 100644 index 00000000000..34874908f9b --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -0,0 +1,109 @@ +<script> +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import { STATE_OPEN } from '../../constants'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; + +export default { + components: { + GlButton, + GlIcon, + RichTimestampTooltip, + WorkItemLinksMenu, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectPath: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + issuableGid: { + type: String, + required: true, + }, + childItem: { + type: Object, + required: true, + }, + }, + computed: { + isItemOpen() { + return this.childItem.state === STATE_OPEN; + }, + iconClass() { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + }, + iconName() { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + }, + stateTimestamp() { + return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; + }, + stateTimestampTypeText() { + return this.isItemOpen ? __('Created') : __('Closed'); + }, + childPath() { + return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; + }, + }, +}; +</script> + +<template> + <div + class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> + <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> + <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-button + :href="childPath" + category="tertiary" + variant="link" + class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" + @click="$emit('click', childItem.id, $event)" + @mouseover="$emit('mouseover', childItem.id, $event)" + @mouseout="$emit('mouseout', childItem.id, $event)" + > + {{ childItem.title }} + </gl-button> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('remove', childItem.id)" + /> + </div> + </div> +</template> 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 534ebabee08..840fd910272 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 @@ -5,22 +5,17 @@ import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; 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, - WORK_ITEM_STATUS_TEXT, - WIDGET_TYPE_HIERARCHY, -} from '../../constants'; +import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, 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 WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -28,14 +23,14 @@ export default { GlIcon, GlAlert, GlLoadingIcon, + WorkItemLinkChild, WorkItemLinksForm, - WorkItemLinksMenu, WorkItemDetailModal, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['projectPath'], + inject: ['projectPath', 'iid'], props: { workItemId: { type: String, @@ -63,6 +58,18 @@ export default { this.error = e.message || this.$options.i18n.fetchError; }, }, + parentIssue: { + query: issueConfidentialQuery, + variables() { + return { + fullPath: this.projectPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable; + }, + }, }, data() { return { @@ -72,9 +79,13 @@ export default { activeToast: null, prefetchedWorkItem: null, error: undefined, + parentIssue: null, }; }, computed: { + confidential() { + return this.parentIssue?.confidential || this.workItem?.confidential || false; + }, children() { return ( this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children @@ -84,9 +95,6 @@ export default { 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; @@ -95,9 +103,7 @@ export default { return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; }, toggleLabel() { - return this.isOpen - ? s__('WorkItem|Collapse child items') - : s__('WorkItem|Expand child items'); + return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks'); }, issuableGid() { return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; @@ -112,22 +118,7 @@ export default { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, }, - mounted() { - SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems); - }, - destroyed() { - SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems); - }, methods: { - 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; }, @@ -169,9 +160,6 @@ export default { replace: true, }); }, - childPath(childItemId) { - return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`; - }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ query: getWorkItemLinksQuery, @@ -242,14 +230,12 @@ export default { }, }, i18n: { - title: s__('WorkItem|Child items'), - fetchError: s__( - 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.', - ), + title: s__('WorkItem|Tasks'), + fetchError: s__('WorkItem|Something went wrong when fetching tasks. 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!', + 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', ), - addChildButtonLabel: s__('WorkItem|Add a task'), + addChildButtonLabel: s__('WorkItem|Add'), }, WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, WORK_ITEM_STATUS_TEXT, @@ -257,7 +243,10 @@ export default { </script> <template> - <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10"> + <div + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5" + data-testid="work-item-links" + > <div class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" @@ -319,48 +308,18 @@ export default { @cancel="hideAddForm" @addWorkItemChild="addChild" /> - <div + <work-item-link-child v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <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-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> + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="issuableGid" + :child-item="child" + @click="openChild" + @mouseover="prefetchWorkItem" + @mouseout="clearPrefetching" + @remove="removeChild" + /> <work-item-detail-modal ref="modal" :work-item-id="activeChildId" 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 080d4025cc3..3880ae25c8c 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -2,7 +2,8 @@ import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; import { - i18n, + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, STATE_CLOSED, STATE_EVENT_CLOSE, @@ -93,7 +94,9 @@ export default { throw new Error(errors[0]); } } catch (error) { - this.$emit('error', i18n.updateError); + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + + this.$emit('error', msg); Sentry.captureException(error); } 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 cd5cc3894f6..c52a6854fad 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -1,7 +1,11 @@ <script> import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_UPDATING, + TRACKING_CATEGORY_SHOW, +} from '../constants'; import { getUpdateWorkItemMutation } from './update_work_item'; import ItemTitle from './item_title.vue'; @@ -78,7 +82,8 @@ export default { throw new Error(errors[0]); } } catch (error) { - this.$emit('error', i18n.updateError); + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); Sentry.captureException(error); } diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index fd914fa350b..31e75663055 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -1,11 +1,14 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { WORK_ITEMS_TYPE_MAP } from '../constants'; export default { components: { GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { workItemType: { type: String, @@ -22,6 +25,11 @@ export default { required: false, default: '', }, + showTooltipOnHover: { + type: Boolean, + required: false, + default: false, + }, }, computed: { iconName() { @@ -32,13 +40,21 @@ export default { workItemTypeName() { return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name; }, + workItemTooltipTitle() { + return this.showTooltipOnHover ? this.workItemTypeName : ''; + }, }, }; </script> <template> <span> - <gl-icon :name="iconName" class="gl-mr-2" /> + <gl-icon + v-gl-tooltip.hover="showTooltipOnHover" + :name="iconName" + :title="workItemTooltipTitle" + class="gl-mr-2 gl-text-gray-500" + /> <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 deleted file mode 100644 index b0ad7c97bb1..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_weight.vue +++ /dev/null @@ -1,162 +0,0 @@ -<script> -import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; -import updateWorkItemMutation from '../graphql/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: { - 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) { - 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: 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); - }); - }, - }, -}; -</script> - -<template> - <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 a2aea3cd327..78219e62d01 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,4 +1,5 @@ -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; export const STATE_OPEN = 'OPEN'; export const STATE_CLOSED = 'CLOSED'; @@ -13,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; export const WIDGET_TYPE_LABELS = 'LABELS'; +export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; @@ -31,6 +33,30 @@ export const i18n = { ), }; +export const I18N_WORK_ITEM_ERROR_CREATING = s__( + 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_ERROR_UPDATING = s__( + 'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_ERROR_DELETING = s__( + 'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.', +); +export const I18N_WORK_ITEM_DELETE = s__('WorkItem|Delete %{workItemType}'); +export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__( + 'WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed.', +); +export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted'); + +export const sprintfWorkItem = (msg, workItemTypeArg) => { + const workItemType = workItemTypeArg || s__('WorkItem|Work item'); + return capitalizeFirstCharacter( + sprintf(msg, { + workItemType: workItemType.toLocaleLowerCase(), + }), + ); +}; + export const WIDGET_ICONS = { TASK: 'issue-type-task', }; @@ -62,3 +88,5 @@ export const WORK_ITEMS_TYPE_MAP = { name: s__('WorkItem|Requirements'), }, }; + +export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; 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 4cc23fa0071..1228c876a55 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,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" mutation createWorkItem($input: WorkItemCreateInput!) { workItemCreate(input: $input) { 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 1f98cd4fa2b..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,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./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 790b8e60b6a..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,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./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 deleted file mode 100644 index b70c06fddea..00000000000 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ /dev/null @@ -1,77 +0,0 @@ -import produce from 'immer'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_LABELS } from '../constants'; -import typeDefs from './typedefs.graphql'; -import workItemQuery from './work_item.query.graphql'; - -export const temporaryConfig = { - typeDefs, - cacheConfig: { - possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemLabels'], - }, - typePolicies: { - WorkItem: { - fields: { - mockWidgets: { - read(widgets) { - return ( - widgets || [ - { - __typename: 'LocalWorkItemLabels', - type: WIDGET_TYPE_LABELS, - allowScopedLabels: true, - nodes: [], - }, - ] - ); - }, - }, - widgets: { - merge(_, incoming) { - return incoming; - }, - }, - }, - }, - }, - }, -}; - -export const resolvers = { - Mutation: { - localUpdateWorkItem(_, { input }, { cache }) { - const sourceData = cache.readQuery({ - query: workItemQuery, - variables: { id: input.id }, - }); - - const data = produce(sourceData, (draftData) => { - if (input.labels) { - const labelsWidget = draftData.workItem.mockWidgets.find( - (widget) => widget.type === WIDGET_TYPE_LABELS, - ); - labelsWidget.nodes = [...input.labels]; - } - }); - - cache.writeQuery({ - query: workItemQuery, - variables: { id: input.id }, - data, - }); - }, - }, -}; - -export function createApolloProvider() { - Vue.use(VueApollo); - - const defaultClient = createDefaultClient(resolvers, temporaryConfig); - - return new VueApollo({ - defaultClient, - }); -} 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 0a887fcfc00..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 @@ -1,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./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 fad5a9fa5bc..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,4 +1,4 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./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 deleted file mode 100644 index 6a94c96b347..00000000000 --- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql" - -mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) { - workItemUpdateWidgets(input: $input) { - workItem { - ...WorkItem - } - errors - } -} diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index e8ef27ec778..f4c77ed2ec0 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql" fragment WorkItem on WorkItem { id @@ -6,6 +7,12 @@ fragment WorkItem on WorkItem { state description confidential + createdAt + closedAt + project { + id + fullPath + } workItemType { id name @@ -16,34 +23,6 @@ fragment WorkItem on WorkItem { updateWorkItem } widgets { - ... on WorkItemWidgetDescription { - type - description - descriptionHtml - } - ... on WorkItemWidgetAssignees { - type - allowsMultipleAssignees - canInviteMembers - assignees { - nodes { - ...User - } - } - } - ... on WorkItemWidgetHierarchy { - type - parent { - id - iid - title - confidential - } - children { - nodes { - id - } - } - } + ...WorkItemWidgets } } 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 a9f7b714551..276061af193 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql" +#import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql new file mode 100644 index 00000000000..7e045fdf431 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql @@ -0,0 +1,13 @@ +subscription issuableDatesUpdated($issuableId: IssuableID!) { + issuableDatesUpdated(issuableId: $issuableId) { + ... on WorkItem { + id + widgets { + ... on WorkItemWidgetStartAndDueDate { + dueDate + startDate + } + } + } + } +} 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 df62ca1c143..7b63d9c7ca3 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 @@ -1,4 +1,4 @@ -query workItemQuery($id: WorkItemID!) { +query workItemLinksQuery($id: WorkItemID!) { workItem(id: $id) { id workItemType { @@ -26,6 +26,8 @@ query workItemQuery($id: WorkItemID!) { } title state + createdAt + closedAt } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql new file mode 100644 index 00000000000..3005069f59a --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -0,0 +1,36 @@ +fragment WorkItemWidgets on WorkItemWidget { + ... on WorkItemWidgetDescription { + type + description + descriptionHtml + } + ... on WorkItemWidgetAssignees { + type + allowsMultipleAssignees + canInviteMembers + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetStartAndDueDate { + type + dueDate + startDate + } + ... on WorkItemWidgetHierarchy { + type + parent { + id + iid + title + confidential + } + children { + nodes { + id + } + } + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 6437df597b4..bb4c7052238 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; import App from './components/app.vue'; import { createRouter } from './router'; -import { createApolloProvider } from './graphql/provider'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); @@ -12,7 +12,7 @@ export const initWorkItemsRoot = () => { el, name: 'WorkItemsRoot', router: createRouter(el.dataset.fullPath), - apolloProvider: createApolloProvider(), + apolloProvider, provide: { fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), 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 482da5419c6..3b7257591e2 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -1,7 +1,9 @@ <script> import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { getPreferredLocales, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; 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'; @@ -10,7 +12,6 @@ import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query. import ItemTitle from '../components/item_title.vue'; export default { - 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', ), @@ -69,7 +70,7 @@ export default { update(data) { return data.workspace?.workItemTypes?.nodes.map((node) => ({ value: node.id, - text: node.name, + text: capitalizeFirstCharacter(node.name.toLocaleLowerCase(getPreferredLocales()[0])), })); }, error() { @@ -78,15 +79,19 @@ export default { }, }, computed: { - dropdownButtonText() { - return this.selectedWorkItemType?.name || s__('WorkItem|Type'); - }, formOptions() { return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes]; }, isButtonDisabled() { return this.title.trim().length === 0 || !this.selectedWorkItemType; }, + createErrorText() { + const workItemType = this.workItemTypes.find( + (item) => item.value === this.selectedWorkItemType, + )?.text; + + return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType); + }, }, methods: { async createWorkItem() { @@ -128,7 +133,7 @@ export default { } = response; this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); } catch { - this.error = this.$options.createErrorText; + this.error = this.createErrorText; } }, async createWorkItemFromTask() { @@ -150,7 +155,7 @@ export default { }); this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml); } catch { - this.error = this.$options.createErrorText; + this.error = this.createErrorText; } }, handleTitleInput(title) { diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index e9840889bdb..a2cacd8bd7a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -3,9 +3,13 @@ import { GlAlert } from '@gitlab/ui'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { visitUrl } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import ZenMode from '~/zen_mode'; import WorkItemDetail from '../components/work_item_detail.vue'; +import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_DELETING, + I18N_WORK_ITEM_DELETED, +} from '../constants'; import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; export default { @@ -34,7 +38,7 @@ export default { this.ZenMode = new ZenMode(); }, methods: { - deleteWorkItem() { + deleteWorkItem(workItemType) { this.$apollo .mutate({ mutation: deleteWorkItemMutation, @@ -53,13 +57,12 @@ export default { throw new Error(workItemDelete.errors[0]); } - this.$toast.show(s__('WorkItem|Work item deleted')); + const msg = sprintfWorkItem(I18N_WORK_ITEM_DELETED, workItemType); + this.$toast.show(msg); visitUrl(this.issuesListPath); }) .catch((e) => { - this.error = - e.message || - s__('WorkItem|Something went wrong when deleting the work item. Please try again.'); + this.error = e.message || sprintfWorkItem(I18N_WORK_ITEM_ERROR_DELETING, workItemType); }); }, }, @@ -69,6 +72,6 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem" /> + <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" /> </div> </template> |