diff options
Diffstat (limited to 'app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue')
-rw-r--r-- | app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue | 430 |
1 files changed, 272 insertions, 158 deletions
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> |