diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-09 15:07:31 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-09 15:07:31 +0300 |
commit | 1935f3e81b99c00697bf0b4d6a44d64068b34745 (patch) | |
tree | e2c42218945d0ae19c4566e844d4707513cc2fd6 /app/assets/javascripts/work_items | |
parent | a352bc8e72b948a834b0569d0d4288e95a9c529e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/work_items')
9 files changed, 356 insertions, 7 deletions
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..5c843d84ae0 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue @@ -0,0 +1,223 @@ +<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 } 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]); + } + + 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 1855a0a37f2..130442476a3 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -633,7 +633,7 @@ export default { @addWorkItemChild="addChild" @removeChild="removeChild" /> - <template v-if="workItemsMvc2Enabled"> + <template v-if="workItemsMvcEnabled"> <work-item-notes v-if="workItemNotes" :work-item-id="workItem.id" 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_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 b8019dcad1a..8bd4e7cdb73 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -6,6 +6,7 @@ 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 WorkItemCommentForm from './work_item_comment_form.vue'; export default { i18n: { @@ -17,29 +18,34 @@ export default { height: 40, }, components: { - SystemNote, GlSkeletonLoader, ActivityFilter, GlIntersectionObserver, + SystemNote, + WorkItemCommentForm, }, props: { workItemId: { type: String, required: true, }, - fetchByIid: { - type: Boolean, - required: false, - default: false, - }, queryVariables: { type: Object, required: true, }, + fullPath: { + type: String, + required: true, + }, workItemType: { type: String, required: true, }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -57,6 +63,9 @@ export default { pageInfo() { return this.workItemNotes?.pageInfo; }, + avatarUrl() { + return window.gon.current_user_avatar_url; + }, hasNextPage() { return this.pageInfo?.hasNextPage; }, @@ -196,6 +205,12 @@ export default { :note="note.notes.nodes[0]" :data-testid="note.notes.nodes[0].id" /> + <work-item-comment-form + :query-variables="queryVariables" + :full-path="fullPath" + :work-item-id="workItemId" + @error="$emit('error', $event)" + /> </ul> </template> 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/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/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), }, |