diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-10 09:09:29 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-10 09:09:29 +0300 |
commit | 76c4dd062c4eeb853866ef8b6451c59f9e24221c (patch) | |
tree | faf481c7b2f6da10c13234ad4e4a6ca1cb5a1030 /app/assets/javascripts | |
parent | c2858333644a2bca10fd556a5a298b4a1aaedca2 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
19 files changed, 743 insertions, 104 deletions
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index a6faa04b440..689f2f0898e 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -399,6 +399,12 @@ export const ISSUABLE_CHANGE_LABEL = { defaultKeys: ['l'], }; +export const ISSUABLE_COPY_REF = { + id: 'issuables.copyIssuableRef', + description: __('Copy reference'), + defaultKeys: ['c r'], // eslint-disable-line @gitlab/require-i18n-strings +}; + export const ISSUE_MR_CHANGE_ASSIGNEE = { id: 'issuesMRs.changeAssignee', description: __('Change assignee'), @@ -606,7 +612,12 @@ const PROJECT_FILES_SHORTCUTS_GROUP = { const ISSUABLE_SHORTCUTS_GROUP = { id: 'issuables', name: __('Epics, issues, and merge requests'), - keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL], + keybindings: [ + ISSUABLE_COMMENT_OR_REPLY, + ISSUABLE_EDIT_DESCRIPTION, + ISSUABLE_CHANGE_LABEL, + ISSUABLE_COPY_REF, + ], }; const ISSUE_MR_SHORTCUTS_GROUP = { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 0c882ff9ea2..b0e515ac19d 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -14,6 +14,7 @@ import { ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, MR_COPY_SOURCE_BRANCH_NAME, + ISSUABLE_COPY_REF, } from './keybindings'; import Shortcuts from './shortcuts'; @@ -21,15 +22,24 @@ export default class ShortcutsIssuable extends Shortcuts { constructor() { super(); - this.inMemoryButton = document.createElement('button'); - this.clipboardInstance = new ClipboardJS(this.inMemoryButton); - this.clipboardInstance.on('success', () => { + this.branchInMemoryButton = document.createElement('button'); + this.branchClipboardInstance = new ClipboardJS(this.branchInMemoryButton); + this.branchClipboardInstance.on('success', () => { toast(s__('GlobalShortcuts|Copied source branch name to clipboard.')); }); - this.clipboardInstance.on('error', () => { + this.branchClipboardInstance.on('error', () => { toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.')); }); + this.refInMemoryButton = document.createElement('button'); + this.refClipboardInstance = new ClipboardJS(this.refInMemoryButton); + this.refClipboardInstance.on('success', () => { + toast(s__('GlobalShortcuts|Copied reference to clipboard.')); + }); + this.refClipboardInstance.on('error', () => { + toast(s__('GlobalShortcuts|Unable to copy the reference at this time.')); + }); + this.bindCommands([ [ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')], [ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')], @@ -37,6 +47,7 @@ export default class ShortcutsIssuable extends Shortcuts { [ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText], [ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue], [MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()], + [ISSUABLE_COPY_REF, () => this.copyIssuableRef()], ]); /** @@ -163,9 +174,20 @@ export default class ShortcutsIssuable extends Shortcuts { const branchName = button?.dataset.clipboardText; if (branchName) { - this.inMemoryButton.dataset.clipboardText = branchName; + this.branchInMemoryButton.dataset.clipboardText = branchName; + + this.branchInMemoryButton.dispatchEvent(new CustomEvent('click')); + } + } + + async copyIssuableRef() { + const refButton = document.querySelector('.js-copy-reference'); + const copiedRef = refButton?.dataset.clipboardText; + + if (copiedRef) { + this.refInMemoryButton.dataset.clipboardText = copiedRef; - this.inMemoryButton.dispatchEvent(new CustomEvent('click')); + this.refInMemoryButton.dispatchEvent(new CustomEvent('click')); } } } diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue index 5468e42b6b3..86c0f34215e 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -25,6 +25,7 @@ import { AWS_TOKEN_CONSTANTS, ADD_CI_VARIABLE_MODAL_ID, AWS_TIP_DISMISSED_COOKIE_NAME, + AWS_TIP_TITLE, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, defaultVariableState, @@ -62,10 +63,6 @@ export default { }, mixins: [glFeatureFlagsMixin(), trackingMixin], inject: [ - 'awsLogoSvgPath', - 'awsTipCommandsLink', - 'awsTipDeployLink', - 'awsTipLearnLink', 'containsVariableReferenceLink', 'environmentScopeLink', 'isProtectedByDefault', @@ -295,6 +292,7 @@ export default { }, }, i18n: { + awsTipTitle: AWS_TIP_TITLE, awsTipMessage: AWS_TIP_MESSAGE, containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, defaultScope: allEnvironments.text, @@ -305,6 +303,9 @@ export default { flagLink: helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui', }), + oidcLink: helpPagePath('ci/cloud_services/index', { + anchor: 'oidc-authorization-with-your-cloud-provider', + }), modalId: ADD_CI_VARIABLE_MODAL_ID, tokens: awsTokens, tokenList: awsTokenList, @@ -322,6 +323,23 @@ export default { @hidden="resetModalHandler" @shown="onShow" > + <gl-collapse :visible="isTipVisible"> + <gl-alert + :title="$options.i18n.awsTipTitle" + variant="warning" + class="gl-mb-5" + data-testid="aws-guidance-tip" + @dismiss="dismissTip" + > + <gl-sprintf :message="$options.i18n.awsTipMessage"> + <template #link="{ content }"> + <gl-link :href="$options.oidcLink"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </gl-collapse> <form> <gl-form-combobox v-model="variable.key" @@ -468,45 +486,7 @@ export default { </gl-form-checkbox> </gl-form-group> </form> - <gl-collapse :visible="isTipVisible"> - <gl-alert - :title="__('Deploying to AWS is easy with GitLab')" - variant="tip" - data-testid="aws-guidance-tip" - @dismiss="dismissTip" - > - <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3"> - <div> - <p> - <gl-sprintf :message="$options.i18n.awsTipMessage"> - <template #deployLink="{ content }"> - <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> - </template> - <template #commandsLink="{ content }"> - <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p> - <gl-button - :href="awsTipLearnLink" - target="_blank" - category="secondary" - variant="confirm" - class="gl-overflow-wrap-break" - >{{ __('Learn more about deploying to AWS') }}</gl-button - > - </p> - </div> - <img - class="gl-mt-3" - :alt="__('Amazon Web Services Logo')" - :src="awsLogoSvgPath" - height="32" - /> - </div> - </gl-alert> - </gl-collapse> + <gl-alert v-if="containsVariableReference" :title="__('Value might contain a variable reference')" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index cebc0c0b388..825b39e0cf9 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -40,8 +40,9 @@ export const instanceString = 'Instance'; export const projectString = 'Project'; export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; -export const AWS_TIP_MESSAGE = __( - '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', +export const AWS_TIP_TITLE = s__('CiVariable|Use OIDC to securely connect to cloud services'); +export const AWS_TIP_MESSAGE = s__( + 'CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}', ); export const EVENT_LABEL = 'ci_variable_modal'; diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js index e47b41ceae5..9342f57f2d8 100644 --- a/app/assets/javascripts/ci/ci_variable_list/index.js +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -10,10 +10,6 @@ import { generateCacheConfig, resolvers } from './graphql/settings'; const mountCiVariableListApp = (containerEl) => { const { - awsLogoSvgPath, - awsTipCommandsLink, - awsTipDeployLink, - awsTipLearnLink, containsVariableReferenceLink, endpoint, environmentScopeLink, @@ -57,10 +53,6 @@ const mountCiVariableListApp = (containerEl) => { el: containerEl, apolloProvider, provide: { - awsLogoSvgPath, - awsTipCommandsLink, - awsTipDeployLink, - awsTipLearnLink, containsVariableReferenceLink, endpoint, environmentScopeLink, diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index a1b264cfe54..0871d543d46 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -1,13 +1,5 @@ <script> -import { - GlAlert, - GlAvatar, - GlAvatarLink, - GlBadge, - GlButton, - GlTable, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlBadge, GlButton, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { thWidthPercent } from '~/lib/utils/table_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -25,7 +17,6 @@ export default { }, components: { ClipboardButton, - GlAlert, GlAvatar, GlAvatarLink, GlBadge, @@ -53,28 +44,32 @@ export default { { key: 'token', label: s__('Pipelines|Token'), - thClass: thWidthPercent(70), + thClass: thWidthPercent(60), + tdClass: 'gl-vertical-align-middle!', }, { key: 'description', label: s__('Pipelines|Description'), - thClass: thWidthPercent(15), + thClass: thWidthPercent(20), + tdClass: 'gl-vertical-align-middle!', }, { key: 'owner', label: s__('Pipelines|Owner'), thClass: thWidthPercent(5), + tdClass: 'gl-vertical-align-middle!', }, { key: 'lastUsed', label: s__('Pipelines|Last Used'), - thClass: thWidthPercent(5), + thClass: thWidthPercent(10), + tdClass: 'gl-vertical-align-middle!', }, { key: 'actions', - label: '', + label: __('Actions'), tdClass: 'gl-text-right gl-white-space-nowrap', - thClass: thWidthPercent(5), + thClass: `gl-text-right ${thWidthPercent(5)}`, }, ], computed: { @@ -88,9 +83,22 @@ export default { return '*'.repeat(47); }, }, + mounted() { + const revealButton = document.querySelector('[data-testid="reveal-hide-values-button"]'); + if (revealButton) { + if (this.triggers.length === 0) { + revealButton.style.display = 'none'; + } + + revealButton.addEventListener('click', () => { + this.toggleHiddenState(revealButton); + }); + } + }, methods: { - toggleHiddenState() { + toggleHiddenState(element) { this.areValuesHidden = !this.areValuesHidden; + element.innerText = this.valuesButtonText; }, }, }; @@ -102,7 +110,8 @@ export default { v-if="hasTriggers" :fields="$options.fields" :items="triggers" - class="triggers-list" + class="triggers-list gl-mb-0" + stacked="md" responsive > <template #cell(token)="{ item }"> @@ -116,8 +125,8 @@ export default { :title="$options.i18n.copyTrigger" css-class="gl-border-none gl-py-0 gl-px-2" /> - <div class="gl-display-inline-block gl-ml-3"> - <gl-badge v-if="!item.canAccessProject" variant="danger"> + <div v-if="!item.canAccessProject" class="gl-display-inline-block gl-ml-3"> + <gl-badge variant="danger"> <span v-gl-tooltip.viewport boundary="viewport" @@ -132,7 +141,7 @@ export default { :title="item.description" truncate-target="child" placement="top" - class="gl-max-w-15 gl-display-flex" + class="gl-max-w-15 gl-display-inline-flex" > <div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div> </tooltip-on-truncate> @@ -157,6 +166,7 @@ export default { :title="$options.i18n.editButton" :aria-label="$options.i18n.editButton" icon="pencil" + category="tertiary" data-testid="edit-btn" :href="item.editProjectTriggerPath" /> @@ -164,32 +174,24 @@ export default { :title="$options.i18n.revokeButton" :aria-label="$options.i18n.revokeButton" icon="remove" + category="tertiary" :data-confirm="$options.i18n.revokeButtonConfirm" data-method="delete" data-confirm-btn-variant="danger" rel="nofollow" - class="gl-ml-3" data-testid="trigger_revoke_button" data-qa-selector="trigger_revoke_button" :href="item.projectTriggerPath" /> </template> </gl-table> - <gl-alert + <div v-else - variant="warning" - :dismissible="false" - :show-icon="false" + class="gl-new-card-empty gl-px-5 gl-py-4" data-testid="no_triggers_content" data-qa-selector="no_triggers_content" > {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} - </gl-alert> - <gl-button - v-if="hasTriggers" - data-testid="reveal-hide-values-button" - @click="toggleHiddenState" - >{{ valuesButtonText }}</gl-button - > + </div> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index d05311cb1db..8177720b172 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -374,6 +374,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-dropdown-item :data-clipboard-text="issuableReference" + button-class="js-copy-reference" data-testid="copy-reference" @click="copyReference" >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item @@ -486,6 +487,7 @@ export default { <template v-if="isMrSidebarMoved"> <gl-dropdown-item :data-clipboard-text="issuableReference" + button-class="js-copy-reference" data-testid="copy-reference" @click="copyReference" >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item diff --git a/app/assets/javascripts/lib/print_markdown_dom.js b/app/assets/javascripts/lib/print_markdown_dom.js new file mode 100644 index 00000000000..fb5ea09b6c8 --- /dev/null +++ b/app/assets/javascripts/lib/print_markdown_dom.js @@ -0,0 +1,50 @@ +function getPrintContent(target, ignoreSelectors) { + const cloneDom = target.cloneNode(true); + cloneDom.querySelectorAll('details').forEach((detail) => { + detail.setAttribute('open', ''); + }); + + if (Array.isArray(ignoreSelectors) && ignoreSelectors.length > 0) { + cloneDom.querySelectorAll(ignoreSelectors.join(',')).forEach((ignoredNode) => { + ignoredNode.remove(); + }); + } + + cloneDom.querySelectorAll('img').forEach((img) => { + img.setAttribute('loading', 'eager'); + }); + + return cloneDom.innerHTML; +} + +function getTitleContent(title) { + const titleElement = document.createElement('h2'); + titleElement.className = 'gl-mt-0 gl-mb-5'; + titleElement.innerText = title; + return titleElement.outerHTML; +} + +export default async function printMarkdownDom({ + target, + title, + ignoreSelectors = [], + stylesheet = [], +}) { + const printJS = (await import('print-js')).default; + + const printContent = getPrintContent(target, ignoreSelectors); + + const titleElement = title ? getTitleContent(title) : ''; + + const markdownElement = `<div class="md">${printContent}</div>`; + + const printable = titleElement + markdownElement; + + printJS({ + printable, + type: 'raw-html', + documentTitle: title, + scanStyles: false, + css: stylesheet, + }); +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue new file mode 100644 index 00000000000..4d13f25c4cb --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_export.vue @@ -0,0 +1,40 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; +import printMarkdownDom from '~/lib/print_markdown_dom'; + +export default { + components: { + GlDisclosureDropdown, + }, + inject: ['target', 'title', 'stylesheet'], + computed: { + dropdownItems() { + return [ + { + text: __('Print as PDF'), + action: this.print, + }, + ]; + }, + }, + methods: { + print() { + printMarkdownDom({ + target: document.querySelector(this.target), + title: this.title, + stylesheet: this.stylesheet, + }); + }, + }, +}; +</script> +<template> + <gl-disclosure-dropdown + :items="dropdownItems" + icon="ellipsis_v" + category="tertiary" + placement="right" + no-caret + /> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/show.js b/app/assets/javascripts/pages/shared/wikis/show.js index 9906cb595f8..9bc399d07b3 100644 --- a/app/assets/javascripts/pages/shared/wikis/show.js +++ b/app/assets/javascripts/pages/shared/wikis/show.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Wikis from './wikis'; import WikiContent from './components/wiki_content.vue'; +import WikiExport from './components/wiki_export.vue'; const mountWikiContentApp = () => { const el = document.querySelector('.js-async-wiki-page-content'); @@ -20,8 +21,28 @@ const mountWikiContentApp = () => { } }; +const mountWikiExportApp = () => { + const el = document.querySelector('#js-export-actions'); + + if (!el) return false; + const { target, title, stylesheet } = JSON.parse(el.dataset.options); + + return new Vue({ + el, + provide: { + target, + title, + stylesheet, + }, + render(createElement) { + return createElement(WikiExport); + }, + }); +}; + export const mountApplications = () => { // eslint-disable-next-line no-new new Wikis(); mountWikiContentApp(); + mountWikiExportApp(); }; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue index f90633c6e03..a1c1b1141a7 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email.vue @@ -8,7 +8,9 @@ import { I18N_CARD_TITLE, I18N_GENERIC_ERROR, I18N_FEEDBACK_PARAGRAPH, + I18N_TOAST_SAVED, } from '../custom_email_constants'; +import CustomEmailForm from './custom_email_form.vue'; export default { components: { @@ -18,11 +20,13 @@ export default { GlSprintf, GlLink, GlCard, + CustomEmailForm, }, FEEDBACK_ISSUE_URL, I18N_LOADING_LABEL, I18N_CARD_TITLE, I18N_FEEDBACK_PARAGRAPH, + I18N_TOAST_SAVED, props: { incomingEmail: { type: String, @@ -38,6 +42,7 @@ export default { data() { return { loading: true, + submitting: false, customEmail: null, enabled: false, verificationState: null, @@ -47,6 +52,11 @@ export default { alertMessage: null, }; }, + computed: { + customEmailNotSetUp() { + return !this.enabled && this.verificationState === null && this.customEmail === null; + }, + }, mounted() { this.getCustomEmailDetails(); }, @@ -76,6 +86,21 @@ export default { this.smtpAddress = data.custom_email_smtp_address; this.errorMessage = data.error_message; }, + onSaveCustomEmail(requestData) { + this.alertMessage = null; + this.submitting = true; + + axios + .post(this.customEmailEndpoint, requestData) + .then(({ data }) => { + this.updateData(data); + this.$toast.show(this.$options.I18N_TOAST_SAVED); + }) + .catch(this.handleRequestError) + .finally(() => { + this.submitting = false; + }); + }, }, }; </script> @@ -108,11 +133,20 @@ export default { <gl-alert v-if="alertMessage" variant="warning" - class="gl-mt-n5 gl-mx-n5" + class="gl-mt-n5 gl-mb-4 gl-mx-n5" @dismiss="dismissAlert" > {{ alertMessage }} </gl-alert> + + <!-- Use v-show to preserve form data after verification failure + without the need to maintain a state in this component. --> + <custom-email-form + v-show="customEmailNotSetUp && !loading" + :incoming-email="incomingEmail" + :submitting="submitting" + @submit="onSaveCustomEmail" + /> </template> <template #footer> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue new file mode 100644 index 00000000000..7088627a487 --- /dev/null +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue @@ -0,0 +1,291 @@ +<script> +import { GlButton, GlForm, GlFormGroup, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; +import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { + I18N_FORM_INTRODUCTION_PARAGRAPH, + I18N_FORM_CUSTOM_EMAIL_LABEL, + I18N_FORM_CUSTOM_EMAIL_DESCRIPTION, + I18N_FORM_FORWARDING_LABEL, + I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE, + I18N_FORM_SMTP_ADDRESS_LABEL, + I18N_FORM_SMTP_PORT_LABEL, + I18N_FORM_SMTP_PORT_DESCRIPTION, + I18N_FORM_SMTP_USERNAME_LABEL, + I18N_FORM_SMTP_PASSWORD_LABEL, + I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SUBMIT_LABEL, + I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, + I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, + I18N_FORM_INVALID_FEEDBACK_SMTP_PORT, + I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME, + I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD, +} from '../custom_email_constants'; + +export default { + components: { + ClipboardButton, + GlButton, + GlForm, + GlFormGroup, + GlFormInputGroup, + GlFormInput, + }, + I18N_FORM_INTRODUCTION_PARAGRAPH, + I18N_FORM_CUSTOM_EMAIL_LABEL, + I18N_FORM_CUSTOM_EMAIL_DESCRIPTION, + I18N_FORM_FORWARDING_LABEL, + I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE, + I18N_FORM_SMTP_ADDRESS_LABEL, + I18N_FORM_SMTP_PORT_LABEL, + I18N_FORM_SMTP_PORT_DESCRIPTION, + I18N_FORM_SMTP_USERNAME_LABEL, + I18N_FORM_SMTP_PASSWORD_LABEL, + I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SUBMIT_LABEL, + I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, + I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, + I18N_FORM_INVALID_FEEDBACK_SMTP_PORT, + I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME, + I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD, + props: { + incomingEmail: { + type: String, + required: false, + default: '', + }, + submitting: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + customEmail: '', + forwardingConfigured: false, + smtpAddress: '', + smtpPort: '587', + smtpUsername: '', + smtpPassword: '', + validationState: { + customEmail: null, + smtpAddress: null, + smtpPort: true, + smtpUsername: null, + smtpPassword: null, + }, + }; + }, + computed: { + isFormValid() { + return Object.values(this.validationState).every(Boolean); + }, + }, + methods: { + onSubmit() { + this.triggerVerification(); + + if (!this.isFormValid) { + return; + } + + this.$emit('submit', this.getRequestFormData()); + }, + getRequestFormData() { + return { + custom_email: this.customEmail, + smtp_address: this.smtpAddress, + smtp_port: this.smtpPort, + smtp_username: this.smtpUsername, + smtp_password: this.smtpPassword, + }; + }, + onCustomEmailChange() { + this.validateCustomEmail(); + + if (this.validationState.customEmail && isEmptyValue(this.smtpUsername)) { + this.smtpUsername = this.customEmail; + this.validateSmtpUsername(); + } + }, + validateCustomEmail() { + this.validationState.customEmail = isEmail(this.customEmail); + }, + validateSmtpAddress() { + this.validationState.smtpAddress = !isEmptyValue(this.smtpAddress); + }, + validateSmtpPort() { + this.validationState.smtpPort = isIntegerGreaterThan(this.smtpPort, 0); + }, + validateSmtpUsername() { + this.validationState.smtpUsername = !isEmptyValue(this.smtpUsername); + }, + validateSmtpPassword() { + this.validationState.smtpPassword = hasMinimumLength(this.smtpPassword, 8); + }, + triggerVerification() { + this.validateCustomEmail(); + this.validateSmtpAddress(); + this.validateSmtpPort(); + this.validateSmtpUsername(); + this.validateSmtpPassword(); + }, + }, +}; +</script> + +<template> + <div> + <p>{{ $options.I18N_FORM_INTRODUCTION_PARAGRAPH }}</p> + <gl-form class="js-quick-submit" @submit.prevent="onSubmit"> + <gl-form-group + :label="$options.I18N_FORM_FORWARDING_LABEL" + label-for="custom-email-form-forwarding" + class="gl-mt-3" + > + <gl-form-input-group> + <gl-form-input + id="custom-email-form-forwarding" + ref="service-desk-incoming-email" + type="text" + data-testid="custom-email-form-forwarding" + :aria-label="$options.I18N_FORM_FORWARDING_LABEL" + :value="incomingEmail" + :disabled="true" + /> + <template #append> + <clipboard-button + :title="$options.I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE" + :text="incomingEmail" + css-class="input-group-text" + /> + </template> + </gl-form-input-group> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL" + label-for="custom-email-form-custom-email" + data-testid="form-group-custom-email" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL" + class="gl-mt-3" + :description="$options.I18N_FORM_CUSTOM_EMAIL_DESCRIPTION" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-custom-email" + v-model.trim="customEmail" + data-testid="form-custom-email" + :aria-label="$options.I18N_FORM_CUSTOM_EMAIL_LABEL" + placeholder="contact@example.com" + type="email" + :state="validationState.customEmail" + :required="true" + :disabled="submitting" + @change="onCustomEmailChange" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_ADDRESS_LABEL" + label-for="custom-email-form-smtp-address" + data-testid="form-group-smtp-address" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS" + class="gl-mt-3" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-address" + v-model.trim="smtpAddress" + data-testid="form-smtp-address" + :aria-label="$options.I18N_FORM_SMTP_ADDRESS_LABEL" + placeholder="smtp.example.com" + type="email" + :state="validationState.smtpAddress" + :required="true" + :disabled="submitting" + @change="validateSmtpAddress" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_PORT_LABEL" + label-for="custom-email-form-smtp-port" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PORT" + class="gl-mt-3" + :description="$options.I18N_FORM_SMTP_PORT_DESCRIPTION" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-port" + v-model.trim="smtpPort" + data-testid="form-smtp-port" + :aria-label="$options.I18N_FORM_SMTP_PORT_LABEL" + placeholder="587" + type="number" + :state="validationState.smtpPort" + :required="true" + :disabled="submitting" + @change="validateSmtpPort" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_USERNAME_LABEL" + label-for="custom-email-form-smtp-username" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME" + class="gl-mt-3" + > + <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings --> + <gl-form-input + id="custom-email-form-smtp-username" + v-model.trim="smtpUsername" + data-testid="form-smtp-username" + :aria-label="$options.I18N_FORM_SMTP_USERNAME_LABEL" + placeholder="contact@example.com" + :state="validationState.smtpUsername" + :required="true" + :disabled="submitting" + @change="validateSmtpUsername" + /> + <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings --> + </gl-form-group> + + <gl-form-group + :label="$options.I18N_FORM_SMTP_PASSWORD_LABEL" + label-for="custom-email-form-smtp-password" + :invalid-feedback="$options.I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD" + class="gl-mt-3" + :description="$options.I18N_FORM_SMTP_PASSWORD_DESCRIPTION" + > + <gl-form-input + id="custom-email-form-smtp-password" + v-model.trim="smtpPassword" + data-testid="form-smtp-password" + :aria-label="$options.I18N_FORM_SMTP_PASSWORD_LABEL" + type="password" + :state="validationState.smtpPassword" + :required="true" + :disabled="submitting" + @change="validateSmtpPassword" + /> + </gl-form-group> + + <gl-button + type="submit" + variant="confirm" + class="gl-mt-5" + data-testid="form-submit" + :disabled="!isFormValid" + :loading="submitting" + @click="onSubmit" + > + {{ $options.I18N_FORM_SUBMIT_LABEL }} + </gl-button> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js index 9770a1f4df9..cc5dc8af2e4 100644 --- a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js +++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js @@ -8,3 +8,46 @@ export const I18N_FEEDBACK_PARAGRAPH = s__( 'ServiceDesk|Please share your feedback on this feature in the %{linkStart}feedback issue%{linkEnd}', ); export const I18N_GENERIC_ERROR = __('An error occurred. Please try again.'); + +export const I18N_TOAST_SAVED = s__( + 'ServiceDesk|Saved custom email address and started verification.', +); + +export const I18N_FORM_INTRODUCTION_PARAGRAPH = s__( + 'ServiceDesk|Connect a custom email address your customers can use to create Service Desk issues. Forward all emails from your custom email address to the Service Desk email address of this project. GitLab will send Service Desk emails from the custom address on your behalf using your SMTP credentials.', +); +export const I18N_FORM_FORWARDING_LABEL = s__( + 'ServiceDesk|Service Desk email address to forward emails to', +); +export const I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE = s__( + 'ServiceDesk|Copy Service Desk email address', +); +export const I18N_FORM_CUSTOM_EMAIL_LABEL = s__('ServiceDesk|Custom email address'); +export const I18N_FORM_CUSTOM_EMAIL_DESCRIPTION = s__( + 'ServiceDesk|Email address your customers can use to send support requests. It must support sub-addressing.', +); +export const I18N_FORM_SMTP_ADDRESS_LABEL = s__('ServiceDesk|SMTP host'); +export const I18N_FORM_SMTP_PORT_LABEL = s__('ServiceDesk|SMTP port'); +export const I18N_FORM_SMTP_PORT_DESCRIPTION = s__( + 'ServiceDesk|Common ports are 587 when using TLS, and 25 when not.', +); +export const I18N_FORM_SMTP_USERNAME_LABEL = s__('ServiceDesk|SMTP username'); +export const I18N_FORM_SMTP_PASSWORD_LABEL = s__('ServiceDesk|SMTP password'); +export const I18N_FORM_SMTP_PASSWORD_DESCRIPTION = s__('ServiceDesk|Minimum 8 characters long.'); +export const I18N_FORM_SUBMIT_LABEL = s__('ServiceDesk|Save and test connection'); + +export const I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL = s__( + 'ServiceDesk|Custom email is required and must be a valid email address.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS = s__( + 'ServiceDesk|SMTP address is required and must be resolvable.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_PORT = s__( + 'ServiceDesk|SMTP port is required and must be a port number larger than 0.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_USERNAME = s__( + 'ServiceDesk|SMTP username is required.', +); +export const I18N_FORM_INVALID_FEEDBACK_SMTP_PASSWORD = s__( + 'ServiceDesk|SMTP password is required and must be at least 8 characters long.', +); diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index dd9585734db..c4d4f42576f 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -1,7 +1,10 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import ServiceDeskRoot from './components/service_desk_root.vue'; +Vue.use(GlToast); + export default () => { const el = document.querySelector('.js-service-desk-setting-root'); diff --git a/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..a15c8ee2e9f --- /dev/null +++ b/app/assets/javascripts/service_desk/components/empty_state_with_any_issues.vue @@ -0,0 +1,58 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, +} from '../constants'; + +export default { + i18n: { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, + }, + components: { + GlEmptyState, + }, + inject: ['emptyStateSvgPath'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, + computed: { + content() { + if (this.hasSearch) { + return { + title: noSearchResultsTitle, + description: noSearchResultsDescription, + svgHeight: 150, + }; + } else if (this.isOpenTab) { + return { title: noOpenIssuesTitle, description: infoBannerUserNote }; + } + + return { title: noClosedIssuesTitle, svgHeight: 150 }; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :description="content.description" + :title="content.title" + :svg-path="emptyStateSvgPath" + :svg-height="content.svgHeight" + /> +</template> diff --git a/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..0679d31a8b8 --- /dev/null +++ b/app/assets/javascripts/service_desk/components/empty_state_without_any_issues.vue @@ -0,0 +1,75 @@ +<script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, +} from '../constants'; + +export default { + i18n: { + noIssuesSignedOutButtonText, + infoBannerTitle, + infoBannerUserNote, + infoBannerAdminNote, + learnMore, + }, + serviceDeskHelpPagePath: helpPagePath('user/project/service_desk/index'), + components: { + GlEmptyState, + GlLink, + }, + inject: [ + 'emptyStateSvgPath', + 'isSignedIn', + 'signInPath', + 'canAdminIssues', + 'isServiceDeskEnabled', + 'serviceDeskEmailAddress', + ], + computed: { + canSeeEmailAddress() { + return this.canAdminIssues && this.isServiceDeskEnabled; + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + content-class="gl-max-w-80!" + > + <template #description> + <p v-if="canSeeEmailAddress"> + {{ $options.i18n.infoBannerAdminNote }} <br /><code>{{ serviceDeskEmailAddress }}</code> + </p> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="$options.serviceDeskHelpPagePath" target="_blank"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.infoBannerTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + content-class="gl-max-w-80!" + > + <template #description> + <p>{{ $options.i18n.infoBannerUserNote }}</p> + <gl-link :href="$options.serviceDeskHelpPagePath"> + {{ $options.i18n.learnMore }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue index e4b8142e153..32b219a9fa9 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -1,5 +1,4 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isEmpty } from 'lodash'; @@ -44,7 +43,6 @@ import searchProjectMilestonesQuery from '../queries/search_project_milestones.q import { errorFetchingCounts, errorFetchingIssues, - noSearchNoFilterTitle, searchPlaceholder, SERVICE_DESK_BOT_USERNAME, STATUS_OPEN, @@ -63,19 +61,21 @@ import { confidentialityTokenBase, } from '../search_tokens'; import InfoBanner from './info_banner.vue'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; export default { i18n: { errorFetchingCounts, errorFetchingIssues, - noSearchNoFilterTitle, searchPlaceholder, }, issuableListTabs, components: { - GlEmptyState, IssuableList, InfoBanner, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, }, mixins: [glFeatureFlagMixin()], inject: [ @@ -188,6 +188,9 @@ export default { [STATUS_ALL]: allIssues?.count, }; }, + isOpenTab() { + return this.state === STATUS_OPEN; + }, urlParams() { return { sort: urlSortParams[this.sortKey], @@ -200,7 +203,10 @@ export default { }; }, isInfoBannerVisible() { - return this.isServiceDeskSupported && this.hasAnyIssues; + return this.isServiceDeskSupported && this.hasAnyServiceDeskIssues; + }, + hasAnyServiceDeskIssues() { + return this.hasSearch || Boolean(this.tabCounts.all); }, hasOrFeature() { return this.glFeatures.orIssuableQueries; @@ -404,6 +410,7 @@ export default { <section> <info-banner v-if="isInfoBannerVisible" /> <issuable-list + v-if="hasAnyServiceDeskIssues" namespace="service-desk" recent-searches-storage-key="service-desk-issues" :error="issuesError" @@ -423,11 +430,10 @@ export default { @filter="handleFilter" > <template #empty-state> - <gl-empty-state - :svg-path="emptyStateSvgPath" - :title="$options.i18n.noSearchNoFilterTitle" - /> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> </template> </issuable-list> + + <empty-state-without-any-issues v-else /> </section> </template> diff --git a/app/assets/javascripts/service_desk/constants.js b/app/assets/javascripts/service_desk/constants.js index 54615f466f3..a83c0d9ca57 100644 --- a/app/assets/javascripts/service_desk/constants.js +++ b/app/assets/javascripts/service_desk/constants.js @@ -228,7 +228,13 @@ export const filtersMap = { export const errorFetchingCounts = __('An error occurred while getting issue counts'); export const errorFetchingIssues = __('An error occurred while loading issues'); -export const noSearchNoFilterTitle = __('Please select at least one filter to see results'); +export const noOpenIssuesTitle = __('There are no open issues'); +export const noClosedIssuesTitle = __('There are no closed issues'); +export const noIssuesSignedOutButtonText = __('Register / Sign In'); +export const noSearchResultsDescription = __( + 'To widen your search, change or remove filters above', +); +export const noSearchResultsTitle = __('Sorry, your filter produced no results'); export const searchPlaceholder = __('Search or filter results...'); export const infoBannerTitle = s__( 'ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab', @@ -238,7 +244,7 @@ export const infoBannerUserNote = s__( 'ServiceDesk|Issues created from Service Desk emails will appear here. Each comment becomes part of the email conversation.', ); export const enableServiceDesk = s__('ServiceDesk|Enable Service Desk'); -export const learnMore = __('Learn more'); +export const learnMore = __('Learn more about Service Desk'); export const titles = __('Titles'); export const descriptions = __('Descriptions'); export const no = __('No'); diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js index ab96b550613..afb2d0e8de3 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/service_desk/index.js @@ -23,6 +23,7 @@ export async function mountServiceDeskListApp() { projectDataFullPath, projectDataIsProject, projectDataIsSignedIn, + projectDataSignInPath, projectDataHasAnyIssues, projectDataInitialSort, serviceDeskEmailAddress, @@ -68,6 +69,7 @@ export async function mountServiceDeskListApp() { serviceDeskHelpPath, isServiceDeskSupported: parseBoolean(isServiceDeskSupported), isServiceDeskEnabled: parseBoolean(isServiceDeskEnabled), + signInPath: projectDataSignInPath, hasAnyIssues: parseBoolean(projectDataHasAnyIssues), initialSort: projectDataInitialSort, }, |