diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-20 23:50:22 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-20 23:50:22 +0000 |
commit | 9dc93a4519d9d5d7be48ff274127136236a3adb3 (patch) | |
tree | 70467ae3692a0e35e5ea56bcb803eb512a10bedb /app/assets/javascripts/alerts_settings | |
parent | 4b0f34b6d759d6299322b3a54453e930c6121ff0 (diff) | |
download | gitlab-ce-9dc93a4519d9d5d7be48ff274127136236a3adb3.tar.gz |
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc43
Diffstat (limited to 'app/assets/javascripts/alerts_settings')
7 files changed, 446 insertions, 286 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 07b2e59671e..5171588eb64 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -118,17 +118,17 @@ export default { <template> <div class="gl-display-table gl-w-full gl-mt-5"> <div class="gl-display-table-row"> - <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3"> {{ $options.i18n.columns.gitlabKeyTitle }} </h5> - <h5 class="gl-display-table-cell gl-py-3 gl-pr-3"> </h5> - <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + <h5 class="gl-display-table-cell gl-pb-3 gl-pr-3"> </h5> + <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3"> {{ $options.i18n.columns.payloadKeyTitle }} </h5> <h5 v-if="hasFallbackColumn" id="fallbackFieldsHeader" - class="gl-display-table-cell gl-py-3 gl-pr-3" + class="gl-display-table-cell gl-pb-3 gl-pr-3" > {{ $options.i18n.columns.fallbackKeyTitle }} <gl-icon @@ -140,11 +140,7 @@ export default { </h5> </div> - <div - v-for="(gitlabField, index) in mappingData" - :key="gitlabField.name" - class="gl-display-table-row" - > + <div v-for="gitlabField in mappingData" :key="gitlabField.name" class="gl-display-table-row"> <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle"> <gl-form-input aria-labelledby="gitlabFieldsHeader" @@ -153,8 +149,8 @@ export default { /> </div> - <div class="gl-display-table-cell gl-py-3 gl-pr-3"> - <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }"> + <div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle"> + <div class="right-arrow"> <i class="right-arrow-head"></i> </div> </div> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index a5e17d80f86..ef29fc5e8b4 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -21,8 +21,10 @@ import { import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; export const i18n = { + deleteIntegration: s__('AlertSettings|Delete integration'), + editIntegration: s__('AlertSettings|Edit integration'), title: s__('AlertsIntegrations|Current integrations'), - emptyState: s__('AlertsIntegrations|No integrations have been added yet'), + emptyState: s__('AlertsIntegrations|No integrations have been added yet.'), status: { enabled: { name: __('Enabled'), @@ -139,7 +141,7 @@ export default { <template> <div class="incident-management-list"> - <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> + <h5 class="gl-font-lg gl-mt-5">{{ $options.i18n.title }}</h5> <gl-table class="integration-list" :items="integrations" @@ -174,11 +176,16 @@ export default { <template #cell(actions)="{ item }"> <gl-button-group class="gl-ml-3"> - <gl-button icon="settings" @click="editIntegration(item)" /> + <gl-button + icon="settings" + :aria-label="$options.i18n.editIntegration" + @click="editIntegration(item)" + /> <gl-button v-gl-modal.deleteIntegration :disabled="item.type === $options.typeSet.prometheus" icon="remove" + :aria-label="$options.i18n.deleteIntegration" @click="setIntegrationToDelete(item)" /> </gl-button-group> @@ -198,15 +205,15 @@ export default { </gl-table> <gl-modal modal-id="deleteIntegration" - :title="s__('AlertSettings|Delete integration')" - :ok-title="s__('AlertSettings|Delete integration')" + :title="$options.i18n.deleteIntegration" + :ok-title="$options.i18n.deleteIntegration" ok-variant="danger" @ok="deleteIntegration" > <gl-sprintf :message=" s__( - 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.', + 'AlertsIntegrations|If you delete the %{integrationName} integration, alerts are no longer sent from this endpoint. This action cannot be undone.', ) " > diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 5d9513e5b53..a5f7b84446f 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -14,7 +14,7 @@ import { GlTab, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { isEmpty, omit } from 'lodash'; +import { isEqual, isEmpty, omit } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { integrationTypes, @@ -24,8 +24,9 @@ import { JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, typeSet, - viewCredentialsTabIndex, i18n, + tabIndices, + testAlertModalId, } from '../constants'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql'; @@ -40,6 +41,10 @@ export default { typeSet, integrationSteps, i18n, + primaryProps: { text: i18n.integrationFormSteps.testPayload.savedAndTest }, + secondaryProps: { text: i18n.integrationFormSteps.testPayload.proceedWithoutSave }, + cancelProps: { text: i18n.integrationFormSteps.testPayload.cancel }, + testAlertModalId, components: { ClipboardButton, GlButton, @@ -60,11 +65,8 @@ export default { GlModal: GlModalDirective, }, inject: { - generic: { - default: {}, - }, - prometheus: { - default: {}, + alertsUsageUrl: { + default: '#', }, multiIntegrations: { default: false, @@ -87,6 +89,11 @@ export default { required: false, default: null, }, + tabIndex: { + type: Number, + required: false, + default: tabIndices.configureDetails, + }, }, apollo: { currentIntegration: { @@ -96,11 +103,10 @@ export default { data() { return { integrationTypesOptions: Object.values(integrationTypes), - selectedIntegration: integrationTypes.none.value, - active: false, samplePayload: { json: null, error: null, + loading: false, }, testPayload: { json: null, @@ -108,18 +114,32 @@ export default { }, resetPayloadAndMappingConfirmed: false, mapping: [], - parsingPayload: false, + integrationForm: { + active: false, + type: integrationTypes.none.value, + name: '', + token: '', + url: '', + apiUrl: '', + }, + activeTabIndex: this.tabIndex, currentIntegration: null, parsedPayload: [], - activeTabIndex: 0, + validationState: { + name: true, + apiUrl: true, + }, }; }, computed: { isPrometheus() { - return this.selectedIntegration === this.$options.typeSet.prometheus; + return this.integrationForm.type === typeSet.prometheus; }, isHttp() { - return this.selectedIntegration === this.$options.typeSet.http; + return this.integrationForm.type === typeSet.http; + }, + isNone() { + return !this.isHttp && !this.isPrometheus; }, isCreating() { return !this.currentIntegration; @@ -130,29 +150,6 @@ export default { isTestPayloadValid() { return this.testPayload.error === null; }, - selectedIntegrationType() { - switch (this.selectedIntegration) { - case typeSet.http: - return this.generic; - case typeSet.prometheus: - return this.prometheus; - default: - return {}; - } - }, - integrationForm() { - return { - name: this.currentIntegration?.name || '', - active: this.currentIntegration?.active || false, - token: - this.currentIntegration?.token || - (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.token : ''), - url: - this.currentIntegration?.url || - (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.url : ''), - apiUrl: this.currentIntegration?.apiUrl || '', - }; - }, testAlertPayload() { return { data: this.testPayload.json, @@ -170,13 +167,7 @@ export default { return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed; }, canParseSamplePayload() { - return !this.active || !this.isSampePayloadValid || !this.samplePayload.json; - }, - isResetAuthKeyDisabled() { - return !this.active && !this.integrationForm.token !== ''; - }, - isPayloadEditDisabled() { - return !this.active || this.canEditPayload; + return this.isSampePayloadValid && this.samplePayload.json; }, isSelectDisabled() { return this.currentIntegration !== null || !this.canAddIntegration; @@ -186,30 +177,105 @@ export default { ? i18n.integrationFormSteps.setupCredentials.prometheusHelp : i18n.integrationFormSteps.setupCredentials.help; }, + isFormValid() { + return ( + Object.values(this.validationState).every(Boolean) && + !this.isNone && + this.isSampePayloadValid + ); + }, + isFormDirty() { + const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } = + this.currentIntegration || {}; + const { + name: formName, + apiUrl: formApiUrl, + active: formActive, + type: formType, + } = this.integrationForm; + + const isDirty = + type !== formType || + active !== formActive || + name !== formName || + apiUrl !== formApiUrl || + !isEqual(this.parsedPayload, payloadAlertFields) || + !isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings)); + + return isDirty; + }, + canSubmitForm() { + return this.isFormValid && this.isFormDirty; + }, + dataForSave() { + const { name, apiUrl, active } = this.integrationForm; + const customMappingVariables = { + payloadAttributeMappings: this.mapping, + payloadExample: this.samplePayload.json || '{}', + }; + + const variables = this.isHttp + ? { name, active, ...customMappingVariables } + : { apiUrl, active }; + + return { type: this.integrationForm.type, variables }; + }, + testAlertModal() { + return this.isFormDirty ? testAlertModalId : null; + }, + prometheusUrlInvalidFeedback() { + const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl; + return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError; + }, }, watch: { + tabIndex(val) { + this.activeTabIndex = val; + }, currentIntegration(val) { if (val === null) { this.reset(); return; } - const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val; - this.selectedIntegration = type; - this.active = active; - if (type === typeSet.http && this.showMappingBuilder) { + this.resetPayloadAndMapping(); + const { + name, + type, + active, + url, + apiUrl, + token, + payloadExample, + payloadAlertFields, + payloadAttributeMappings, + } = val; + this.integrationForm = { type, name, active, url, apiUrl, token }; + + if (this.showMappingBuilder) { + this.resetPayloadAndMappingConfirmed = false; this.parsedPayload = payloadAlertFields; - this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null; - const mapping = payloadAttributeMappings.map((mappingItem) => - omit(mappingItem, '__typename'), - ); - this.updateMapping(mapping); + this.samplePayload.json = this.getPrettifiedPayload(payloadExample); + this.updateMapping(this.getCleanMapping(payloadAttributeMappings)); } - this.activeTabIndex = viewCredentialsTabIndex; this.$el.scrollIntoView({ block: 'center' }); }, }, methods: { + getCleanMapping(mapping) { + return mapping.map((mappingItem) => omit(mappingItem, '__typename')); + }, + validateName() { + this.validationState.name = Boolean(this.integrationForm.name?.length); + }, + validateApiUrl() { + try { + const parsedUrl = new URL(this.integrationForm.apiUrl); + this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol); + } catch (e) { + this.validationState.apiUrl = false; + } + }, isValidNonEmptyJSON(JSONString) { if (JSONString) { let parsed; @@ -222,29 +288,37 @@ export default { } return false; }, + getPrettifiedPayload(payload) { + return this.isValidNonEmptyJSON(payload) + ? JSON.stringify(JSON.parse(payload), null, '\t') + : null; + }, + triggerValidation() { + if (this.isHttp) { + this.validationState.apiUrl = true; + this.validateName(); + if (!this.validationState.name) { + this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } else if (this.isPrometheus) { + this.validationState.name = true; + this.validateApiUrl(); + } + }, sendTestAlert() { this.$emit('test-alert-payload', this.testAlertPayload); }, - submit() { - const { name, apiUrl } = this.integrationForm; - const customMappingVariables = { - payloadAttributeMappings: this.mapping, - payloadExample: this.samplePayload.json || '{}', - }; - - const variables = - this.selectedIntegration === typeSet.http - ? { name, active: this.active, ...customMappingVariables } - : { apiUrl, active: this.active }; - - const integrationPayload = { type: this.selectedIntegration, variables }; + saveAndSendTestAlert() { + this.$emit('save-and-test-alert-payload', this.dataForSave, this.testAlertPayload); + }, + submit(testAfterSubmit = false) { + this.triggerValidation(); - if (this.currentIntegration) { - return this.$emit('update-integration', integrationPayload); + if (!this.isFormValid) { + return; } - - this.reset(); - return this.$emit('create-new-integration', integrationPayload); + const event = this.currentIntegration ? 'update-integration' : 'create-new-integration'; + this.$emit(event, this.dataForSave, testAfterSubmit); }, reset() { this.resetFormValues(); @@ -252,14 +326,14 @@ export default { this.$emit('clear-current-integration', { type: this.currentIntegration?.type }); }, resetFormValues() { - this.selectedIntegration = integrationTypes.none.value; + this.integrationForm.type = integrationTypes.none.value; this.integrationForm.name = ''; + this.integrationForm.active = false; this.integrationForm.apiUrl = ''; this.samplePayload = { json: null, error: null, }; - this.active = false; }, resetAuthKey() { if (!this.currentIntegration) { @@ -267,7 +341,7 @@ export default { } this.$emit('reset-token', { - type: this.selectedIntegration, + type: this.integrationForm.type, variables: { id: this.currentIntegration.id }, }); }, @@ -285,8 +359,8 @@ export default { payload.error = JSON.stringify(e.message); } }, - parseMapping() { - this.parsingPayload = true; + parseSamplePayload() { + this.samplePayload.loading = true; return this.$apollo .query({ @@ -303,7 +377,7 @@ export default { this.resetPayloadAndMappingConfirmed = false; this.$toast.show( - this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg, + this.$options.i18n.integrationFormSteps.mapFields.payloadParsedSucessMsg, ); }, ) @@ -311,7 +385,7 @@ export default { this.samplePayload.error = message; }) .finally(() => { - this.parsingPayload = false; + this.samplePayload.loading = false; }); }, updateMapping(mapping) { @@ -338,7 +412,7 @@ export default { <template> <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset"> <gl-tabs v-model="activeTabIndex"> - <gl-tab :title="$options.i18n.integrationTabs.configureDetails"> + <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3"> <gl-form-group v-if="isCreating" id="integration-type" @@ -351,7 +425,7 @@ export default { label-for="integration-type" > <gl-form-select - v-model="selectedIntegration" + v-model="integrationForm.type" :disabled="isSelectDisabled" class="gl-max-w-full" :options="integrationTypesOptions" @@ -369,7 +443,6 @@ export default { <div class="gl-mt-3"> <gl-form-group v-if="isHttp" - id="name-integration" :label=" getLabelWithStepNumber( $options.integrationSteps.nameIntegration, @@ -377,67 +450,82 @@ export default { ) " label-for="name-integration" + :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error" + :state="validationState.name" > <gl-form-input + id="name-integration" + ref="integrationName" v-model="integrationForm.name" type="text" :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + @input="validateName" /> </gl-form-group> - <gl-toggle - v-model="active" - :is-loading="loading" - :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" - class="gl-my-4 gl-font-weight-normal" - /> - - <div v-if="isPrometheus" class="gl-my-4"> - <span class="gl-font-weight-bold"> - {{ - getLabelWithStepNumber( - $options.integrationSteps.setPrometheusApiUrl, - $options.i18n.integrationFormSteps.prometheusFormUrl.label, - ) - }} - </span> + <gl-form-group + v-if="!isNone" + :label=" + getLabelWithStepNumber( + isHttp + ? $options.integrationSteps.enableHttpIntegration + : $options.integrationSteps.enablePrometheusIntegration, + $options.i18n.integrationFormSteps.enableIntegration.label, + ) + " + > + <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span> + + <gl-toggle + id="enable-integration" + v-model="integrationForm.active" + :is-loading="loading" + :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" + class="gl-mt-4 gl-font-weight-normal" + /> + </gl-form-group> + <gl-form-group + v-if="isPrometheus" + class="gl-my-4" + :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label" + label-for="api-url" + :invalid-feedback="prometheusUrlInvalidFeedback" + :state="validationState.apiUrl" + > <gl-form-input - id="integration-apiUrl" + id="api-url" v-model="integrationForm.apiUrl" type="text" :placeholder="$options.placeholders.prometheus" + @input="validateApiUrl" /> - <span class="gl-text-gray-400"> {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} </span> - </div> + </gl-form-group> <template v-if="showMappingBuilder"> <gl-form-group data-testid="sample-payload-section" :label=" getLabelWithStepNumber( - $options.integrationSteps.setSamplePayload, - $options.i18n.integrationFormSteps.setSamplePayload.label, + $options.integrationSteps.customizeMapping, + $options.i18n.integrationFormSteps.mapFields.label, ) " label-for="sample-payload" class="gl-mb-0!" :invalid-feedback="samplePayload.error" > - <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp" - :link="generic.alertsUsageUrl" - /> + <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span> <gl-form-textarea id="sample-payload" - v-model.trim="samplePayload.json" - :disabled="isPayloadEditDisabled" + v-model="samplePayload.json" + :disabled="canEditPayload" :state="isSampePayloadValid" - :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" + :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder" class="gl-my-3" :debounce="$options.JSON_VALIDATE_DELAY" rows="6" @@ -450,71 +538,76 @@ export default { v-if="canEditPayload" v-gl-modal.resetPayloadModal data-testid="payload-action-btn" - :disabled="!active" + :disabled="!integrationForm.active" class="gl-mt-3" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }} + {{ $options.i18n.integrationFormSteps.mapFields.editPayload }} </gl-button> <gl-button v-else data-testid="payload-action-btn" :class="{ 'gl-mt-3': samplePayload.error }" - :disabled="canParseSamplePayload" - :loading="parsingPayload" - @click="parseMapping" + :disabled="!canParseSamplePayload" + :loading="samplePayload.loading" + @click="parseSamplePayload" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }} + {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }} </gl-button> <gl-modal modal-id="resetPayloadModal" - :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader" - :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk" + :title="$options.i18n.integrationFormSteps.mapFields.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk" ok-variant="danger" - @ok="resetPayloadAndMapping" + @ok="resetPayloadAndMappingConfirmed = true" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }} + {{ $options.i18n.integrationFormSteps.mapFields.resetBody }} </gl-modal> - <gl-form-group - id="mapping-builder" - class="gl-mt-5" - :label=" - getLabelWithStepNumber( - $options.integrationSteps.customizeMapping, - $options.i18n.integrationFormSteps.mapFields.label, - ) - " - label-for="mapping-builder" - > - <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span> + <div class="gl-mt-5"> + <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span> <mapping-builder :parsed-payload="parsedPayload" :saved-mapping="mapping" :alert-fields="alertFields" @onMappingUpdate="updateMapping" /> - </gl-form-group> + </div> </template> </div> - <div class="gl-display-flex gl-justify-content-start gl-py-3"> <gl-button - type="submit" + :disabled="!canSubmitForm" variant="confirm" class="js-no-auto-disable" data-testid="integration-form-submit" + @click="submit(false)" > {{ $options.i18n.saveIntegration }} </gl-button> + <gl-button + :disabled="!canSubmitForm" + variant="confirm" + category="secondary" + class="gl-ml-3 js-no-auto-disable" + data-testid="integration-form-test-and-submit" + @click="submit(true)" + > + {{ $options.i18n.saveAndTestIntegration }} + </gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ $options.i18n.cancelAndClose }}</gl-button> </div> </gl-tab> - <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating"> + <gl-tab + :title="$options.i18n.integrationTabs.viewCredentials" + :disabled="isCreating" + class="gl-mt-3" + > <alert-settings-form-help-block :message="viewCredentialsHelpMsg" link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" @@ -559,13 +652,15 @@ export default { </div> </gl-form-group> - <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger"> - {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} - </gl-button> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button v-gl-modal.authKeyModal variant="danger"> + {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} + </gl-button> - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ - $options.i18n.cancelAndClose - }}</gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> <gl-modal modal-id="authKeyModal" @@ -578,18 +673,22 @@ export default { </gl-modal> </gl-tab> - <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating"> + <gl-tab + :title="$options.i18n.integrationTabs.sendTestAlert" + :disabled="isCreating" + class="gl-mt-3" + > <gl-form-group id="test-integration" :invalid-feedback="testPayload.error"> <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp" - :link="generic.alertsUsageUrl" + :message="$options.i18n.integrationFormSteps.testPayload.help" + :link="alertsUsageUrl" /> <gl-form-textarea id="test-payload" - v-model.trim="testPayload.json" + v-model="testPayload.json" :state="isTestPayloadValid" - :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" + :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder" class="gl-my-3" :debounce="$options.JSON_VALIDATE_DELAY" rows="6" @@ -597,20 +696,35 @@ export default { @input="validateJson(false)" /> </gl-form-group> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + v-gl-modal="testAlertModal" + :disabled="!isTestPayloadValid" + :loading="loading" + data-testid="send-test-alert" + variant="confirm" + class="js-no-auto-disable" + @click="isFormDirty ? null : sendTestAlert()" + > + {{ $options.i18n.send }} + </gl-button> - <gl-button - :disabled="!isTestPayloadValid" - data-testid="send-test-alert" - variant="confirm" - class="js-no-auto-disable" - @click="sendTestAlert" - > - {{ $options.i18n.send }} - </gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ - $options.i18n.cancelAndClose - }}</gl-button> + <gl-modal + :modal-id="$options.testAlertModalId" + :title="$options.i18n.integrationFormSteps.testPayload.modalTitle" + :action-primary="$options.primaryProps" + :action-secondary="$options.secondaryProps" + :action-cancel="$options.cancelProps" + @primary="saveAndSendTestAlert" + @secondary="sendTestAlert" + > + {{ $options.i18n.integrationFormSteps.testPayload.modalBody }} + </gl-modal> </gl-tab> </gl-tabs> </gl-form> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 3ffb652e61b..f51c8d7e9f7 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,11 +1,11 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlAlert } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; -import { s__ } from '~/locale'; -import { typeSet } from '../constants'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { typeSet, i18n, tabIndices } from '../constants'; import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; @@ -28,21 +28,12 @@ import { RESET_INTEGRATION_TOKEN_ERROR, UPDATE_INTEGRATION_ERROR, INTEGRATION_PAYLOAD_TEST_ERROR, + INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, + DEFAULT_ERROR, } from '../utils/error_messages'; import IntegrationsList from './alerts_integrations_list.vue'; import AlertSettingsForm from './alerts_settings_form.vue'; -export const i18n = { - changesSaved: s__( - 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', - ), - integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), - alertSent: s__( - 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.', - ), - addNewIntegration: s__('AlertSettings|Add new integration'), -}; - export default { typeSet, i18n, @@ -50,14 +41,9 @@ export default { IntegrationsList, AlertSettingsForm, GlButton, + GlAlert, }, inject: { - generic: { - default: {}, - }, - prometheus: { - default: {}, - }, projectPath: { default: '', }, @@ -124,7 +110,10 @@ export default { integrations: {}, httpIntegrations: {}, currentIntegration: null, + newIntegration: null, formVisible: false, + showSuccessfulCreateAlert: false, + tabIndex: tabIndices.configureDetails, }; }, computed: { @@ -139,10 +128,10 @@ export default { isHttp(type) { return type === typeSet.http; }, - createNewIntegration({ type, variables }) { + createNewIntegration({ type, variables }, testAfterSubmit) { const { projectPath } = this; - const isHttp = this.isHttp(type); + this.isUpdating = true; this.$apollo .mutate({ @@ -163,16 +152,19 @@ export default { .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0]; if (error) { - return createFlash({ message: error }); + createFlash({ message: error }); + return; } - const { integration } = httpIntegrationCreate || prometheusIntegrationCreate; - this.editIntegration(integration); + const { integration } = httpIntegrationCreate || prometheusIntegrationCreate; + this.newIntegration = integration; + this.showSuccessfulCreateAlert = true; - return createFlash({ - message: this.$options.i18n.changesSaved, - type: FLASH_TYPES.SUCCESS, - }); + if (testAfterSubmit) { + this.viewIntegration(this.newIntegration, tabIndices.sendTestAlert); + } else { + this.setFormVisibility(false); + } }) .catch(() => { createFlash({ message: ADD_INTEGRATION_ERROR }); @@ -181,9 +173,9 @@ export default { this.isUpdating = false; }); }, - updateIntegration({ type, variables }) { + updateIntegration({ type, variables }, testAfterSubmit) { this.isUpdating = true; - this.$apollo + return this.$apollo .mutate({ mutation: this.isHttp(type) ? updateHttpIntegrationMutation @@ -196,12 +188,20 @@ export default { .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => { const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0]; if (error) { - return createFlash({ message: error }); + createFlash({ message: error }); + return; } - this.clearCurrentIntegration({ type }); + const integration = + httpIntegrationUpdate?.integration || prometheusIntegrationUpdate?.integration; - return createFlash({ + if (testAfterSubmit) { + this.viewIntegration(integration, tabIndices.sendTestAlert); + } else { + this.clearCurrentIntegration(type); + } + + createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, }); @@ -261,13 +261,23 @@ export default { currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData }; } - this.$apollo.mutate({ - mutation: this.isHttp(type) - ? updateCurrentHttpIntegrationMutation - : updateCurrentPrometheusIntegrationMutation, - variables: currentIntegration, - }); - this.setFormVisibility(true); + this.viewIntegration(currentIntegration, tabIndices.viewCredentials); + }, + viewIntegration(integration, tabIndex) { + this.$apollo + .mutate({ + mutation: this.isHttp(integration.type) + ? updateCurrentHttpIntegrationMutation + : updateCurrentPrometheusIntegrationMutation, + variables: integration, + }) + .then(() => { + this.setFormVisibility(true); + this.tabIndex = tabIndex; + }) + .catch(() => { + createFlash({ message: DEFAULT_ERROR }); + }); }, deleteIntegration({ id, type }) { const { projectPath } = this; @@ -319,19 +329,44 @@ export default { type: FLASH_TYPES.SUCCESS, }); }) - .catch(() => { - createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + .catch((error) => { + let message = INTEGRATION_PAYLOAD_TEST_ERROR; + if (error.response?.status === httpStatusCodes.FORBIDDEN) { + message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR; + } + createFlash({ message }); }); }, + saveAndTestAlertPayload(integration, payload) { + return this.updateIntegration(integration, false).then(() => { + this.testAlertPayload(payload); + }); + }, setFormVisibility(visible) { this.formVisible = visible; }, + viewCreatedIntegration() { + this.viewIntegration(this.newIntegration, tabIndices.viewCredentials); + this.showSuccessfulCreateAlert = false; + this.newIntegration = null; + }, }, }; </script> <template> <div> + <gl-alert + v-if="showSuccessfulCreateAlert" + class="gl-mt-n2" + :primary-button-text="$options.i18n.integrationCreated.btnCaption" + :title="$options.i18n.integrationCreated.title" + @primaryAction="viewCreatedIntegration" + @dismiss="showSuccessfulCreateAlert = false" + > + {{ $options.i18n.integrationCreated.successMsg }} + </gl-alert> + <integrations-list :integrations="integrations.list" :loading="loading" @@ -353,11 +388,13 @@ export default { :loading="isUpdating" :can-add-integration="canAddIntegration" :alert-fields="alertFields" + :tab-index="tabIndex" @create-new-integration="createNewIntegration" @update-integration="updateIntegration" @reset-token="resetToken" @clear-current-integration="clearCurrentIntegration" @test-alert-payload="testAlertPayload" + @save-and-test-alert-payload="saveAndTestAlertPayload" /> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index ce6cf61b5dd..4a180ed2bc0 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -10,96 +10,118 @@ export const i18n = { selectType: { label: s__('AlertSettings|Select integration type'), enterprise: s__( - 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + 'AlertSettings|Free versions of GitLab are limited to one integration per type. To add more, %{linkStart}upgrade your subscription%{linkEnd}.', ), }, nameIntegration: { label: s__('AlertSettings|Name integration'), placeholder: s__('AlertSettings|Enter integration name'), activeToggle: __('Active'), + error: __("Name can't be blank"), + }, + enableIntegration: { + label: s__('AlertSettings|Enable integration'), + help: s__( + 'AlertSettings|A webhook URL and authorization key is generated for the integration. After you save the integration, both are visible under the “View credentials” tab.', + ), }, setupCredentials: { help: s__( - "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + 'AlertSettings|Use the URL and authorization key below to configure how an external service sends alerts to GitLab. %{linkStart}How do I configure the endpoint?%{linkEnd}', ), prometheusHelp: s__( - 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', + 'AlertSettings|Use the URL and authorization key below to configure how Prometheus sends alerts to GitLab. Review the %{linkStart}GitLab documentation%{linkEnd} to learn how to configure your endpoint.', ), webhookUrl: s__('AlertSettings|Webhook URL'), authorizationKey: s__('AlertSettings|Authorization key'), reset: s__('AlertSettings|Reset Key'), }, - setSamplePayload: { - label: s__('AlertSettings|Sample alert payload (optional)'), - testPayloadHelpHttp: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).', - ), - testPayloadHelp: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.', + mapFields: { + label: s__('AlertSettings|Customize alert payload mapping (optional)'), + help: s__( + 'AlertSettings|To create a custom mapping, enter an example payload from your monitoring tool, in JSON format. Select the "Parse payload fields" button to continue.', ), placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), - resetHeader: s__('AlertSettings|Reset the mapping'), - resetBody: s__( - "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", - ), - resetOk: s__('AlertSettings|Proceed with editing'), editPayload: s__('AlertSettings|Edit payload'), - parsePayload: s__('AlertSettings|Parse payload for custom mapping'), + parsePayload: s__('AlertSettings|Parse payload fields'), payloadParsedSucessMsg: s__( 'AlertSettings|Sample payload has been parsed. You can now map the fields.', ), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__('AlertSettings|If you edit the payload, you must re-map the fields again.'), + resetOk: s__('AlertSettings|Proceed with editing'), + mapIntro: s__( + 'AlertSettings|You can map default GitLab alert fields to your payload keys in the dropdowns below.', + ), }, - mapFields: { - label: s__('AlertSettings|Customize alert payload mapping (optional)'), - intro: s__( - 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.', + testPayload: { + help: s__( + 'AlertSettings|Enter an example payload from your selected monitoring tool. This supports sending alerts to a GitLab endpoint.', ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + modalTitle: s__('AlertSettings|The form has unsaved changes'), + modalBody: s__('AlertSettings|The form has unsaved changes. How would you like to proceed?'), + savedAndTest: s__('AlertSettings|Save integration & send'), + proceedWithoutSave: s__('AlertSettings|Send without saving'), + cancel: __('Cancel'), }, prometheusFormUrl: { label: s__('AlertSettings|Prometheus API base URL'), - help: s__('AlertSettings|URL cannot be blank and must start with http or https'), + help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'), + blankUrlError: __('URL cannot be blank'), + invalidUrlError: __('URL is invalid'), }, restKeyInfo: { label: s__( - 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + 'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.', ), }, }, saveIntegration: s__('AlertSettings|Save integration'), - changesSaved: s__('AlertSettings|Your integration was successfully updated.'), + saveAndTestIntegration: s__('AlertSettings|Save & create test alert'), cancelAndClose: __('Cancel and close'), - send: s__('AlertSettings|Send'), + send: __('Send'), copy: __('Copy'), + integrationCreated: { + title: s__('AlertSettings|Integration successfully saved'), + successMsg: s__( + 'AlertSettings|GitLab has created a URL and authorization key for your integration. You can use them to set up a webhook and authorize your endpoint to send alerts to GitLab.', + ), + btnCaption: s__('AlertSettings|View URL and authorization key'), + }, + changesSaved: s__('AlertsIntegrations|The integration is saved.'), + integrationRemoved: s__('AlertsIntegrations|The integration is deleted.'), + alertSent: s__('AlertsIntegrations|The test alert should now be visible in your alerts list.'), + addNewIntegration: s__('AlertSettings|Add new integration'), }; export const integrationSteps = { selectType: 'SELECT_TYPE', nameIntegration: 'NAME_INTEGRATION', - setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL', - setSamplePayload: 'SET_SAMPLE_PAYLOAD', + enableHttpIntegration: 'ENABLE_HTTP_INTEGRATION', + enablePrometheusIntegration: 'ENABLE_PROMETHEUS_INTEGRATION', customizeMapping: 'CUSTOMIZE_MAPPING', }; export const createStepNumbers = { [integrationSteps.selectType]: 1, [integrationSteps.nameIntegration]: 2, - [integrationSteps.setPrometheusApiUrl]: 2, - [integrationSteps.setSamplePayload]: 3, + [integrationSteps.enableHttpIntegration]: 3, + [integrationSteps.enablePrometheusIntegration]: 2, [integrationSteps.customizeMapping]: 4, }; export const editStepNumbers = { - [integrationSteps.selectType]: 1, [integrationSteps.nameIntegration]: 1, - [integrationSteps.setPrometheusApiUrl]: null, - [integrationSteps.setSamplePayload]: 2, + [integrationSteps.enableHttpIntegration]: 2, + [integrationSteps.enablePrometheusIntegration]: null, [integrationSteps.customizeMapping]: 3, }; export const integrationTypes = { none: { value: '', text: s__('AlertSettings|Select integration type') }, http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, - prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, + prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|Prometheus') }, }; export const typeSet = { @@ -127,4 +149,10 @@ export const mappingFields = { fallback: 'fallback', }; -export const viewCredentialsTabIndex = 1; +export const tabIndices = { + configureDetails: 0, + viewCredentials: 1, + sendTestAlert: 2, +}; + +export const testAlertModalId = 'confirmSendTestAlert'; diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 321af9fedb6..953a867b2b7 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -3,12 +3,15 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue'; import apolloProvider from './graphql'; +import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; -apolloProvider.clients.defaultClient.cache.writeData({ +apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getCurrentIntegrationQuery, data: { currentIntegration: null, }, }); + Vue.use(GlToast); export default (el) => { @@ -16,23 +19,7 @@ export default (el) => { return null; } - const { - prometheusActivated, - prometheusUrl, - prometheusAuthorizationKey, - prometheusFormPath, - prometheusResetKeyPath, - prometheusApiUrl, - activated: activatedStr, - alertsSetupUrl, - alertsUsageUrl, - formPath, - authorizationKey, - url, - projectPath, - multiIntegrations, - alertFields, - } = el.dataset; + const { alertsUsageUrl, projectPath, multiIntegrations, alertFields } = el.dataset; return new Vue({ el, @@ -40,22 +27,7 @@ export default (el) => { AlertSettingsWrapper, }, provide: { - prometheus: { - active: parseBoolean(prometheusActivated), - url: prometheusUrl, - token: prometheusAuthorizationKey, - prometheusFormPath, - prometheusResetKeyPath, - prometheusApiUrl, - }, - generic: { - alertsSetupUrl, - alertsUsageUrl, - active: parseBoolean(activatedStr), - formPath, - token: authorizationKey, - url, - }, + alertsUsageUrl, projectPath, multiIntegrations: parseBoolean(multiIntegrations), }, diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js index e380257f983..9a0644b4e22 100644 --- a/app/assets/javascripts/alerts_settings/utils/error_messages.js +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const DELETE_INTEGRATION_ERROR = s__( 'AlertsIntegrations|The integration could not be deleted. Please try again.', @@ -19,3 +19,9 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__( export const INTEGRATION_PAYLOAD_TEST_ERROR = s__( 'AlertsIntegrations|Integration payload is invalid.', ); + +export const INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR = s__( + 'AlertsIntegrations|The integration is currently inactive. Enable the integration to send the test alert.', +); + +export const DEFAULT_ERROR = __('Something went wrong on our end.'); |