diff options
Diffstat (limited to 'app/assets/javascripts/work_items')
24 files changed, 992 insertions, 49 deletions
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue index 0b6c1a75bb2..69670d3471c 100644 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -49,14 +49,28 @@ export default { </script> <template> - <gl-form-group :label="$options.i18n.status" :label-for="$options.labelId"> + <gl-form-group + :label="$options.i18n.status" + :label-for="$options.labelId" + label-cols="3" + label-cols-lg="2" + label-class="gl-pb-0!" + class="gl-align-items-center" + > <gl-form-select :id="$options.labelId" :value="state" :options="$options.states" :disabled="loading" - class="gl-w-auto" + class="gl-w-auto hide-select-decoration" @change="setState" /> </gl-form-group> </template> + +<style> +.hide-select-decoration:not(:focus, :hover) { + background-image: none; + box-shadow: none; +} +</style> diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 232510b108d..ce2fa158596 100644 --- a/app/assets/javascripts/work_items/components/item_title.vue +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -40,18 +40,18 @@ export default { <template> <h2 - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block" + class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full" :class="{ 'gl-cursor-not-allowed': disabled }" aria-labelledby="item-title" > - <span + <div id="item-title" ref="titleEl" role="textbox" :aria-label="__('Title')" :data-placeholder="placeholder" :contenteditable="!disabled" - class="gl-pseudo-placeholder" + class="gl-pseudo-placeholder gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" @blur="handleBlur" @keyup="handleInput" @keydown.enter.exact="handleSubmit" @@ -59,7 +59,8 @@ 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/update_work_item.js b/app/assets/javascripts/work_items/components/update_work_item.js new file mode 100644 index 00000000000..fc395fa5be3 --- /dev/null +++ b/app/assets/javascripts/work_items/components/update_work_item.js @@ -0,0 +1,23 @@ +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; + +export function getUpdateWorkItemMutation({ input, workItemParentId }) { + let mutation = updateWorkItemMutation; + + const variables = { + input, + }; + + if (workItemParentId) { + mutation = updateWorkItemTaskMutation; + variables.input = { + id: workItemParentId, + taskData: input, + }; + } + + return { + mutation, + variables, + }; +} diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue new file mode 100644 index 00000000000..4d1c171772e --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -0,0 +1,111 @@ +<script> +import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +function isClosingIcon(el) { + return el?.classList.contains('gl-token-close'); +} + +export default { + components: { + GlTokenSelector, + GlIcon, + GlAvatar, + GlLink, + }, + props: { + workItemId: { + type: String, + required: true, + }, + assignees: { + type: Array, + required: true, + }, + }, + data() { + return { + isEditing: false, + localAssignees: this.assignees.map((assignee) => ({ + ...assignee, + class: 'gl-bg-transparent!', + })), + }; + }, + computed: { + assigneeIds() { + return this.localAssignees.map((assignee) => assignee.id); + }, + assigneeListEmpty() { + return this.assignees.length === 0; + }, + containerClass() { + return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : ''; + }, + }, + methods: { + getUserId(id) { + return getIdFromGraphQLId(id); + }, + setAssignees(e) { + if (isClosingIcon(e.relatedTarget) || !this.isEditing) return; + this.isEditing = false; + this.$apollo.mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + assigneeIds: this.assigneeIds, + }, + }, + }); + }, + async focusTokenSelector() { + this.isEditing = true; + await this.$nextTick(); + this.$refs.tokenSelector.focusTextInput(); + }, + }, +}; +</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> + <gl-token-selector + ref="tokenSelector" + v-model="localAssignees" + hide-dropdown-with-no-items + :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" + > + <template #empty-placeholder> + <div + class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-top-2" + data-testid="empty-state" + > + <gl-icon name="profile" /> + <span class="gl-ml-2">{{ __('Add assignees') }}</span> + </div> + </template> + <template #token-content="{ token }"> + <gl-link + :href="token.webUrl" + :title="token.name" + :data-user-id="getUserId(token.id)" + data-placement="top" + class="gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link" + > + <gl-avatar :size="24" :src="token.avatarUrl" /> + <span class="gl-pl-2">{{ token.name }}</span> + </gl-link> + </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 new file mode 100644 index 00000000000..5a85fcdd7ac --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -0,0 +1,234 @@ +<script> +import { GlButton, GlFormGroup, GlSafeHtmlDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +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 { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; + +export default { + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + components: { + GlButton, + GlFormGroup, + MarkdownField, + }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], + props: { + workItemId: { + type: String, + required: true, + }, + }, + markdownDocsPath: helpPagePath('user/markdown'), + data() { + return { + workItem: {}, + isEditing: false, + isSubmitting: false, + isSubmittingWithKeydown: false, + desc: '', + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId; + }, + error() { + this.error = i18n.fetchError; + }, + }, + }, + computed: { + autosaveKey() { + return this.workItemId; + }, + canEdit() { + return this.workItem?.userPermissions?.updateWorkItem; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_description', + property: `type_${this.workItemType}`, + }; + }, + descriptionHtml() { + return this.workItemDescription?.descriptionHtml; + }, + descriptionText: { + get() { + return this.desc; + }, + set(desc) { + this.desc = desc; + }, + }, + workItemDescription() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + }, + workItemType() { + return this.workItem?.workItemType?.name; + }, + markdownPreviewPath() { + return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${ + this.workItemType + }`; + }, + }, + methods: { + async startEditing() { + this.isEditing = true; + + this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || ''; + + await this.$nextTick(); + + this.$refs.textarea.focus(); + }, + async cancelEditing() { + const isDirty = this.desc !== this.workItemDescription?.description; + + if (isDirty) { + const msg = s__('WorkItem|Are you sure you want to cancel editing?'); + + const confirmed = await confirmAction(msg, { + primaryBtnText: __('Discard changes'), + cancelBtnText: __('Continue editing'), + }); + + if (!confirmed) { + return; + } + } + + this.isEditing = false; + clearDraft(this.autosaveKey); + }, + onInput() { + if (this.isSubmittingWithKeydown) { + return; + } + + updateDraft(this.autosaveKey, this.desc); + }, + async updateWorkItem(event) { + if (event.key) { + this.isSubmittingWithKeydown = true; + } + + this.isSubmitting = true; + + try { + this.track('updated_description'); + + const { + data: { workItemUpdateWidgets }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemWidgetsMutation, + variables: { + input: { + id: this.workItem.id, + descriptionWidget: { + description: this.descriptionText, + }, + }, + }, + }); + + if (workItemUpdateWidgets.errors?.length) { + throw new Error(workItemUpdateWidgets.errors[0]); + } + + this.isEditing = false; + clearDraft(this.autosaveKey); + } catch (error) { + this.$emit('error', error.message); + Sentry.captureException(error); + } + + this.isSubmitting = false; + }, + }, +}; +</script> + +<template> + <gl-form-group + v-if="isEditing" + class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b" + :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" + :is-submitting="isSubmitting" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="$options.markdownDocsPath" + class="gl-p-3 bordered-box" + > + <template #textarea> + <textarea + id="work-item-description" + ref="textarea" + v-model="descriptionText" + :disabled="isSubmitting" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files hereā¦')" + @keydown.meta.enter="updateWorkItem" + @keydown.ctrl.enter="updateWorkItem" + @keydown.exact.esc.stop="cancelEditing" + @input="onInput" + ></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 + v-if="canEdit" + class="gl-ml-auto" + icon="pencil" + data-testid="edit-description" + @click="startEditing" + >{{ __('Edit') }}</gl-button + > + </div> + <div v-safe-html="descriptionHtml" class="md gl-mb-5"></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 4222ffe42fe..5272df2d53f 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,27 +1,45 @@ <script> import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import { i18n } from '../constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + i18n, + WIDGET_TYPE_ASSIGNEE, + WIDGET_TYPE_DESCRIPTION, + WIDGET_TYPE_WEIGHT, +} from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; 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 WorkItemAssignees from './work_item_assignees.vue'; +import WorkItemWeight from './work_item_weight.vue'; export default { i18n, components: { GlAlert, GlSkeletonLoader, + WorkItemAssignees, WorkItemActions, + WorkItemDescription, WorkItemTitle, WorkItemState, + WorkItemWeight, }, + mixins: [glFeatureFlagMixin()], props: { workItemId: { type: String, required: false, default: null, }, + workItemParentId: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -66,6 +84,18 @@ export default { canDelete() { return this.workItem?.userPermissions?.deleteWorkItem; }, + workItemsMvc2Enabled() { + return this.glFeatures.workItemsMvc2; + }, + hasDescriptionWidget() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + }, + workItemAssignees() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE); + }, + workItemWeight() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); + }, }, }; </script> @@ -83,27 +113,40 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex"> + <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" - @updated="$emit('workItemUpdated')" /> <work-item-actions :work-item-id="workItem.id" :can-delete="canDelete" - class="gl-ml-auto gl-mt-5" + class="gl-ml-auto gl-mt-6" @deleteWorkItem="$emit('deleteWorkItem')" @error="error = $event" /> </div> + <template v-if="workItemsMvc2Enabled"> + <work-item-assignees + v-if="workItemAssignees" + :work-item-id="workItem.id" + :assignees="workItemAssignees.nodes" + /> + <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" /> + </template> <work-item-state :work-item="workItem" + :work-item-parent-id="workItemParentId" + @error="error = $event" + /> + <work-item-description + v-if="hasDescriptionWidget" + :work-item-id="workItem.id" @error="error = $event" - @updated="$emit('workItemUpdated')" /> </template> </section> 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 172a40a6e56..d1c8022ac57 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 @@ -37,7 +37,7 @@ export default { default: null, }, }, - emits: ['workItemDeleted', 'workItemUpdated', 'close'], + emits: ['workItemDeleted', 'close'], data() { return { error: undefined, @@ -98,15 +98,24 @@ export default { </script> <template> - <gl-modal ref="modal" hide-footer size="lg" modal-id="work-item-detail-modal" @hide="closeModal"> + <gl-modal + ref="modal" + hide-footer + 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"> {{ error }} </gl-alert> <work-item-detail + :work-item-parent-id="issueGid" :work-item-id="workItemId" + class="gl-p-5 gl-mt-n3" @deleteWorkItem="deleteWorkItem" - @workItemUpdated="$emit('workItemUpdated')" /> </gl-modal> </template> @@ -114,7 +123,7 @@ export default { <style> /* hide the existing modal header */ -#work-item-detail-modal .modal-header { +#work-item-detail-modal .modal-header * { display: none; } </style> 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 new file mode 100644 index 00000000000..320a4a213e3 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import WorkItemLinks from './work_item_links.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default function initWorkItemLinks() { + if (!window.gon.features.workItemsHierarchy) { + return; + } + + const workItemLinksRoot = document.querySelector('.js-work-item-links-root'); + + if (!workItemLinksRoot) { + return; + } + // eslint-disable-next-line no-new + new Vue({ + el: workItemLinksRoot, + name: 'WorkItemLinksRoot', + apolloProvider, + components: { + workItemLinks: WorkItemLinks, + }, + render: (createElement) => + createElement('work-item-links', { + props: { + issuableId: parseInt(workItemLinksRoot.dataset.issuableId, 10), + }, + }), + }); +} 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 new file mode 100644 index 00000000000..bdfff100333 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -0,0 +1,165 @@ +<script> +import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { + STATE_OPEN, + WIDGET_ICONS, + WORK_ITEM_STATUS_TEXT, + WIDGET_TYPE_HIERARCHY, +} from '../../constants'; +import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import WorkItemLinksForm from './work_item_links_form.vue'; + +export default { + components: { + GlButton, + GlBadge, + GlIcon, + GlLoadingIcon, + WorkItemLinksForm, + }, + props: { + workItemId: { + type: String, + required: false, + default: null, + }, + issuableId: { + type: Number, + required: false, + default: null, + }, + }, + apollo: { + children: { + query: getWorkItemLinksQuery, + variables() { + return { + id: this.issuableGid, + }; + }, + update(data) { + return ( + data.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children + .nodes ?? [] + ); + }, + skip() { + return !this.issuableId; + }, + }, + }, + data() { + return { + isShownAddForm: false, + isOpen: true, + children: [], + }; + }, + computed: { + // Only used for children for now but should be extended later to support parents and siblings + isChildrenEmpty() { + return this.children?.length === 0; + }, + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen + ? s__('WorkItem|Collapse child items') + : s__('WorkItem|Expand child items'); + }, + issuableGid() { + return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null; + }, + isLoading() { + return this.$apollo.queries.children.loading; + }, + }, + methods: { + badgeVariant(state) { + return state === STATE_OPEN ? 'success' : 'info'; + }, + toggle() { + this.isOpen = !this.isOpen; + }, + toggleAddForm() { + this.isShownAddForm = !this.isShownAddForm; + }, + }, + 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'), + }, + WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, + WORK_ITEM_STATUS_TEXT, +}; +</script> + +<template> + <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10"> + <div + class="gl-p-4 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-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"> + <gl-button + category="tertiary" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-links" + @click="toggle" + /> + </div> + </div> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + data-testid="links-body" + > + <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> + {{ $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> + <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" + 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"> + <gl-badge :variant="badgeVariant(child.state)"> + <span class="gl-sm-display-block">{{ + $options.WORK_ITEM_STATUS_TEXT[child.state] + }}</span> + </gl-badge> + </div> + </div> + </template> + </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 new file mode 100644 index 00000000000..22728f58026 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -0,0 +1,28 @@ +<script> +import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; + +export default { + components: { + GlForm, + GlFormInput, + GlButton, + }, + data() { + return { + relatedWorkItem: '', + }; + }, +}; +</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-button> + <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')"> + {{ s__('WorkItem|Cancel') }} + </gl-button> + </gl-form> +</template> 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 51db4c804eb..87f4a8822b1 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -7,8 +7,9 @@ import { STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, + TRACKING_CATEGORY_SHOW, } from '../constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import { getUpdateWorkItemMutation } from './update_work_item'; import ItemState from './item_state.vue'; export default { @@ -21,6 +22,11 @@ export default { type: Object, required: true, }, + workItemParentId: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -33,14 +39,14 @@ export default { }, tracking() { return { - category: 'workItems:show', + category: TRACKING_CATEGORY_SHOW, label: 'item_state', property: `type_${this.workItemType}`, }; }, }, methods: { - async updateWorkItemState(newState) { + updateWorkItemState(newState) { const stateEventMap = { [STATE_OPEN]: STATE_EVENT_REOPEN, [STATE_CLOSED]: STATE_EVENT_CLOSE, @@ -48,35 +54,39 @@ export default { const stateEvent = stateEventMap[newState]; - await this.updateWorkItem(stateEvent); + this.updateWorkItem(stateEvent); }, + async updateWorkItem(updatedState) { if (!updatedState) { return; } + const input = { + id: this.workItem.id, + stateEvent: updatedState, + }; + this.updateInProgress = true; try { this.track('updated_state'); - const { - data: { workItemUpdate }, - } = await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: this.workItem.id, - stateEvent: updatedState, - }, - }, + const { mutation, variables } = getUpdateWorkItemMutation({ + workItemParentId: this.workItemParentId, + input, }); - if (workItemUpdate?.errors?.length) { - throw new Error(workItemUpdate.errors[0]); - } + const { data } = await this.$apollo.mutate({ + mutation, + variables, + }); - this.$emit('updated'); + const errors = data.workItemUpdate?.errors; + + if (errors?.length) { + throw new Error(errors[0]); + } } catch (error) { this.$emit('error', i18n.updateError); 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 d2e6d3c0bbf..b4c13037038 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,8 @@ <script> +import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; -import { i18n } from '../constants'; -import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; +import { getUpdateWorkItemMutation } from './update_work_item'; import ItemTitle from './item_title.vue'; export default { @@ -25,11 +26,16 @@ export default { required: false, default: '', }, + workItemParentId: { + type: String, + required: false, + default: null, + }, }, computed: { tracking() { return { - category: 'workItems:show', + category: TRACKING_CATEGORY_SHOW, label: 'item_title', property: `type_${this.workItemType}`, }; @@ -41,21 +47,37 @@ export default { return; } + const input = { + id: this.workItemId, + title: updatedTitle, + }; + + this.updateInProgress = true; + try { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: this.workItemId, - title: updatedTitle, - }, - }, - }); this.track('updated_title'); - this.$emit('updated'); - } catch { + + const { mutation, variables } = getUpdateWorkItemMutation({ + workItemParentId: this.workItemParentId, + input, + }); + + const { data } = await this.$apollo.mutate({ + mutation, + variables, + }); + + const errors = data.workItemUpdate?.errors; + + if (errors?.length) { + throw new Error(errors[0]); + } + } catch (error) { this.$emit('error', i18n.updateError); + Sentry.captureException(error); } + + this.updateInProgress = false; }, }, }; diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue new file mode 100644 index 00000000000..b0f2b3aa14a --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -0,0 +1,26 @@ +<script> +import { __ } from '~/locale'; + +export default { + inject: ['hasIssueWeightsFeature'], + props: { + weight: { + type: Number, + required: false, + default: undefined, + }, + }, + computed: { + weightText() { + return this.weight ?? __('None'); + }, + }, +}; +</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> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index e914500108f..2df4978a319 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -6,9 +6,27 @@ export const STATE_CLOSED = 'CLOSED'; export const STATE_EVENT_REOPEN = 'REOPEN'; export const STATE_EVENT_CLOSE = 'CLOSE'; +export const TRACKING_CATEGORY_SHOW = 'workItems:show'; + export const i18n = { fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), }; export const DEFAULT_MODAL_TYPE = 'Task'; + +export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES'; +export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; +export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; + +export const WIDGET_TYPE_TASK_ICON = 'task-done'; + +export const WIDGET_ICONS = { + TASK: 'task-done', +}; + +export const WORK_ITEM_STATUS_TEXT = { + CLOSED: s__('WorkItem|Closed'), + OPEN: s__('WorkItem|Open'), +}; 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 new file mode 100644 index 00000000000..0d31ecef6f8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql @@ -0,0 +1,9 @@ +#import "./work_item.fragment.graphql" + +mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) { + localUpdateWorkItem(input: $input) @client { + workItem { + ...WorkItem + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 3c2955ce1e2..09d929faae2 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -1,11 +1,93 @@ +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 typeDefs from './typedefs.graphql'; +import workItemQuery from './work_item.query.graphql'; + +export const temporaryConfig = { + typeDefs, + cacheConfig: { + possibleTypes: { + LocalWorkItemWidget: ['LocalWorkItemAssignees'], + }, + typePolicies: { + WorkItem: { + fields: { + mockWidgets: { + read(widgets) { + 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: 'LocalWorkItemWeight', + type: 'WEIGHT', + weight: 0, + }, + ] + ); + }, + }, + }, + }, + }, + }, +}; + +export const resolvers = { + Mutation: { + localUpdateWorkItem(_, { input }, { cache }) { + const sourceData = cache.readQuery({ + query: workItemQuery, + variables: { id: input.id }, + }); + + 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), + ); + }); + + cache.writeQuery({ + query: workItemQuery, + variables: { id: input.id }, + data, + }); + }, + }, +}; export function createApolloProvider() { Vue.use(VueApollo); - const defaultClient = createDefaultClient(); + const defaultClient = createDefaultClient(resolvers, temporaryConfig); return new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql new file mode 100644 index 00000000000..bfe2f0fe0ce --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -0,0 +1,36 @@ +enum LocalWidgetType { + ASSIGNEES + WEIGHT +} + +interface LocalWorkItemWidget { + type: LocalWidgetType! +} + +type LocalWorkItemAssignees implements LocalWorkItemWidget { + type: LocalWidgetType! + nodes: [UserCore] +} + +type LocalWorkItemWeight implements LocalWorkItemWidget { + type: LocalWidgetType! + weight: Int +} + +extend type WorkItem { + mockWidgets: [LocalWorkItemWidget] +} + +type LocalWorkItemAssigneesInput { + id: WorkItemID! + assigneeIds: [ID!] +} + +type LocalWorkItemPayload { + workItem: WorkItem! + errors: [String!] +} + +extend type Mutation { + localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload +} 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 new file mode 100644 index 00000000000..470de060ee3 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql @@ -0,0 +1,8 @@ +mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) { + workItemUpdate: workItemUpdateTask(input: $input) { + workItem { + id + descriptionHtml + } + } +} 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 new file mode 100644 index 00000000000..148b340b439 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql @@ -0,0 +1,10 @@ +#import "./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 e25fd102699..04701f6899e 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -11,4 +11,11 @@ fragment WorkItem on WorkItem { deleteWorkItem updateWorkItem } + widgets { + ... on WorkItemWidgetDescription { + type + description + descriptionHtml + } + } } 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 3b46fed97ec..30bc61f5c59 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -3,5 +3,21 @@ query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem + mockWidgets @client { + ... on LocalWorkItemAssignees { + type + nodes { + id + avatarUrl + name + username + webUrl + } + } + ... on LocalWorkItemWeight { + type + weight + } + } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql new file mode 100644 index 00000000000..c2496f53cc8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -0,0 +1,28 @@ +query workItemQuery($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + } + title + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + children { + nodes { + id + workItemType { + id + } + title + state + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index e39b0d6a353..33e28831b54 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -1,11 +1,12 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; 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'); - const { fullPath, issuesListPath } = el.dataset; + const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset; return new Vue({ el, @@ -13,6 +14,7 @@ export const initWorkItemsRoot = () => { apolloProvider: createApolloProvider(), provide: { fullPath, + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), issuesListPath, }, render(createElement) { 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 6dc3dc3b3c9..e9840889bdb 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -4,6 +4,7 @@ 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 deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql'; @@ -29,6 +30,9 @@ export default { return convertToGraphQLId(TYPE_WORK_ITEM, this.id); }, }, + mounted() { + this.ZenMode = new ZenMode(); + }, methods: { deleteWorkItem() { this.$apollo |