summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/work_items
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/work_items')
-rw-r--r--app/assets/javascripts/work_items/components/notes/activity_filter.vue113
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue59
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_body.vue37
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_form.vue228
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_locked.vue66
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue67
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue21
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue45
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue149
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_note_signed_out.vue31
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue158
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue5
-rw-r--r--app/assets/javascripts/work_items/constants.js31
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql (renamed from app/assets/javascripts/work_items/graphql/discussion.fragment.graphql)8
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/index.js4
29 files changed, 990 insertions, 118 deletions
diff --git a/app/assets/javascripts/work_items/components/notes/activity_filter.vue b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
new file mode 100644
index 00000000000..71784d3a807
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { ASC, DESC } from '~/notes/constants';
+import { TRACKING_CATEGORY_SHOW, WORK_ITEM_NOTES_SORT_ORDER_KEY } from '~/work_items/constants';
+
+const SORT_OPTIONS = [
+ { key: DESC, text: __('Newest first'), dataid: 'js-newest-first' },
+ { key: ASC, text: __('Oldest first'), dataid: 'js-oldest-first' },
+];
+
+export default {
+ SORT_OPTIONS,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ sortOrder: {
+ type: String,
+ default: ASC,
+ required: false,
+ },
+ loading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ persistSortOrder: true,
+ };
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_track_notes_sorting',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ selectedSortOption() {
+ const isSortOptionValid = this.sortOrder === ASC || this.sortOrder === DESC;
+ return isSortOptionValid ? SORT_OPTIONS.find(({ key }) => this.sortOrder === key) : ASC;
+ },
+ getDropdownSelectedText() {
+ return this.selectedSortOption.text;
+ },
+ },
+ methods: {
+ setDiscussionSortDirection(direction) {
+ this.$emit('updateSavedSortOrder', direction);
+ },
+ fetchSortedDiscussions(direction) {
+ if (this.isSortDropdownItemActive(direction)) {
+ return;
+ }
+ this.track('notes_sort_order_changed');
+ this.$emit('changeSortOrder', direction);
+ },
+ isSortDropdownItemActive(sortDir) {
+ return sortDir === this.sortOrder;
+ },
+ },
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+};
+</script>
+
+<template>
+ <div
+ id="discussion-preferences"
+ data-testid="discussion-preferences"
+ class="gl-display-inline-block gl-vertical-align-bottom gl-w-full gl-sm-w-auto"
+ >
+ <local-storage-sync
+ :value="sortOrder"
+ :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
+ :persist="persistSortOrder"
+ as-string
+ @input="setDiscussionSortDirection"
+ />
+ <gl-dropdown
+ :id="`discussion-preferences-dropdown-${workItemType}`"
+ class="gl-xs-w-full"
+ size="small"
+ :text="getDropdownSelectedText"
+ :disabled="loading"
+ right
+ >
+ <div id="discussion-sort">
+ <gl-dropdown-item
+ v-for="{ text, key, dataid } in $options.SORT_OPTIONS"
+ :key="text"
+ :data-testid="dataid"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
new file mode 100644
index 00000000000..5efa9c94f2b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
+import NoteHeader from '~/notes/components/note_header.vue';
+
+export default {
+ components: {
+ NoteHeader,
+ NoteBody,
+ TimelineEntryItem,
+ GlAvatarLink,
+ GlAvatar,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-entry-item
+ :id="noteAnchorId"
+ :class="{ 'internal-note': note.internal }"
+ :data-note-id="note.id"
+ class="note note-wrapper note-comment"
+ >
+ <div class="timeline-avatar gl-float-left">
+ <gl-avatar-link :href="author.webUrl">
+ <gl-avatar
+ :src="author.avatarUrl"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="32"
+ />
+ </gl-avatar-link>
+ </div>
+
+ <div class="timeline-content">
+ <div class="note-header">
+ <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
+ </div>
+ <div class="timeline-discussion-body">
+ <note-body :note="note" />
+ </div>
+ </div>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
new file mode 100644
index 00000000000..dcee8750f81
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
@@ -0,0 +1,37 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ renderGFM(this.$refs['note-body']);
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
+ },
+};
+</script>
+
+<template>
+ <div ref="note-body" class="note-body">
+ <div
+ v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml"
+ class="note-text md"
+ data-testid="work-item-note-body"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue
new file mode 100644
index 00000000000..65042f1431d
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue
@@ -0,0 +1,228 @@
+<script>
+import { GlAvatar, GlButton } 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils';
+import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
+import WorkItemCommentLocked from './work_item_comment_locked.vue';
+
+export default {
+ constantOptions: {
+ markdownDocsPath: helpPagePath('user/markdown'),
+ avatarUrl: window.gon.current_user_avatar_url,
+ },
+ components: {
+ GlAvatar,
+ GlButton,
+ MarkdownEditor,
+ WorkItemNoteSignedOut,
+ WorkItemCommentLocked,
+ },
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ workItem: {},
+ isEditing: false,
+ isSubmitting: false,
+ isSubmittingWithKeydown: false,
+ commentText: '',
+ };
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ skip() {
+ return !this.queryVariables.id && !this.queryVariables.iid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ },
+ computed: {
+ signedIn() {
+ return Boolean(window.gon.current_user_id);
+ },
+ autosaveKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.workItemId}-comment`;
+ },
+ canEdit() {
+ // maybe this should use `NotePermissions.updateNote`, but if
+ // we don't have any notes yet, that permission isn't on WorkItem
+ return Boolean(this.workItem?.userPermissions?.updateWorkItem);
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_comment',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ workItemType() {
+ return this.workItem?.workItemType?.name;
+ },
+ markdownPreviewPath() {
+ return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
+ this.workItemType
+ }`;
+ },
+ isProjectArchived() {
+ return this.workItem?.project?.archived;
+ },
+ },
+ methods: {
+ startEditing() {
+ this.isEditing = true;
+ this.commentText = getDraft(this.autosaveKey) || '';
+ },
+ async cancelEditing() {
+ if (this.commentText) {
+ 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);
+ },
+ async updateWorkItem(event = {}) {
+ const { key } = event;
+
+ if (key) {
+ this.isSubmittingWithKeydown = true;
+ }
+
+ this.isSubmitting = true;
+
+ try {
+ this.track('add_work_item_comment');
+
+ const {
+ data: { createNote },
+ } = await this.$apollo.mutate({
+ mutation: createNoteMutation,
+ variables: {
+ input: {
+ noteableId: this.workItem.id,
+ body: this.commentText,
+ },
+ },
+ });
+
+ if (createNote.errors?.length) {
+ throw new Error(createNote.errors[0]);
+ }
+
+ const client = this.$apollo.provider.defaultClient;
+ client.refetchQueries({
+ include: [getWorkItemNotesQuery(this.fetchByIid)],
+ });
+
+ this.isEditing = false;
+ clearDraft(this.autosaveKey);
+ } catch (error) {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ }
+
+ this.isSubmitting = false;
+ },
+ setCommentText(newText) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry">
+ <work-item-note-signed-out v-if="!signedIn" />
+ <work-item-comment-locked
+ v-else-if="!canEdit"
+ :work-item-type="workItemType"
+ :is-project-archived="isProjectArchived"
+ />
+ <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
+ <markdown-editor
+ class="gl-mb-3"
+ :value="commentText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :form-field-aria-label="__('Add a comment')"
+ :form-field-placeholder="__('Write a comment or drag your files here…')"
+ form-field-id="work-item-add-comment"
+ form-field-name="work-item-add-comment"
+ enable-autocomplete
+ autofocus
+ use-bottom-toolbar
+ @input="setCommentText"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ @keydown.esc="cancelEditing"
+ />
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ @click="updateWorkItem"
+ >{{ __('Comment') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </form>
+ <gl-button
+ v-else
+ class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
+ @click="startEditing"
+ >{{ __('Add a comment') }}</gl-button
+ >
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue
new file mode 100644
index 00000000000..f837d025b7f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_comment_locked.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { TASK_TYPE_NAME } from '~/work_items/constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ workItemType: {
+ required: false,
+ type: String,
+ default: TASK_TYPE_NAME,
+ },
+ isProjectArchived: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ },
+ constantOptions: {
+ archivedProjectDocsPath: helpPagePath('user/project/settings/index.md', {
+ anchor: 'archive-a-project',
+ }),
+ lockedIssueDocsPath: helpPagePath('user/discussions/index.md', {
+ anchor: 'prevent-comments-by-locking-the-discussion',
+ }),
+ projectArchivedWarning: __('This project is archived and cannot be commented on.'),
+ },
+ computed: {
+ issuableDisplayName() {
+ return this.workItemType.replace(/_/g, ' ');
+ },
+ lockedIssueWarning() {
+ return sprintf(
+ __('This %{issuableDisplayName} is locked. Only project members can comment.'),
+ { issuableDisplayName: this.issuableDisplayName },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="disabled-comment text-center">
+ <span class="issuable-note-warning gl-display-inline-block">
+ <gl-icon name="lock" class="gl-mr-2" />
+ <template v-if="isProjectArchived">
+ {{ $options.constantOptions.projectArchivedWarning }}
+ <gl-link :href="$options.constantOptions.archivedProjectDocsPath" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
+ </template>
+
+ <template v-else>
+ {{ lockedIssueWarning }}
+ <gl-link :href="$options.constantOptions.lockedIssueDocsPath" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
+ </template>
+ </span>
+ </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 cb45a05de89..ade954b2a7f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -15,10 +15,14 @@ import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
+import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
+ sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
@@ -53,6 +57,7 @@ import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemNotes from './work_item_notes.vue';
+import WorkItemDetailModal from './work_item_detail_modal.vue';
export default {
i18n,
@@ -83,6 +88,7 @@ export default {
WorkItemMilestone,
WorkItemTree,
WorkItemNotes,
+ WorkItemDetailModal,
},
mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
@@ -109,11 +115,16 @@ export default {
},
},
data() {
+ const workItemId = getParameterByName('work_item_id');
+
return {
error: undefined,
updateError: undefined,
workItem: {},
updateInProgress: false,
+ modalWorkItemId: isPositiveInteger(workItemId)
+ ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ : null,
};
},
apollo: {
@@ -207,6 +218,9 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ confidentialTooltip() {
+ return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
+ },
fullPath() {
return this.workItem?.project.fullPath;
},
@@ -295,6 +309,11 @@ export default {
return widgetHierarchy.children.nodes;
},
},
+ mounted() {
+ if (this.modalWorkItemId) {
+ this.openInModal(undefined, { id: this.modalWorkItemId });
+ }
+ },
methods: {
isWidgetPresent(type) {
return this.workItem?.widgets?.find((widget) => widget.type === type);
@@ -362,9 +381,10 @@ export default {
});
const newData = produce(sourceData, (draftState) => {
- const widgetHierarchy = draftState.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
+ const widgets = this.fetchByIid
+ ? draftState.workspace.workItems.nodes[0].widgets
+ : draftState.workItem.widgets;
+ const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
@@ -419,6 +439,26 @@ export default {
Sentry.captureException(error);
}
},
+ updateUrl(modalWorkItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }),
+ replace: true,
+ });
+ },
+ openInModal(event, modalWorkItem) {
+ if (event) {
+ event.preventDefault();
+
+ this.updateUrl(modalWorkItem.id);
+ }
+
+ if (this.isModal) {
+ this.$emit('update-modal', event, modalWorkItem.id);
+ return;
+ }
+ this.modalWorkItemId = modalWorkItem.id;
+ this.$refs.modal.show();
+ },
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
@@ -456,6 +496,7 @@ export default {
category="tertiary"
:href="parentUrl"
:title="parentWorkItem.title"
+ @click="openInModal($event, parentWorkItem)"
>{{ parentWorkItem.title }}</gl-button
>
<gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
@@ -482,7 +523,7 @@ export default {
<gl-badge
v-if="workItem.confidential"
v-gl-tooltip.bottom
- :title="$options.i18n.confidentialTooltip"
+ :title="confidentialTooltip"
variant="warning"
icon="eye-slash"
class="gl-mr-3 gl-cursor-help"
@@ -605,6 +646,9 @@ export default {
:can-update="canUpdate"
:work-item-id="workItem.id"
:work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
@error="updateError = $event"
/>
<work-item-description
@@ -619,20 +663,24 @@ export default {
<work-item-tree
v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
:work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
:work-item-id="workItem.id"
:children="children"
:can-update="canUpdate"
:project-path="fullPath"
+ :confidential="workItem.confidential"
@addWorkItemChild="addChild"
@removeChild="removeChild"
+ @show-modal="openInModal"
/>
- <template v-if="workItemsMvc2Enabled">
+ <template v-if="workItemsMvcEnabled">
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
:query-variables="queryVariables"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
+ :work-item-type="workItemType"
class="gl-pt-5"
@error="updateError = $event"
/>
@@ -644,5 +692,12 @@ export default {
:svg-path="noAccessSvgPath"
/>
</template>
+ <work-item-detail-modal
+ v-if="!isModal"
+ ref="modal"
+ :work-item-id="modalWorkItemId"
+ :show="true"
+ @close="updateUrl"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index e8726814aaf..faea80a9de8 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
@@ -3,7 +3,6 @@ import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
-import WorkItemDetail from './work_item_detail.vue';
export default {
i18n: {
@@ -12,7 +11,7 @@ export default {
components: {
GlAlert,
GlModal,
- WorkItemDetail,
+ WorkItemDetail: () => import('./work_item_detail.vue'),
},
props: {
workItemId: {
@@ -46,12 +45,18 @@ export default {
default: null,
},
},
- emits: ['workItemDeleted', 'close'],
+ emits: ['workItemDeleted', 'close', 'update-modal'],
data() {
return {
error: undefined,
+ updatedWorkItemId: null,
};
},
+ computed: {
+ displayedWorkItemId() {
+ return this.updatedWorkItemId || this.workItemId;
+ },
+ },
methods: {
deleteWorkItem() {
if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
@@ -116,6 +121,7 @@ export default {
});
},
closeModal() {
+ this.updatedWorkItemId = null;
this.error = '';
this.$emit('close');
},
@@ -128,6 +134,10 @@ export default {
show() {
this.$refs.modal.show();
},
+ updateModal($event, workItemId) {
+ this.updatedWorkItemId = workItemId;
+ this.$emit('update-modal', $event, workItemId);
+ },
},
};
</script>
@@ -149,11 +159,12 @@ export default {
<work-item-detail
is-modal
:work-item-parent-id="issueGid"
- :work-item-id="workItemId"
+ :work-item-id="displayedWorkItemId"
:work-item-iid="workItemIid"
- class="gl-p-5 gl-mt-n3"
+ class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
+ @update-modal="updateModal"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index edad0e9b616..a7405b6d86c 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -18,6 +18,8 @@ export default function initWorkItemLinks() {
iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
+ registerPath,
+ signInPath,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
@@ -35,6 +37,8 @@ export default function initWorkItemLinks() {
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
+ registerPath,
+ signInPath,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
index 763f2f338a3..3a3a846bce5 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -5,11 +5,14 @@ import { __, s__ } from '~/locale';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
+import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
import {
STATE_OPEN,
TASK_TYPE_NAME,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ WIDGET_TYPE_PROGRESS,
+ WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_ASSIGNEES,
@@ -17,7 +20,6 @@ import {
WORK_ITEM_NAME_TO_ICON_MAP,
} from '../../constants';
import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql';
-import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue';
import WorkItemLinksMenu from './work_item_links_menu.vue';
import WorkItemTreeChildren from './work_item_tree_children.vue';
@@ -73,8 +75,15 @@ export default {
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
- allowsScopedLabels() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels;
+ metadataWidgets() {
+ return this.childItem.widgets?.reduce((metadataWidgets, widget) => {
+ // Skip Hierarchy widget as it is not part of metadata.
+ if (widget.type && widget.type !== WIDGET_TYPE_HIERARCHY) {
+ // eslint-disable-next-line no-param-reassign
+ metadataWidgets[widget.type] = widget;
+ }
+ return metadataWidgets;
+ }, {});
},
isItemOpen() {
return this.childItem.state === STATE_OPEN;
@@ -113,16 +122,16 @@ export default {
return this.isExpanded ? __('Collapse') : __('Expand');
},
hasMetadata() {
- return this.milestone || this.assignees.length > 0 || this.labels.length > 0;
- },
- milestone() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone;
- },
- assignees() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || [];
- },
- labels() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || [];
+ if (this.metadataWidgets) {
+ return (
+ Number.isInteger(this.metadataWidgets[WIDGET_TYPE_PROGRESS]?.progress) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_HEALTH_STATUS]?.healthStatus) ||
+ Boolean(this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone) ||
+ this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes.length > 0 ||
+ this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes.length > 0
+ );
+ }
+ return false;
},
},
methods: {
@@ -230,10 +239,7 @@ export default {
</div>
<work-item-link-child-metadata
v-if="hasMetadata"
- :allows-scoped-labels="allowsScopedLabels"
- :milestone="milestone"
- :assignees="assignees"
- :labels="labels"
+ :metadata-widgets="metadataWidgets"
class="gl-mt-3"
/>
</div>
@@ -258,6 +264,7 @@ export default {
:work-item-type="workItemType"
:children="children"
@removeChild="fetchChildren"
+ @click="$emit('click', $event)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 7be7e1f3496..6974804523a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -6,6 +6,8 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
+import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants';
+
export default {
components: {
GlLabel,
@@ -18,28 +20,25 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- allowsScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- milestone: {
+ metadataWidgets: {
type: Object,
required: false,
- default: null,
- },
- assignees: {
- type: Array,
- required: false,
- default: () => [],
- },
- labels: {
- type: Array,
- required: false,
- default: () => [],
+ default: () => ({}),
},
},
computed: {
+ milestone() {
+ return this.metadataWidgets[WIDGET_TYPE_MILESTONE]?.milestone;
+ },
+ assignees() {
+ return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || [];
+ },
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
assigneesCollapsedTooltip() {
if (this.assignees.length > 2) {
return sprintf(s__('WorkItem|%{count} more assignees'), {
@@ -56,12 +55,6 @@ export default {
}
return '';
},
- labelsContainerClass() {
- if (this.milestone || this.assignees.length) {
- return 'gl-sm-ml-5';
- }
- return '';
- },
},
methods: {
showScopedLabel(label) {
@@ -73,6 +66,7 @@ export default {
<template>
<div class="gl-display-flex gl-flex-wrap gl-align-items-center">
+ <slot></slot>
<item-milestone
v-if="milestone"
:milestone="milestone"
@@ -87,6 +81,7 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
+ class="gl-mr-5"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
@@ -94,7 +89,7 @@ export default {
</gl-avatar-link>
</template>
</gl-avatars-inline>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass">
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap">
<gl-label
v-for="label in labels"
:key="label.id"
@@ -102,7 +97,7 @@ export default {
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
- class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
+ class="gl-mt-3 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index faadb5fa6fa..b078711ec5d 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -399,6 +399,7 @@ export default {
:parent-iteration="issuableIteration"
:parent-milestone="issuableMilestone"
:form-type="formType"
+ :parent-work-item-type="workItem.workItemType.name"
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 5cf0c4154bb..d79aaab38f2 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,9 +1,18 @@
<script>
-import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlFormGroup,
+ GlForm,
+ GlTokenSelector,
+ GlButton,
+ GlFormInput,
+ GlFormCheckbox,
+ GlTooltip,
+} from '@gitlab/ui';
import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
@@ -17,6 +26,8 @@ import {
I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
I18N_WORK_ITEM_ADD_BUTTON_LABEL,
I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL,
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL,
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP,
sprintfWorkItem,
} from '../../constants';
@@ -28,6 +39,8 @@ export default {
GlButton,
GlFormGroup,
GlFormInput,
+ GlFormCheckbox,
+ GlTooltip,
},
mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
@@ -61,6 +74,11 @@ export default {
type: String,
required: true,
},
+ parentWorkItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
childrenType: {
type: String,
required: false,
@@ -108,6 +126,7 @@ export default {
error: null,
childToCreateTitle: null,
workItemsToAdd: [],
+ confidential: this.parentConfidential,
};
},
computed: {
@@ -119,7 +138,7 @@ export default {
hierarchyWidget: {
parentId: this.issuableGid,
},
- confidential: this.parentConfidential,
+ confidential: this.parentConfidential || this.confidential,
};
if (this.parentMilestoneId) {
@@ -154,6 +173,9 @@ export default {
childrenTypeName() {
return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name;
},
+ childrenTypeValue() {
+ return WORK_ITEMS_TYPE_MAP[this.childrenType]?.value;
+ },
addOrCreateButtonLabel() {
if (this.isCreateForm) {
return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName);
@@ -162,11 +184,24 @@ export default {
}
return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName);
},
+ confidentialityCheckboxLabel() {
+ return sprintfWorkItem(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, this.childrenTypeName);
+ },
+ confidentialityCheckboxTooltip() {
+ return sprintfWorkItem(
+ I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP,
+ this.childrenTypeName,
+ this.parentWorkItemType,
+ );
+ },
+ showConfidentialityTooltip() {
+ return this.isCreateForm && this.parentConfidential;
+ },
addOrCreateMethod() {
return this.isCreateForm ? this.createChild : this.addChild;
},
childWorkItemType() {
- return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id;
+ return this.workItemTypes.find((type) => type.name === this.childrenTypeValue)?.id;
},
parentIterationId() {
return this.parentIteration?.id;
@@ -178,7 +213,10 @@ export default {
return this.parentMilestone?.id;
},
isSubmitButtonDisabled() {
- return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0;
+ if (this.isCreateForm) {
+ return this.search.length === 0;
+ }
+ return this.workItemsToAdd.length === 0 || !this.areWorkItemsToAddValid;
},
isLoading() {
return this.$apollo.queries.availableWorkItems.loading;
@@ -186,12 +224,43 @@ export default {
addInputPlaceholder() {
return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName);
},
+ tokenSelectorContainerClass() {
+ return !this.areWorkItemsToAddValid ? 'gl-inset-border-1-red-500!' : '';
+ },
+ invalidWorkItemsToAdd() {
+ return this.parentConfidential
+ ? this.workItemsToAdd.filter((workItem) => !workItem.confidential)
+ : [];
+ },
+ areWorkItemsToAddValid() {
+ return this.invalidWorkItemsToAdd.length === 0;
+ },
+ showWorkItemsToAddInvalidMessage() {
+ return !this.isCreateForm && !this.areWorkItemsToAddValid;
+ },
+ workItemsToAddInvalidMessage() {
+ return sprintf(
+ s__(
+ 'WorkItem|%{invalidWorkItemsList} cannot be added: Cannot assign a non-confidential %{childWorkItemType} to a confidential parent %{parentWorkItemType}. Make the selected %{childWorkItemType} confidential and try again.',
+ ),
+ {
+ invalidWorkItemsList: this.invalidWorkItemsToAdd.map(({ title }) => title).join(', '),
+ childWorkItemType: this.childrenTypeName,
+ parentWorkItemType: this.parentWorkItemType,
+ },
+ );
+ },
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getIdFromGraphQLId,
+ getConfidentialityTooltipTarget() {
+ // We want tooltip to be anchored to `input` within checkbox component
+ // but `$el.querySelector('input')` doesn't work. 🤷‍♂️
+ return this.$refs.confidentialityCheckbox?.$el;
+ },
unsetError() {
this.error = null;
},
@@ -299,30 +368,54 @@ export default {
autofocus
/>
</gl-form-group>
- <gl-token-selector
- v-else
- v-model="workItemsToAdd"
- :dropdown-items="availableWorkItems"
- :loading="isLoading"
- :placeholder="addInputPlaceholder"
- menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
- class="gl-mb-4"
- data-testid="work-item-token-select-input"
- @text-input="debouncedSearchKeyUpdate"
- @focus="handleFocus"
- @mouseover.native="handleMouseOver"
- @mouseout.native="handleMouseOut"
+ <gl-form-checkbox
+ v-if="isCreateForm"
+ ref="confidentialityCheckbox"
+ v-model="confidential"
+ name="isConfidential"
+ class="gl-md-mt-5 gl-mb-5 gl-md-mb-3!"
+ :disabled="parentConfidential"
+ >{{ confidentialityCheckboxLabel }}</gl-form-checkbox
+ >
+ <gl-tooltip
+ v-if="showConfidentialityTooltip"
+ :target="getConfidentialityTooltipTarget"
+ triggers="hover"
+ >{{ confidentialityCheckboxTooltip }}</gl-tooltip
>
- <template #token-content="{ token }">
- {{ token.title }}
- </template>
- <template #dropdown-item-content="{ dropdownItem }">
- <div class="gl-display-flex">
- <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
- <div class="gl-text-truncate">{{ dropdownItem.title }}</div>
- </div>
- </template>
- </gl-token-selector>
+ <div class="gl-mb-4">
+ <gl-token-selector
+ v-if="!isCreateForm"
+ v-model="workItemsToAdd"
+ :dropdown-items="availableWorkItems"
+ :loading="isLoading"
+ :placeholder="addInputPlaceholder"
+ menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!"
+ :container-class="tokenSelectorContainerClass"
+ data-testid="work-item-token-select-input"
+ @text-input="debouncedSearchKeyUpdate"
+ @focus="handleFocus"
+ @mouseover.native="handleMouseOver"
+ @mouseout.native="handleMouseOut"
+ >
+ <template #token-content="{ token }">
+ {{ token.title }}
+ </template>
+ <template #dropdown-item-content="{ dropdownItem }">
+ <div class="gl-display-flex">
+ <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
+ <div class="gl-text-truncate">{{ dropdownItem.title }}</div>
+ </div>
+ </template>
+ </gl-token-selector>
+ <div
+ v-if="showWorkItemsToAddInvalidMessage"
+ class="gl-text-red-500"
+ data-testid="work-items-invalid"
+ >
+ {{ workItemsToAddInvalidMessage }}
+ </div>
+ </div>
<gl-button
category="primary"
variant="confirm"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index f06de2ca048..81e2bb76900 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -40,10 +40,20 @@ export default {
type: String,
required: true,
},
+ parentWorkItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
workItemId: {
type: String,
required: true,
},
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
children: {
type: Array,
required: false,
@@ -221,8 +231,10 @@ export default {
data-testid="add-tree-form"
:issuable-gid="workItemId"
:form-type="formType"
+ :parent-work-item-type="parentWorkItemType"
:children-type="childType"
:children-ids="childrenIds"
+ :parent-confidential="confidential"
@addWorkItemChild="$emit('addWorkItemChild', $event)"
@cancel="hideAddForm"
/>
@@ -233,11 +245,13 @@ export default {
:can-update="canUpdate"
:issuable-gid="workItemId"
:child-item="child"
+ :confidential="child.confidential"
:work-item-type="workItemType"
:has-indirect-children="hasIndirectChildren"
@mouseover="prefetchWorkItem(child)"
@mouseout="clearPrefetching"
@removeChild="$emit('removeChild', $event)"
+ @click="$emit('show-modal', $event, $event.childItem || child)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
index 911cac4de88..71de6867680 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
@@ -63,6 +63,7 @@ export default {
:child-item="child"
:work-item-type="workItemType"
@removeChild="updateWorkItem"
+ @click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue
new file mode 100644
index 00000000000..3ef4a16bc57
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue
@@ -0,0 +1,31 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { __, sprintf } from '~/locale';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ inject: ['registerPath', 'signInPath'],
+ computed: {
+ signedOutText() {
+ return sprintf(
+ __(
+ 'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply',
+ ),
+ {
+ startTagRegister: `<a href="${this.registerPath}">`,
+ startTagSignIn: `<a href="${this.signInPath}">`,
+ endRegisterTag: '</a>',
+ endSignInTag: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 91e90589a93..a59767d8b70 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -2,8 +2,12 @@
import { GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import SystemNote from '~/work_items/components/notes/system_note.vue';
+import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
+import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
+import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
+import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
i18n: {
@@ -15,8 +19,11 @@ export default {
height: 40,
},
components: {
- SystemNote,
GlSkeletonLoader,
+ ActivityFilter,
+ SystemNote,
+ WorkItemCommentForm,
+ WorkItemNote,
},
props: {
workItemId: {
@@ -31,22 +38,50 @@ export default {
type: String,
required: true,
},
+ workItemType: {
+ type: String,
+ required: true,
+ },
fetchByIid: {
type: Boolean,
required: false,
default: false,
},
},
+ data() {
+ return {
+ notesArray: [],
+ isLoadingMore: false,
+ perPage: DEFAULT_PAGE_SIZE_NOTES,
+ sortOrder: ASC,
+ changeNotesSortOrderAfterLoading: false,
+ };
+ },
computed: {
- areNotesLoading() {
- return this.$apollo.queries.workItemNotes.loading;
- },
- notes() {
- return this.workItemNotes?.nodes;
+ initialLoading() {
+ return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore;
},
pageInfo() {
return this.workItemNotes?.pageInfo;
},
+ avatarUrl() {
+ return window.gon.current_user_avatar_url;
+ },
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ showInitialLoader() {
+ return this.initialLoading || this.changeNotesSortOrderAfterLoading;
+ },
+ showTimeline() {
+ return !this.changeNotesSortOrderAfterLoading;
+ },
+ showLoadingMoreSkeleton() {
+ return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
+ },
+ disableActivityFilter() {
+ return this.initialLoading || this.isLoadingMore;
+ },
},
apollo: {
workItemNotes: {
@@ -59,6 +94,7 @@ export default {
variables() {
return {
...this.queryVariables,
+ after: this.after,
pageSize: DEFAULT_PAGE_SIZE_NOTES,
};
},
@@ -66,7 +102,11 @@ export default {
const workItemWidgets = this.fetchByIid
? data.workspace?.workItems?.nodes[0]?.widgets
: data.workItem?.widgets;
- return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || [];
+ const discussionNodes =
+ workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || [];
+ this.notesArray = discussionNodes?.nodes || [];
+ this.updateSortingOrderIfApplicable();
+ return discussionNodes;
},
skip() {
return !this.queryVariables.id && !this.queryVariables.iid;
@@ -74,6 +114,58 @@ export default {
error() {
this.$emit('error', i18n.fetchError);
},
+ result() {
+ if (this.hasNextPage) {
+ this.fetchMoreNotes();
+ }
+ },
+ },
+ },
+ methods: {
+ isSystemNote(note) {
+ return note.notes.nodes[0].system;
+ },
+ updateSortingOrderIfApplicable() {
+ // when the sort order is DESC in local storage and there is only a single page, call
+ // changeSortOrder manually
+ if (
+ this.changeNotesSortOrderAfterLoading &&
+ this.perPage === DEFAULT_PAGE_SIZE_NOTES &&
+ !this.hasNextPage
+ ) {
+ this.changeNotesSortOrder(DESC);
+ }
+ },
+ updateInitialSortedOrder(direction) {
+ this.sortOrder = direction;
+ // when the direction is reverse , we need to load all since the sorting is on the frontend
+ if (direction === DESC) {
+ this.changeNotesSortOrderAfterLoading = true;
+ }
+ },
+ changeNotesSortOrder(direction) {
+ this.sortOrder = direction;
+ this.notesArray = [...this.notesArray].reverse();
+ this.changeNotesSortOrderAfterLoading = false;
+ },
+ async fetchMoreNotes() {
+ this.isLoadingMore = true;
+ // copied from discussions batch logic - every fetchMore call has a higher
+ // amount of page size than the previous one with the limit being 100
+ this.perPage = Math.min(Math.round(this.perPage * 1.5), 100);
+ await this.$apollo.queries.workItemNotes
+ .fetchMore({
+ variables: {
+ ...this.queryVariables,
+ pageSize: this.perPage,
+ after: this.pageInfo?.endCursor,
+ },
+ })
+ .catch((error) => this.$emit('error', error.message));
+ this.isLoadingMore = false;
+ if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) {
+ this.changeNotesSortOrder(this.sortOrder);
+ }
},
},
};
@@ -81,8 +173,18 @@ export default {
<template>
<div class="gl-border-t gl-mt-5">
- <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
- <div v-if="areNotesLoading" class="gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
+ <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
+ <activity-filter
+ class="gl-min-h-5 gl-pb-3"
+ :loading="disableActivityFilter"
+ :sort-order="sortOrder"
+ :work-item-type="workItemType"
+ @changeSortOrder="changeNotesSortOrder"
+ @updateSavedSortOrder="updateInitialSortedOrder"
+ />
+ </div>
+ <div v-if="showInitialLoader" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
@@ -94,16 +196,40 @@ export default {
<rect width="500" x="45" y="15" height="10" rx="4" />
</gl-skeleton-loader>
</div>
- <div v-else class="issuable-discussion gl-mb-5 work-item-notes">
- <template v-if="notes && notes.length">
- <ul class="notes main-notes-list timeline">
- <system-note
- v-for="note in notes"
- :key="note.notes.nodes[0].id"
- :note="note.notes.nodes[0]"
+ <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
+ <template v-if="showTimeline">
+ <ul class="notes main-notes-list timeline gl-clearfix!">
+ <template v-for="note in notesArray">
+ <system-note
+ v-if="isSystemNote(note)"
+ :key="note.notes.nodes[0].id"
+ :note="note.notes.nodes[0]"
+ />
+ <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" />
+ </template>
+
+ <work-item-comment-form
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :work-item-id="workItemId"
+ :fetch-by-iid="fetchByIid"
+ @error="$emit('error', $event)"
/>
</ul>
</template>
+
+ <template v-if="showLoadingMoreSkeleton">
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <circle cx="20" cy="20" r="16" />
+ <rect width="500" x="45" y="15" height="10" rx="4" />
+ </gl-skeleton-loader>
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
index 32678e29fa4..96a6493357c 100644
--- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -33,11 +33,6 @@ export default {
},
computed: {
iconName() {
- // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865
- // is merged and updated in GitLab repo.
- if (this.workItemIconName === 'issue-type-keyresult') {
- return 'issue-type-key-result';
- }
return (
this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue'
);
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 3cd17f4d360..81f9bf04bc8 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -31,7 +31,12 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE';
export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT';
+export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident';
export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue';
+export const WORK_ITEM_TYPE_VALUE_TASK = 'Task';
+export const WORK_ITEM_TYPE_VALUE_TEST_CASE = 'Test case';
+export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements';
+export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result';
export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective';
export const i18n = {
@@ -41,7 +46,7 @@ export const i18n = {
),
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
confidentialTooltip: s__(
- 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
+ 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this %{workItemType}.',
),
};
@@ -73,12 +78,19 @@ export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{work
export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__(
'WorkItem|Search existing %{workItemType}s',
);
+export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__(
+ 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access',
+);
+export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__(
+ 'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.',
+);
-export const sprintfWorkItem = (msg, workItemTypeArg) => {
+export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
return capitalizeFirstCharacter(
sprintf(msg, {
workItemType: workItemType.toLocaleLowerCase(),
+ parentWorkItemType: parentWorkItemType.toLocaleLowerCase(),
}),
);
};
@@ -96,30 +108,37 @@ export const WORK_ITEMS_TYPE_MAP = {
[WORK_ITEM_TYPE_ENUM_INCIDENT]: {
icon: `issue-type-incident`,
name: s__('WorkItem|Incident'),
+ value: WORK_ITEM_TYPE_VALUE_INCIDENT,
},
[WORK_ITEM_TYPE_ENUM_ISSUE]: {
icon: `issue-type-issue`,
name: s__('WorkItem|Issue'),
+ value: WORK_ITEM_TYPE_VALUE_ISSUE,
},
[WORK_ITEM_TYPE_ENUM_TASK]: {
icon: `issue-type-task`,
name: s__('WorkItem|Task'),
+ value: WORK_ITEM_TYPE_VALUE_TASK,
},
[WORK_ITEM_TYPE_ENUM_TEST_CASE]: {
icon: `issue-type-test-case`,
name: s__('WorkItem|Test case'),
+ value: WORK_ITEM_TYPE_VALUE_TEST_CASE,
},
[WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: {
icon: `issue-type-requirements`,
name: s__('WorkItem|Requirements'),
+ value: WORK_ITEM_TYPE_VALUE_REQUIREMENTS,
},
[WORK_ITEM_TYPE_ENUM_OBJECTIVE]: {
icon: `issue-type-objective`,
name: s__('WorkItem|Objective'),
+ value: WORK_ITEM_TYPE_VALUE_OBJECTIVE,
},
[WORK_ITEM_TYPE_ENUM_KEY_RESULT]: {
- icon: `issue-type-issue`,
+ icon: `issue-type-keyresult`,
name: s__('WorkItem|Key Result'),
+ value: WORK_ITEM_TYPE_VALUE_KEY_RESULT,
},
};
@@ -141,7 +160,7 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = {
Task: 'issue-type-task',
Objective: 'issue-type-objective',
// eslint-disable-next-line @gitlab/require-i18n-strings
- 'Key Result': 'issue-type-key-result',
+ 'Key Result': 'issue-type-keyresult',
};
export const FORM_TYPES = {
@@ -154,4 +173,6 @@ export const FORM_TYPES = {
};
export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
-export const DEFAULT_PAGE_SIZE_NOTES = 100;
+export const DEFAULT_PAGE_SIZE_NOTES = 30;
+
+export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item';
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
new file mode 100644
index 00000000000..6a7afd7bd5b
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
@@ -0,0 +1,5 @@
+mutation createWorkItemNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
index 3a23db3886a..fce10f6f2a6 100644
--- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -11,6 +11,7 @@ query projectWorkItems(
id
title
state
+ confidential
}
}
}
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 6a81cc230b1..3ee263c149d 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -12,6 +12,7 @@ fragment WorkItem on WorkItem {
project {
id
fullPath
+ archived
}
workItemType {
id
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index 7fcf622cdb2..7d7bb9c7fc5 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -3,6 +3,7 @@ query workItemLinksQuery($id: WorkItemID!) {
id
workItemType {
id
+ name
}
title
userPermissions {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index baefcdaea93..b7813ca4dc6 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -19,7 +19,6 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
... on WorkItemWidgetLabels {
type
- allowsScopedLabels
labels {
nodes {
...Label
diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql
index 62ced6bdfea..5215ea10918 100644
--- a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql
@@ -1,12 +1,16 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
-fragment Discussion on Note {
+fragment WorkItemNote on Note {
id
- body
bodyHtml
+ system
+ internal
systemNoteIconName
createdAt
author {
...User
}
+ userPermissions {
+ adminNote
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
index 9439f22f955..9ea9cecc81a 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/discussion.fragment.graphql"
+#import "~/work_items/graphql/work_item_note.fragment.graphql"
query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
workItem(id: $id) {
@@ -8,7 +8,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
widgets {
... on WorkItemWidgetNotes {
type
- discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) {
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
pageInfo {
...PageInfo
}
@@ -16,7 +16,7 @@ query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
id
notes {
nodes {
- ...Discussion
+ ...WorkItemNote
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
index 3e0960f3f54..f401aa5595e 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/discussion.fragment.graphql"
+#import "~/work_items/graphql/work_item_note.fragment.graphql"
query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) {
workspace: project(fullPath: $fullPath) {
@@ -11,7 +11,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize
widgets {
... on WorkItemWidgetNotes {
type
- discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) {
+ discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
pageInfo {
...PageInfo
}
@@ -19,7 +19,7 @@ query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize
id
notes {
nodes {
- ...Discussion
+ ...WorkItemNote
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
index 006ca29e01c..b4fb83b24c2 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql
@@ -1,6 +1,6 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
-#import "./work_item_metadata_widgets.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
query workItemTreeQuery($id: WorkItemID!) {
workItem(id: $id) {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index cf3374e1737..d2a2d7927d3 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -1,7 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
-#import "./work_item_metadata_widgets.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index a056fde6928..98b59449af7 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,8 @@ export const initWorkItemsRoot = () => {
fullPath,
hasIssueWeightsFeature,
issuesListPath,
+ registerPath,
+ signInPath,
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
@@ -26,6 +28,8 @@ export const initWorkItemsRoot = () => {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
+ registerPath,
+ signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
},