diff options
Diffstat (limited to 'app/assets/javascripts/work_items')
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), }, |