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/components
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/work_items/components')
-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
13 files changed, 770 insertions, 47 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>