Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
commit0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch)
tree7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts/work_items
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/work_items')
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue18
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue9
-rw-r--r--app/assets/javascripts/work_items/components/update_work_item.js23
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue111
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue234
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue53
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue17
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js37
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue165
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue28
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state.vue46
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue50
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue26
-rw-r--r--app/assets/javascripts/work_items/constants.js18
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js84
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql36
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql16
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql28
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue4
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