diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-03 06:08:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-03 06:08:30 +0300 |
commit | 00c0098252ac7b303061175ff4c3e002c07407d2 (patch) | |
tree | d3882967cb0737c15ed2916f8bcc3ee9a7920fef /app/assets/javascripts/comment_templates | |
parent | c8f8f2a4966ab5ccbee8477544987ef9bdbcd55f (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/comment_templates')
13 files changed, 623 insertions, 0 deletions
diff --git a/app/assets/javascripts/comment_templates/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue new file mode 100644 index 00000000000..9e0d2cc73ec --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/app.vue @@ -0,0 +1,23 @@ +<script> +export default {}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-lg-4"> + <h4 class="gl-mt-0"> + {{ __('Comment templates') }} + </h4> + <p> + {{ + __( + 'Comment templates can be used when creating comments inside issues, merge requests, and epics.', + ) + }} + </p> + </div> + <div class="col-lg-8"> + <keep-alive><router-view /></keep-alive> + </div> + </div> +</template> diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue new file mode 100644 index 00000000000..47efccc3d0c --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/form.vue @@ -0,0 +1,182 @@ +<script> +import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { logError } from '~/lib/logger'; +import { __ } from '~/locale'; +import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql'; +import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql'; + +export default { + components: { + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlAlert, + MarkdownField, + }, + props: { + id: { + type: String, + required: false, + default: null, + }, + name: { + type: String, + required: false, + default: '', + }, + content: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + errors: [], + saving: false, + showValidation: false, + updateCommentTemplate: { + name: this.name, + content: this.content, + }, + }; + }, + computed: { + isNameValid() { + if (this.showValidation) return Boolean(this.updateCommentTemplate.name); + + return true; + }, + isContentValid() { + if (this.showValidation) return Boolean(this.updateCommentTemplate.content); + + return true; + }, + isValid() { + return this.isNameValid && this.isContentValid; + }, + }, + methods: { + onSubmit() { + this.showValidation = true; + + if (!this.isValid) return; + + this.errors = []; + this.saving = true; + + this.$apollo + .mutate({ + mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation, + variables: { + id: this.id, + name: this.updateCommentTemplate.name, + content: this.updateCommentTemplate.content, + }, + update: (store, { data: { savedReplyMutation } }) => { + if (savedReplyMutation.errors.length) { + this.errors = savedReplyMutation.errors.map((e) => e); + } else { + this.$emit('saved'); + this.updateCommentTemplate = { name: '', content: '' }; + this.showValidation = false; + } + }, + }) + .catch((error) => { + const errors = error.graphQLErrors; + + if (errors?.length) { + this.errors = errors.map((e) => e.message); + } else { + // Let's be sure to log the original error so it isn't just swallowed. + // Also, we don't want to translate console messages. + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Unexpected error while saving reply', error); + + this.errors = [__('An unexpected error occurred. Please try again.')]; + } + }) + .finally(() => { + this.saving = false; + }); + }, + }, + restrictedToolbarItems: ['full-screen'], + markdownDocsPath: helpPagePath('user/markdown'), +}; +</script> + +<template> + <gl-form + class="new-note common-note-form gl-mb-6" + data-testid="comment-template-form" + @submit.prevent="onSubmit" + > + <gl-alert + v-for="error in errors" + :key="error" + variant="danger" + class="gl-mb-3" + :dismissible="false" + > + {{ error }} + </gl-alert> + <gl-form-group + :label="__('Name')" + :state="isNameValid" + :invalid-feedback="__('Please enter a name for the comment template.')" + data-testid="comment-template-name-form-group" + > + <gl-form-input + v-model="updateCommentTemplate.name" + :placeholder="__('Enter a name for your comment template')" + data-testid="comment-template-name-input" + /> + </gl-form-group> + <gl-form-group + :label="__('Content')" + :state="isContentValid" + :invalid-feedback="__('Please enter the comment template content.')" + data-testid="comment-template-content-form-group" + > + <markdown-field + :enable-preview="false" + :is-submitting="saving" + :add-spacing-classes="false" + :textarea-value="updateCommentTemplate.content" + :markdown-docs-path="$options.markdownDocsPath" + :restricted-tool-bar-items="$options.restrictedToolbarItems" + :force-autosize="false" + class="js-no-autosize gl-border-gray-400!" + > + <template #textarea> + <textarea + v-model="updateCommentTemplate.content" + dir="auto" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-supports-quick-actions="false" + :aria-label="__('Content')" + :placeholder="__('Write comment template content hereā¦')" + data-testid="comment-template-content-input" + @keydown.meta.enter="onSubmit" + @keydown.ctrl.enter="onSubmit" + ></textarea> + </template> + </markdown-field> + </gl-form-group> + <gl-button + variant="confirm" + class="gl-mr-3 js-no-auto-disable" + type="submit" + :loading="saving" + data-testid="comment-template-form-submit-btn" + > + {{ __('Save') }} + </gl-button> + <gl-button v-if="id" :to="{ path: '/' }">{{ __('Cancel') }}</gl-button> + </gl-form> +</template> diff --git a/app/assets/javascripts/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue new file mode 100644 index 00000000000..52bebfd050c --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/list.vue @@ -0,0 +1,67 @@ +<script> +import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import ListItem from './list_item.vue'; + +export default { + components: { + GlLoadingIcon, + GlKeysetPagination, + GlSprintf, + ListItem, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + savedReplies: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + count: { + type: Number, + required: true, + }, + }, + methods: { + prevPage() { + this.$emit('input', { + before: this.pageInfo.beforeCursor, + }); + }, + nextPage() { + this.$emit('input', { + after: this.pageInfo.endCursor, + }); + }, + }, +}; +</script> + +<template> + <div class="gl-border-t gl-pt-4"> + <gl-loading-icon v-if="loading" size="lg" /> + <template v-else> + <h5 class="gl-font-lg" data-testid="title"> + <gl-sprintf :message="__('My comment templates (%{count})')"> + <template #count>{{ count }}</template> + </gl-sprintf> + </h5> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <list-item v-for="template in savedReplies" :key="template.id" :template="template" /> + </ul> + <gl-keyset-pagination + v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage" + v-bind="pageInfo" + class="gl-mt-4" + @prev="prevPage" + @next="nextPage" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue new file mode 100644 index 00000000000..d763700db42 --- /dev/null +++ b/app/assets/javascripts/comment_templates/components/list_item.vue @@ -0,0 +1,116 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql'; + +export default { + components: { + GlDisclosureDropdown, + GlTooltip, + GlModal, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + template: { + type: Object, + required: true, + }, + }, + data() { + return { + isDeleting: false, + modalId: uniqueId('delete-comment-template-'), + toggleId: uniqueId('actions-toggle-'), + }; + }, + computed: { + id() { + return getIdFromGraphQLId(this.template.id); + }, + dropdownItems() { + return [ + { + text: __('Edit'), + action: () => this.$router.push({ name: 'edit', params: { id: this.id } }), + extraAttrs: { + 'data-testid': 'comment-template-edit-btn', + }, + }, + { + text: __('Delete'), + action: () => this.$refs['delete-modal'].show(), + extraAttrs: { + 'data-testid': 'comment-template-delete-btn', + class: 'gl-text-red-500!', + }, + }, + ]; + }, + }, + methods: { + onDelete() { + this.isDeleting = true; + + this.$apollo.mutate({ + mutation: deleteSavedReplyMutation, + variables: { + id: this.template.id, + }, + update: (cache) => { + const cacheId = cache.identify(this.template); + cache.evict({ id: cacheId }); + }, + }); + }, + }, + actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } }, + actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } }, +}; +</script> + +<template> + <li class="gl-pt-4 gl-pb-5 gl-border-b"> + <div class="gl-display-flex gl-align-items-center"> + <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6> + <div class="gl-ml-auto"> + <gl-disclosure-dropdown + :items="dropdownItems" + :toggle-id="toggleId" + icon="ellipsis_v" + no-caret + text-sr-only + placement="right" + :toggle-text="__('Comment template actions')" + :loading="isDeleting" + category="tertiary" + /> + <gl-tooltip :target="toggleId"> + {{ __('Comment template actions') }} + </gl-tooltip> + </div> + </div> + <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div> + <gl-modal + ref="delete-modal" + :title="__('Delete comment template')" + :action-primary="$options.actionPrimary" + :action-secondary="$options.actionSecondary" + :modal-id="modalId" + size="sm" + @primary="onDelete" + > + <gl-sprintf + :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')" + > + <template #name + ><strong>{{ template.name }}</strong></template + > + </gl-sprintf> + </gl-modal> + </li> +</template> diff --git a/app/assets/javascripts/comment_templates/index.js b/app/assets/javascripts/comment_templates/index.js new file mode 100644 index 00000000000..8cd763e7a9e --- /dev/null +++ b/app/assets/javascripts/comment_templates/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import routes from './routes'; +import App from './components/app.vue'; + +export const initCommentTemplates = () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const el = document.getElementById('js-comment-templates-root'); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + const router = new VueRouter({ + base: el.dataset.basePath, + mode: 'history', + routes, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + router, + apolloProvider, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/comment_templates/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue new file mode 100644 index 00000000000..343efdccefa --- /dev/null +++ b/app/assets/javascripts/comment_templates/pages/edit.vue @@ -0,0 +1,68 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_USERS_SAVED_REPLY } from '~/graphql_shared/constants'; +import CreateForm from '../components/form.vue'; +import getSavedReply from '../queries/get_saved_reply.query.graphql'; + +export default { + components: { + GlLoadingIcon, + CreateForm, + }, + apollo: { + savedReply: { + fetchPolicy: fetchPolicies.NETWORK_ONLY, + query: getSavedReply, + variables() { + return { + id: convertToGraphQLId(TYPE_USERS_SAVED_REPLY, this.$route.params.id), + }; + }, + update: (r) => r.currentUser.savedReply, + skip() { + return !this.$route.params.id; + }, + result({ + data: { + currentUser: { savedReply }, + }, + }) { + if (!savedReply) { + createAlert({ message: __('Unable to find comment template') }); + this.redirectToRoot(); + } + }, + }, + }, + data() { + return { + savedReply: null, + }; + }, + methods: { + redirectToRoot() { + this.$router.push({ path: '/' }); + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-mt-0 gl-font-lg"> + {{ __('Edit comment template') }} + </h5> + <gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" /> + <create-form + v-else-if="savedReply" + :id="savedReply.id" + :name="savedReply.name" + :content="savedReply.content" + @saved="redirectToRoot" + /> + </div> +</template> diff --git a/app/assets/javascripts/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue new file mode 100644 index 00000000000..72a94dafc58 --- /dev/null +++ b/app/assets/javascripts/comment_templates/pages/index.vue @@ -0,0 +1,67 @@ +<script> +import { fetchPolicies } from '~/lib/graphql'; +import CreateForm from '../components/form.vue'; +import savedRepliesQuery from '../queries/saved_replies.query.graphql'; +import List from '../components/list.vue'; + +export default { + apollo: { + savedReplies: { + fetchPolicy: fetchPolicies.NETWORK_ONLY, + query: savedRepliesQuery, + update: (r) => r.currentUser?.savedReplies?.nodes, + variables() { + return { + ...this.pagination, + }; + }, + result({ data }) { + const pageInfo = data.currentUser?.savedReplies?.pageInfo; + + this.count = data.currentUser?.savedReplies?.count; + + if (pageInfo) { + this.pageInfo = pageInfo; + } + }, + }, + }, + components: { + CreateForm, + List, + }, + data() { + return { + savedReplies: [], + count: 0, + pageInfo: {}, + pagination: {}, + }; + }, + methods: { + refetchSavedReplies() { + this.pagination = {}; + this.$apollo.queries.savedReplies.refetch(); + }, + changePage(pageInfo) { + this.pagination = pageInfo; + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-mt-0 gl-font-lg"> + {{ __('Add new comment template') }} + </h5> + <create-form @saved="refetchSavedReplies" /> + <list + :loading="$apollo.queries.savedReplies.loading" + :saved-replies="savedReplies" + :page-info="pageInfo" + :count="count" + @input="changePage" + /> + </div> +</template> diff --git a/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql new file mode 100644 index 00000000000..c4e632d0f16 --- /dev/null +++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql @@ -0,0 +1,10 @@ +mutation savedReplyCreate($name: String!, $content: String!) { + savedReplyMutation: savedReplyCreate(input: { name: $name, content: $content }) { + errors + savedReply { + id + name + content + } + } +} diff --git a/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql new file mode 100644 index 00000000000..76571ba628c --- /dev/null +++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteSavedReply($id: UsersSavedReplyID!) { + savedReplyDestroy(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql new file mode 100644 index 00000000000..66f5f43af49 --- /dev/null +++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql @@ -0,0 +1,10 @@ +query getSavedReply($id: UsersSavedReplyID!) { + currentUser { + id + savedReply(id: $id) { + id + name + content + } + } +} diff --git a/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql new file mode 100644 index 00000000000..d8e76b5e2a8 --- /dev/null +++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql @@ -0,0 +1,19 @@ +query savedReplies($after: String = "", $before: String = "") { + currentUser { + id + savedReplies(after: $after, before: $before) { + nodes { + id + name + content + } + count + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } + } +} diff --git a/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql new file mode 100644 index 00000000000..14a47d7bc9c --- /dev/null +++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql @@ -0,0 +1,10 @@ +mutation savedReplyUpdate($id: UsersSavedReplyID!, $name: String!, $content: String!) { + savedReplyMutation: savedReplyUpdate(input: { id: $id, name: $name, content: $content }) { + errors + savedReply { + id + name + content + } + } +} diff --git a/app/assets/javascripts/comment_templates/routes.js b/app/assets/javascripts/comment_templates/routes.js new file mode 100644 index 00000000000..7687c6f335a --- /dev/null +++ b/app/assets/javascripts/comment_templates/routes.js @@ -0,0 +1,15 @@ +import IndexComponent from './pages/index.vue'; + +import EditComponent from './pages/edit.vue'; + +export default [ + { + path: '/', + component: IndexComponent, + }, + { + name: 'edit', + path: '/:id', + component: EditComponent, + }, +]; |