diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-15 09:07:50 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-15 09:07:50 +0300 |
commit | 3c5195204b69df0bc69a0c98c7d61d258dc39866 (patch) | |
tree | 10af5c42e5ff538069565954476925709d94b679 /app/assets/javascripts/work_items | |
parent | c40b7517717b0d23893a92527819fd05c2531b93 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/work_items')
10 files changed, 439 insertions, 1 deletions
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue new file mode 100644 index 00000000000..f91a0d01581 --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -0,0 +1,229 @@ +<script> +/** + * Common component to render a system note, icon and user information. + * + * This component need not be used with any store neither has any vuex dependency + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * bodyHtml: String, + * systemNoteIconName: String + * }" + * /> + */ +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import $ from 'jquery'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import '~/behaviors/markdown/render_gfm'; +import axios from '~/lib/utils/axios_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import NoteHeader from '~/notes/components/note_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + i18n: { + deleteButtonLabel: __('Remove description history'), + }, + name: 'SystemNote', + components: { + GlIcon, + NoteHeader, + TimelineEntryItem, + GlButton, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + lines: [], + showLines: false, + loadingDiff: false, + isLoadingDescriptionVersion: false, + }; + }, + computed: { + targetNoteHash() { + return getLocationHash(); + }, + descriptionVersions() { + return []; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.bodyHtml).unwrap().html(); + }, + hasMoreCommits() { + return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT; + }, + descriptionVersion() { + return this.descriptionVersions[this.note.description_version_id]; + }, + }, + mounted() { + $(this.$refs['gfm-content']).renderGFM(); + }, + methods: { + fetchDescriptionVersion() {}, + softDeleteDescriptionVersion() {}, + + async toggleDiff() { + this.showLines = !this.showLines; + + if (!this.lines.length) { + this.loadingDiff = true; + const { data } = await axios.get(this.note.outdated_line_change_path); + + this.lines = data.map((l) => ({ + ...l, + rich_text: l.rich_text.replace(/^[+ -]/, ''), + })); + this.loadingDiff = false; + } + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, + userColorSchemeClass: window.gon.user_color_scheme, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" + class="note system-note note-wrapper" + > + <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + :author="note.author" + :created-at="note.createdAt" + :note-id="note.id" + :is-system-note="true" + > + <span ref="gfm-content" v-safe-html="actionTextHtml"></span> + <template + v-if="canSeeDescriptionVersion || note.outdated_line_change_path" + #extra-controls + > + · + <gl-button + v-if="canSeeDescriptionVersion" + variant="link" + :icon="descriptionVersionToggleIcon" + data-testid="compare-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDescriptionVersion" + >{{ __('Compare with previous version') }}</gl-button + > + <gl-button + v-if="note.outdated_line_change_path" + :icon="showLines ? 'chevron-up' : 'chevron-down'" + variant="link" + data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDiff" + > + {{ __('Compare changes') }} + </gl-button> + </template> + </note-header> + </div> + <div class="note-body"> + <div + v-safe-html="note.bodyHtml" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" + class="note-text md" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> + <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> + <span>{{ __('Toggle commit list') }}</span> + </div> + </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loader /> + </pre> + <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre> + <gl-button + v-if="displayDeleteButton" + v-gl-tooltip + :title="$options.i18n.deleteButtonLabel" + :aria-label="$options.i18n.deleteButtonLabel" + variant="default" + category="tertiary" + icon="remove" + class="delete-description-history" + data-testid="delete-description-version-button" + @click="deleteDescriptionVersion" + /> + </div> + <div + v-if="lines.length && showLines" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + > + <table + :class="$options.userColorSchemeClass" + class="code js-syntax-highlight" + data-testid="outdated-lines" + > + <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> + <td + :class="line.type" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" + > + {{ line.old_line }} + </td> + <td + :class="line.type" + class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.new_line }} + </td> + <td + :class="line.type" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" + v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" + ></td> + </tr> + </table> + </div> + <div v-else-if="showLines" class="mt-4"> + <gl-skeleton-loader /> + </div> + </div> + </div> + </timeline-entry-item> +</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 e0ebc714dbb..4c5c5eb9de9 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -30,6 +30,7 @@ import { WIDGET_TYPE_ITERATION, WORK_ITEM_TYPE_VALUE_ISSUE, WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_NOTES, } from '../constants'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; @@ -49,6 +50,7 @@ import WorkItemDueDate from './work_item_due_date.vue'; 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'; export default { i18n, @@ -75,6 +77,7 @@ export default { WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), WorkItemMilestone, WorkItemTree, + WorkItemNotes, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -258,6 +261,9 @@ export default { workItemMilestone() { return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); }, + workItemNotes() { + return this.isWidgetPresent(WIDGET_TYPE_NOTES); + }, fetchByIid() { return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, @@ -428,7 +434,7 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> @@ -589,6 +595,17 @@ export default { @addWorkItemChild="addChild" @removeChild="removeChild" /> + <template v-if="workItemsMvc2Enabled"> + <work-item-notes + v-if="workItemNotes" + :work-item-id="workItem.id" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + class="gl-pt-5" + @error="updateError = $event" + /> + </template> <gl-empty-state v-if="error" :title="$options.i18n.fetchErrorTitle" 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 a6ef8886d71..e8726814aaf 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 @@ -139,6 +139,7 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" + scrollable @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue new file mode 100644 index 00000000000..91e90589a93 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -0,0 +1,109 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SystemNote from '~/work_items/components/notes/system_note.vue'; +import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +export default { + i18n: { + ACTIVITY_LABEL: s__('WorkItem|Activity'), + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + components: { + SystemNote, + GlSkeletonLoader, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + areNotesLoading() { + return this.$apollo.queries.workItemNotes.loading; + }, + notes() { + return this.workItemNotes?.nodes; + }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, + }, + apollo: { + workItemNotes: { + query() { + return getWorkItemNotesQuery(this.fetchByIid); + }, + context: { + isSingleRequest: true, + }, + variables() { + return { + ...this.queryVariables, + pageSize: DEFAULT_PAGE_SIZE_NOTES, + }; + }, + update(data) { + const workItemWidgets = this.fetchByIid + ? data.workspace?.workItems?.nodes[0]?.widgets + : data.workItem?.widgets; + return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || []; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, +}; +</script> + +<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"> + <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> + </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]" + /> + </ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 368bb6a85a4..791f06a612e 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -19,6 +19,7 @@ export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; export const WIDGET_TYPE_ITERATION = 'ITERATION'; +export const WIDGET_TYPE_NOTES = 'NOTES'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; @@ -145,3 +146,4 @@ export const FORM_TYPES = { }; export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; +export const DEFAULT_PAGE_SIZE_NOTES = 100; diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql new file mode 100644 index 00000000000..62ced6bdfea --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment Discussion on Note { + id + body + bodyHtml + systemNoteIconName + createdAt + author { + ...User + } +} 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 new file mode 100644 index 00000000000..9439f22f955 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { + workItem(id: $id) { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } +} 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 new file mode 100644 index 00000000000..3e0960f3f54 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql @@ -0,0 +1,32 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } + } + } +} 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 9b802a8e8fc..a8d4392c1a5 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 @@ -79,4 +79,7 @@ fragment WorkItemWidgets on WorkItemWidget { ...MilestoneFragment } } + ... on WorkItemWidgetNotes { + type + } } diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 17f9c882c2d..e58fd19ea31 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,6 +1,12 @@ import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; } + +export function getWorkItemNotesQuery(isFetchedByIid) { + return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; +} |