Commit b21436c7 authored by David O'Regan's avatar David O'Regan

Merge branch '262707-integrate-parsing-of-sample-payload-for-mapping' into 'master'

Parse sample payload integration

See merge request gitlab-org/gitlab!53516
parents 225bfcb5 e9c24b9d
...@@ -7,15 +7,12 @@ import { ...@@ -7,15 +7,12 @@ import {
GlSearchBoxByType, GlSearchBoxByType,
GlTooltipDirective as GlTooltip, GlTooltipDirective as GlTooltip,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { cloneDeep } from 'lodash'; import { cloneDeep, isEqual } from 'lodash';
import Vue from 'vue'; import Vue from 'vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { import { mappingFields } from '../constants';
getMappingData, import { getMappingData, transformForSave } from '../utils/mapping_transformations';
getPayloadFields,
transformForSave,
} from '../utils/mapping_transformations';
export const i18n = { export const i18n = {
columns: { columns: {
...@@ -33,6 +30,7 @@ export const i18n = { ...@@ -33,6 +30,7 @@ export const i18n = {
export default { export default {
i18n, i18n,
mappingFields,
components: { components: {
GlIcon, GlIcon,
GlFormInput, GlFormInput,
...@@ -73,18 +71,15 @@ export default { ...@@ -73,18 +71,15 @@ export default {
}; };
}, },
computed: { computed: {
payloadFields() {
return getPayloadFields(this.parsedPayload);
},
mappingData() { mappingData() {
return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping); return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping);
}, },
hasFallbackColumn() { hasFallbackColumn() {
return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks)); return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks));
}, },
}, },
methods: { methods: {
setMapping(gitlabKey, mappingKey, valueKey) { setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) {
const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey);
const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
Vue.set(this.gitlabFields, fieldIndex, updatedField); Vue.set(this.gitlabFields, fieldIndex, updatedField);
...@@ -100,11 +95,11 @@ export default { ...@@ -100,11 +95,11 @@ export default {
return fields.filter((field) => field.label.toLowerCase().includes(search)); return fields.filter((field) => field.label.toLowerCase().includes(search));
}, },
isSelected(fieldValue, mapping) { isSelected(fieldValue, mapping) {
return fieldValue === mapping; return isEqual(fieldValue, mapping);
}, },
selectedValue(name) { selectedValue(mapping) {
return ( return (
this.payloadFields.find((item) => item.name === name)?.label || this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label ||
this.$options.i18n.makeSelection this.$options.i18n.makeSelection
); );
}, },
...@@ -150,7 +145,7 @@ export default { ...@@ -150,7 +145,7 @@ export default {
:key="gitlabField.name" :key="gitlabField.name"
class="gl-display-table-row" class="gl-display-table-row"
> >
<div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input <gl-form-input
aria-labelledby="gitlabFieldsHeader" aria-labelledby="gitlabFieldsHeader"
disabled disabled
...@@ -164,7 +159,7 @@ export default { ...@@ -164,7 +159,7 @@ export default {
</div> </div>
</div> </div>
<div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-dropdown <gl-dropdown
:disabled="!gitlabField.mappingFields.length" :disabled="!gitlabField.mappingFields.length"
aria-labelledby="parsedFieldsHeader" aria-labelledby="parsedFieldsHeader"
...@@ -175,10 +170,10 @@ export default { ...@@ -175,10 +170,10 @@ export default {
<gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" /> <gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" />
<gl-dropdown-item <gl-dropdown-item
v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)" v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
:key="`${mappingField.name}__mapping`" :key="`${mappingField.path}__mapping`"
:is-checked="isSelected(gitlabField.mapping, mappingField.name)" :is-checked="isSelected(gitlabField.mapping, mappingField.path)"
is-check-item is-check-item
@click="setMapping(gitlabField.name, mappingField.name, 'mapping')" @click="setMapping(gitlabField.name, mappingField.path)"
> >
{{ mappingField.label }} {{ mappingField.label }}
</gl-dropdown-item> </gl-dropdown-item>
...@@ -188,7 +183,7 @@ export default { ...@@ -188,7 +183,7 @@ export default {
</gl-dropdown> </gl-dropdown>
</div> </div>
<div class="gl-display-table-cell gl-py-3 w-30p"> <div class="gl-display-table-cell gl-py-3 gl-w-30p">
<gl-dropdown <gl-dropdown
v-if="Boolean(gitlabField.numberOfFallbacks)" v-if="Boolean(gitlabField.numberOfFallbacks)"
:disabled="!gitlabField.mappingFields.length" :disabled="!gitlabField.mappingFields.length"
...@@ -205,10 +200,12 @@ export default { ...@@ -205,10 +200,12 @@ export default {
gitlabField.fallbackSearchTerm, gitlabField.fallbackSearchTerm,
gitlabField.mappingFields, gitlabField.mappingFields,
)" )"
:key="`${mappingField.name}__fallback`" :key="`${mappingField.path}__fallback`"
:is-checked="isSelected(gitlabField.fallback, mappingField.name)" :is-checked="isSelected(gitlabField.fallback, mappingField.path)"
is-check-item is-check-item
@click="setMapping(gitlabField.name, mappingField.name, 'fallback')" @click="
setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback)
"
> >
{{ mappingField.label }} {{ mappingField.label }}
</gl-dropdown-item> </gl-dropdown-item>
......
...@@ -120,14 +120,17 @@ export default { ...@@ -120,14 +120,17 @@ export default {
const { category, action } = trackAlertIntegrationsViewsOptions; const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action); Tracking.event(category, action);
}, },
setIntegrationToDelete({ name, id }) { setIntegrationToDelete(integration) {
this.integrationToDelete.id = id; this.integrationToDelete = integration;
this.integrationToDelete.name = name;
}, },
deleteIntegration() { deleteIntegration() {
this.$emit('delete-integration', { id: this.integrationToDelete.id }); const { id, type } = this.integrationToDelete;
this.$emit('delete-integration', { id, type });
this.integrationToDelete = { ...integrationToDeleteDefault }; this.integrationToDelete = { ...integrationToDeleteDefault };
}, },
editIntegration({ id, type }) {
this.$emit('edit-integration', { id, type });
},
}, },
}; };
</script> </script>
...@@ -169,7 +172,7 @@ export default { ...@@ -169,7 +172,7 @@ export default {
<template #cell(actions)="{ item }"> <template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3"> <gl-button-group class="gl-ml-3">
<gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> <gl-button icon="pencil" @click="editIntegration(item)" />
<gl-button <gl-button
v-gl-modal.deleteIntegration v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus" :disabled="item.type === $options.typeSet.prometheus"
......
...@@ -12,6 +12,8 @@ import { ...@@ -12,6 +12,8 @@ import {
GlModalDirective, GlModalDirective,
GlToggle, GlToggle,
} from '@gitlab/ui'; } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { isEmpty, omit } from 'lodash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -22,12 +24,9 @@ import { ...@@ -22,12 +24,9 @@ import {
typeSet, typeSet,
} from '../constants'; } from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
import MappingBuilder from './alert_mapping_builder.vue'; import MappingBuilder from './alert_mapping_builder.vue';
import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue';
// Mocks will be removed when integrating with BE is ready
// data format is defined and will be the same as mocked (maybe with some minor changes)
// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
import mockedCustomMapping from './mocks/parsedMapping.json';
export const i18n = { export const i18n = {
integrationFormSteps: { integrationFormSteps: {
...@@ -92,7 +91,6 @@ export const i18n = { ...@@ -92,7 +91,6 @@ export const i18n = {
}; };
export default { export default {
integrationTypes,
placeholders: { placeholders: {
prometheus: targetPrometheusUrlPlaceholder, prometheus: targetPrometheusUrlPlaceholder,
}, },
...@@ -128,6 +126,9 @@ export default { ...@@ -128,6 +126,9 @@ export default {
multiIntegrations: { multiIntegrations: {
default: false, default: false,
}, },
projectPath: {
default: '',
},
}, },
props: { props: {
loading: { loading: {
...@@ -151,18 +152,19 @@ export default { ...@@ -151,18 +152,19 @@ export default {
}, },
data() { data() {
return { return {
selectedIntegration: integrationTypes[0].value, integrationTypesOptions: Object.values(integrationTypes),
selectedIntegration: integrationTypes.none.value,
active: false, active: false,
formVisible: false, formVisible: false,
integrationTestPayload: { integrationTestPayload: {
json: null, json: null,
error: null, error: null,
}, },
resetSamplePayloadConfirmed: false, resetPayloadAndMappingConfirmed: false,
customMapping: null,
mapping: [], mapping: [],
parsingPayload: false, parsingPayload: false,
currentIntegration: null, currentIntegration: null,
parsedPayload: [],
}; };
}, },
computed: { computed: {
...@@ -210,17 +212,11 @@ export default { ...@@ -210,17 +212,11 @@ export default {
this.alertFields?.length this.alertFields?.length
); );
}, },
parsedSamplePayload() {
return this.customMapping?.samplePayload?.payloadAlerFields?.nodes;
},
savedMapping() {
return this.customMapping?.storedMapping?.nodes;
},
hasSamplePayload() { hasSamplePayload() {
return Boolean(this.customMapping?.samplePayload); return this.isValidNonEmptyJSON(this.currentIntegration?.payloadExample);
}, },
canEditPayload() { canEditPayload() {
return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
}, },
isResetAuthKeyDisabled() { isResetAuthKeyDisabled() {
return !this.active && !this.integrationForm.token !== ''; return !this.active && !this.integrationForm.token !== '';
...@@ -240,25 +236,52 @@ export default { ...@@ -240,25 +236,52 @@ export default {
isSelectDisabled() { isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration; return this.currentIntegration !== null || !this.canAddIntegration;
}, },
savedMapping() {
return this.mapping;
},
}, },
watch: { watch: {
currentIntegration(val) { currentIntegration(val) {
if (val === null) { if (val === null) {
return this.reset(); this.reset();
return;
}
const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
this.selectedIntegration = type;
this.active = active;
if (type === typeSet.prometheus) {
this.integrationTestPayload.json = null;
}
if (type === typeSet.http && this.showMappingBuilder) {
this.parsedPayload = payloadAlertFields;
this.integrationTestPayload.json = this.isValidNonEmptyJSON(payloadExample)
? payloadExample
: null;
const mapping = payloadAttributeMappings.map((mappingItem) =>
omit(mappingItem, '__typename'),
);
this.updateMapping(mapping);
} }
this.selectedIntegration = val.type; this.toggleFormVisibility();
this.active = val.active;
if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id);
return this.integrationTypeSelect();
}, },
}, },
methods: { methods: {
integrationTypeSelect() { isValidNonEmptyJSON(JSONString) {
if (this.selectedIntegration === integrationTypes[0].value) { if (JSONString) {
this.formVisible = false; let parsed;
} else { try {
this.formVisible = true; parsed = JSON.parse(JSONString);
} catch (error) {
Sentry.captureException(error);
}
if (parsed) return !isEmpty(parsed);
} }
return false;
},
toggleFormVisibility() {
this.formVisible = this.selectedIntegration !== integrationTypes.none.value;
}, },
submitWithTestPayload() { submitWithTestPayload() {
this.$emit('set-test-alert-payload', this.testAlertPayload); this.$emit('set-test-alert-payload', this.testAlertPayload);
...@@ -269,20 +292,15 @@ export default { ...@@ -269,20 +292,15 @@ export default {
const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping
? { ? {
payloadAttributeMappings: this.mapping, payloadAttributeMappings: this.mapping,
payloadExample: this.integrationTestPayload.json, payloadExample: this.integrationTestPayload.json || '{}',
} }
: {}; : {};
const variables = const variables =
this.selectedIntegration === typeSet.http this.selectedIntegration === typeSet.http
? { ? { name, active: this.active, ...customMappingVariables }
name,
active: this.active,
...customMappingVariables,
}
: { apiUrl, active: this.active }; : { apiUrl, active: this.active };
const integrationPayload = { type: this.selectedIntegration, variables }; const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) { if (this.currentIntegration) {
return this.$emit('update-integration', integrationPayload); return this.$emit('update-integration', integrationPayload);
} }
...@@ -291,11 +309,12 @@ export default { ...@@ -291,11 +309,12 @@ export default {
return this.$emit('create-new-integration', integrationPayload); return this.$emit('create-new-integration', integrationPayload);
}, },
reset() { reset() {
this.selectedIntegration = integrationTypes[0].value; this.selectedIntegration = integrationTypes.none.value;
this.integrationTypeSelect(); this.toggleFormVisibility();
this.resetPayloadAndMapping();
if (this.currentIntegration) { if (this.currentIntegration) {
return this.$emit('clear-current-integration'); return this.$emit('clear-current-integration', { type: this.currentIntegration.type });
} }
return this.resetFormValues(); return this.resetFormValues();
...@@ -332,35 +351,40 @@ export default { ...@@ -332,35 +351,40 @@ export default {
} }
}, },
parseMapping() { parseMapping() {
// TODO: replace with real BE mutation when ready;
this.parsingPayload = true; this.parsingPayload = true;
return new Promise((resolve) => { return this.$apollo
setTimeout(() => resolve(mockedCustomMapping), 1000); .query({
}) query: parseSamplePayloadQuery,
.then((res) => { variables: { projectPath: this.projectPath, payload: this.integrationTestPayload.json },
const mapping = { ...res }; })
delete mapping.storedMapping; .then(
this.customMapping = res; ({
this.integrationTestPayload.json = res?.samplePayload.body; data: {
this.resetSamplePayloadConfirmed = false; project: { alertManagementPayloadFields },
},
}) => {
this.parsedPayload = alertManagementPayloadFields;
this.resetPayloadAndMappingConfirmed = false;
this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg); this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
},
)
.catch(({ message }) => {
this.integrationTestPayload.error = message;
}) })
.finally(() => { .finally(() => {
this.parsingPayload = false; this.parsingPayload = false;
}); });
}, },
getIntegrationMapping() {
// TODO: replace with real BE mutation when ready;
return Promise.resolve(mockedCustomMapping).then((res) => {
this.customMapping = res;
this.integrationTestPayload.json = res?.samplePayload.body;
});
},
updateMapping(mapping) { updateMapping(mapping) {
this.mapping = mapping; this.mapping = mapping;
}, },
resetPayloadAndMapping() {
this.resetPayloadAndMappingConfirmed = true;
this.parsedPayload = [];
this.updateMapping([]);
},
}, },
}; };
</script> </script>
...@@ -377,8 +401,8 @@ export default { ...@@ -377,8 +401,8 @@ export default {
v-model="selectedIntegration" v-model="selectedIntegration"
:disabled="isSelectDisabled" :disabled="isSelectDisabled"
class="mw-100" class="mw-100"
:options="$options.integrationTypes" :options="integrationTypesOptions"
@change="integrationTypeSelect" @change="toggleFormVisibility"
/> />
<div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported"> <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
...@@ -551,7 +575,7 @@ export default { ...@@ -551,7 +575,7 @@ export default {
:title="$options.i18n.integrationFormSteps.step4.resetHeader" :title="$options.i18n.integrationFormSteps.step4.resetHeader"
:ok-title="$options.i18n.integrationFormSteps.step4.resetOk" :ok-title="$options.i18n.integrationFormSteps.step4.resetOk"
ok-variant="danger" ok-variant="danger"
@ok="resetSamplePayloadConfirmed = true" @ok="resetPayloadAndMapping"
> >
{{ $options.i18n.integrationFormSteps.step4.resetBody }} {{ $options.i18n.integrationFormSteps.step4.resetBody }}
</gl-modal> </gl-modal>
...@@ -566,7 +590,7 @@ export default { ...@@ -566,7 +590,7 @@ export default {
> >
<span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span> <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
<mapping-builder <mapping-builder
:parsed-payload="parsedSamplePayload" :parsed-payload="parsedPayload"
:saved-mapping="savedMapping" :saved-mapping="savedMapping"
:alert-fields="alertFields" :alert-fields="alertFields"
@onMappingUpdate="updateMapping" @onMappingUpdate="updateMapping"
......
...@@ -8,15 +8,18 @@ import createPrometheusIntegrationMutation from '../graphql/mutations/create_pro ...@@ -8,15 +8,18 @@ import createPrometheusIntegrationMutation from '../graphql/mutations/create_pro
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_current_http_integration.mutation.graphql';
import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import service from '../services'; import service from '../services';
import { import {
updateStoreAfterIntegrationDelete, updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd, updateStoreAfterIntegrationAdd,
updateStoreAfterHttpIntegrationAdd,
} from '../utils/cache_updates'; } from '../utils/cache_updates';
import { import {
DELETE_INTEGRATION_ERROR, DELETE_INTEGRATION_ERROR,
...@@ -84,6 +87,28 @@ export default { ...@@ -84,6 +87,28 @@ export default {
createFlash({ message: err }); createFlash({ message: err });
}, },
}, },
// TODO: we'll need to update the logic to request specific http integration by its id on edit
// when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674
// currently the request for ALL http integrations is made and on specific integration edit we search it in the list
httpIntegrations: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getHttpIntegrationsQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {};
return {
list,
};
},
error(err) {
createFlash({ message: err });
},
},
currentIntegration: { currentIntegration: {
query: getCurrentIntegrationQuery, query: getCurrentIntegrationQuery,
}, },
...@@ -93,6 +118,7 @@ export default { ...@@ -93,6 +118,7 @@ export default {
isUpdating: false, isUpdating: false,
testAlertPayload: null, testAlertPayload: null,
integrations: {}, integrations: {},
httpIntegrations: {},
currentIntegration: null, currentIntegration: null,
}; };
}, },
...@@ -105,22 +131,28 @@ export default { ...@@ -105,22 +131,28 @@ export default {
}, },
}, },
methods: { methods: {
isHttp(type) {
return type === typeSet.http;
},
createNewIntegration({ type, variables }) { createNewIntegration({ type, variables }) {
const { projectPath } = this; const { projectPath } = this;
const isHttp = this.isHttp(type);
this.isUpdating = true; this.isUpdating = true;
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: mutation: isHttp ? createHttpIntegrationMutation : createPrometheusIntegrationMutation,
type === this.$options.typeSet.http
? createHttpIntegrationMutation
: createPrometheusIntegrationMutation,
variables: { variables: {
...variables, ...variables,
projectPath, projectPath,
}, },
update(store, { data }) { update(store, { data }) {
updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath }); updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
if (isHttp) {
updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, {
projectPath,
});
}
}, },
}) })
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
...@@ -157,10 +189,9 @@ export default { ...@@ -157,10 +189,9 @@ export default {
this.isUpdating = true; this.isUpdating = true;
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: mutation: this.isHttp(type)
type === this.$options.typeSet.http ? updateHttpIntegrationMutation
? updateHttpIntegrationMutation : updatePrometheusIntegrationMutation,
: updatePrometheusIntegrationMutation,
variables: { variables: {
...variables, ...variables,
id: this.currentIntegration.id, id: this.currentIntegration.id,
...@@ -176,7 +207,7 @@ export default { ...@@ -176,7 +207,7 @@ export default {
return this.validateAlertPayload(); return this.validateAlertPayload();
} }
this.clearCurrentIntegration(); this.clearCurrentIntegration({ type });
return createFlash({ return createFlash({
message: this.$options.i18n.changesSaved, message: this.$options.i18n.changesSaved,
...@@ -195,16 +226,13 @@ export default { ...@@ -195,16 +226,13 @@ export default {
this.isUpdating = true; this.isUpdating = true;
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: mutation: this.isHttp(type) ? resetHttpTokenMutation : resetPrometheusTokenMutation,
type === this.$options.typeSet.http
? resetHttpTokenMutation
: resetPrometheusTokenMutation,
variables, variables,
}) })
.then( .then(
({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => { ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
const error = const [error] =
httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0]; httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors;
if (error) { if (error) {
return createFlash({ message: error }); return createFlash({ message: error });
} }
...@@ -214,10 +242,10 @@ export default { ...@@ -214,10 +242,10 @@ export default {
prometheusIntegrationResetToken?.integration; prometheusIntegrationResetToken?.integration;
this.$apollo.mutate({ this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation, mutation: this.isHttp(type)
variables: { ? updateCurrentHttpIntegrationMutation
...integration, : updateCurrentPrometheusIntegrationMutation,
}, variables: integration,
}); });
return createFlash({ return createFlash({
...@@ -233,33 +261,30 @@ export default { ...@@ -233,33 +261,30 @@ export default {
this.isUpdating = false; this.isUpdating = false;
}); });
}, },
editIntegration({ id }) { editIntegration({ id, type }) {
const currentIntegration = this.integrations.list.find( let currentIntegration = this.integrations.list.find((integration) => integration.id === id);
(integration) => integration.id === id, if (this.isHttp(type)) {
); const httpIntegrationMappingData = this.httpIntegrations.list.find(
(integration) => integration.id === id,
);
currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
}
this.$apollo.mutate({ this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation, mutation: this.isHttp(type)
variables: { ? updateCurrentHttpIntegrationMutation
id: currentIntegration.id, : updateCurrentPrometheusIntegrationMutation,
name: currentIntegration.name, variables: currentIntegration,
active: currentIntegration.active,
token: currentIntegration.token,
type: currentIntegration.type,
url: currentIntegration.url,
apiUrl: currentIntegration.apiUrl,
},
}); });
}, },
deleteIntegration({ id }) { deleteIntegration({ id, type }) {
const { projectPath } = this; const { projectPath } = this;
this.isUpdating = true; this.isUpdating = true;
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: destroyHttpIntegrationMutation, mutation: destroyHttpIntegrationMutation,
variables: { variables: { id },
id,
},
update(store, { data }) { update(store, { data }) {
updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath }); updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath });
}, },
...@@ -269,7 +294,7 @@ export default { ...@@ -269,7 +294,7 @@ export default {
if (error) { if (error) {
return createFlash({ message: error }); return createFlash({ message: error });
} }
this.clearCurrentIntegration(); this.clearCurrentIntegration({ type });
return createFlash({ return createFlash({
message: this.$options.i18n.integrationRemoved, message: this.$options.i18n.integrationRemoved,
type: FLASH_TYPES.SUCCESS, type: FLASH_TYPES.SUCCESS,
...@@ -282,9 +307,11 @@ export default { ...@@ -282,9 +307,11 @@ export default {
this.isUpdating = false; this.isUpdating = false;
}); });
}, },
clearCurrentIntegration() { clearCurrentIntegration({ type }) {
this.$apollo.mutate({ this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation, mutation: this.isHttp(type)
? updateCurrentHttpIntegrationMutation
: updateCurrentPrometheusIntegrationMutation,
variables: {}, variables: {},
}); });
}, },
......
{
"samplePayload": {
"body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n",
"payloadAlerFields": {
"nodes": [
{
"path": ["dashboardId"],
"label": "Dashboard Id",
"type": "string"
},
{
"path": ["evalMatches"],
"label": "Eval Matches",
"type": "array"
},
{
"path": ["createdAt"],
"label": "Created At",
"type": "datetime"
},
{
"path": ["imageUrl"],
"label": "Image Url",
"type": "string"
},
{
"path": ["message"],
"label": "Message",
"type": "string"
},
{
"path": ["orgId"],
"label": "Org Id",
"type": "string"
},
{
"path": ["panelId"],
"label": "Panel Id",
"type": "string"
},
{
"path": ["ruleId"],
"label": "Rule Id",
"type": "string"
},
{
"path": ["ruleName"],
"label": "Rule Name",
"type": "string"
},
{
"path": ["ruleUrl"],
"label": "Rule Url",
"type": "string"
},
{
"path": ["state"],
"label": "State",
"type": "string"
},
{
"path": ["title"],
"label": "Title",
"type": "string"
},
{
"path": ["tags", "tag"],
"label": "Tags",
"type": "string"
}
]
}
},
"storedMapping": {
"nodes": [
{
"alertFieldName": "title",
"payloadAlertPaths": "title",
"fallbackAlertPaths": "ruleUrl"
},
{
"alertFieldName": "description",
"payloadAlertPaths": "message"
},
{
"alertFieldName": "hosts",
"payloadAlertPaths": "evalMatches"
},
{
"alertFieldName": "startTime",
"payloadAlertPaths": "createdAt"
}
]
}
}
...@@ -40,11 +40,11 @@ export const i18n = { ...@@ -40,11 +40,11 @@ export const i18n = {
integration: s__('AlertSettings|Integration'), integration: s__('AlertSettings|Integration'),
}; };
export const integrationTypes = [ export const integrationTypes = {
{ value: '', text: s__('AlertSettings|Select integration type') }, none: { value: '', text: s__('AlertSettings|Select integration type') },
{ value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
{ value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
]; };
export const typeSet = { export const typeSet = {
http: 'HTTP', http: 'HTTP',
...@@ -68,3 +68,8 @@ export const trackAlertIntegrationsViewsOptions = { ...@@ -68,3 +68,8 @@ export const trackAlertIntegrationsViewsOptions = {
category: 'Alert Integrations', category: 'Alert Integrations',
action: 'view_alert_integrations_list', action: 'view_alert_integrations_list',
}; };
export const mappingFields = {
mapping: 'mapping',
fallback: 'fallback',
};
...@@ -10,7 +10,18 @@ const resolvers = { ...@@ -10,7 +10,18 @@ const resolvers = {
Mutation: { Mutation: {
updateCurrentIntegration: ( updateCurrentIntegration: (
_, _,
{ id = null, name, active, token, type, url, apiUrl }, {
id = null,
name,
active,
token,
type,
url,
apiUrl,
payloadExample,
payloadAttributeMappings,
payloadAlertFields,
},
{ cache }, { cache },
) => { ) => {
const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery }); const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery });
...@@ -28,6 +39,9 @@ const resolvers = { ...@@ -28,6 +39,9 @@ const resolvers = {
type, type,
url, url,
apiUrl, apiUrl,
payloadExample,
payloadAttributeMappings,
payloadAlertFields,
}; };
} }
}); });
......
#import "./integration_item.fragment.graphql"
#import "./http_integration_payload_data.fragment.graphql"
fragment HttpIntegrationItem on AlertManagementHttpIntegration {
...IntegrationItem
...HttpIntegrationPayloadData
}
fragment HttpIntegrationPayloadData on AlertManagementHttpIntegration {
payloadExample
payloadAttributeMappings {
fieldName
path
type
label
}
payloadAlertFields {
path
type
label
}
}
#import "../fragments/integration_item.fragment.graphql" #import "../fragments/http_integration_item.fragment.graphql"
mutation createHttpIntegration( mutation createHttpIntegration(
$projectPath: ID! $projectPath: ID!
...@@ -18,7 +18,7 @@ mutation createHttpIntegration( ...@@ -18,7 +18,7 @@ mutation createHttpIntegration(
) { ) {
errors errors
integration { integration {
...IntegrationItem ...HttpIntegrationItem
} }
} }
} }
#import "../fragments/integration_item.fragment.graphql" #import "../fragments/http_integration_item.fragment.graphql"
mutation destroyHttpIntegration($id: ID!) { mutation destroyHttpIntegration($id: ID!) {
httpIntegrationDestroy(input: { id: $id }) { httpIntegrationDestroy(input: { id: $id }) {
errors errors
integration { integration {
...IntegrationItem ...HttpIntegrationItem
} }
} }
} }
#import "../fragments/integration_item.fragment.graphql" #import "../fragments/http_integration_item.fragment.graphql"
mutation resetHttpIntegrationToken($id: ID!) { mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) { httpIntegrationResetToken(input: { id: $id }) {
errors errors
integration { integration {
...IntegrationItem ...HttpIntegrationItem
} }
} }
} }
mutation updateCurrentHttpIntegration(
$id: String
$name: String
$active: Boolean
$token: String
$type: String
$url: String
$apiUrl: String
$payloadExample: JsonString
$payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
$payloadAlertFields: [AlertManagementPayloadAlertField!]
) {
updateCurrentIntegration(
id: $id
name: $name
active: $active
token: $token
type: $type
url: $url
apiUrl: $apiUrl
payloadExample: $payloadExample
payloadAttributeMappings: $payloadAttributeMappings
payloadAlertFields: $payloadAlertFields
) @client
}
mutation updateCurrentIntegration( mutation updateCurrentPrometheusIntegration(
$id: String $id: String
$name: String $name: String
$active: Boolean $active: Boolean
...@@ -6,6 +6,7 @@ mutation updateCurrentIntegration( ...@@ -6,6 +6,7 @@ mutation updateCurrentIntegration(
$type: String $type: String
$url: String $url: String
$apiUrl: String $apiUrl: String
$samplePayload: String
) { ) {
updateCurrentIntegration( updateCurrentIntegration(
id: $id id: $id
...@@ -15,5 +16,6 @@ mutation updateCurrentIntegration( ...@@ -15,5 +16,6 @@ mutation updateCurrentIntegration(
type: $type type: $type
url: $url url: $url
apiUrl: $apiUrl apiUrl: $apiUrl
samplePayload: $samplePayload
) @client ) @client
} }
#import "../fragments/integration_item.fragment.graphql" #import "../fragments/http_integration_item.fragment.graphql"
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { mutation updateHttpIntegration(
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { $id: ID!
$name: String!
$active: Boolean!
$payloadExample: JsonString
$payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
) {
httpIntegrationUpdate(
input: {
id: $id
name: $name
active: $active
payloadExample: $payloadExample
payloadAttributeMappings: $payloadAttributeMappings
}
) {
errors errors
integration { integration {
...IntegrationItem ...HttpIntegrationItem
} }
} }
} }
#import "../fragments/http_integration_payload_data.fragment.graphql"
# TODO: this query need to accept http integration id to request a sepcific integration
query getHttpIntegrations($projectPath: ID!) {
project(fullPath: $projectPath) {
alertManagementHttpIntegrations {
nodes {
id
...HttpIntegrationPayloadData
}
}
}
}
query parsePayloadFields($projectPath: ID!, $payload: String!) {
project(fullPath: $projectPath) {
alertManagementPayloadFields(payloadExample: $payload) {
path
label
type
}
}
}
...@@ -60,6 +60,32 @@ const addIntegrationToStore = ( ...@@ -60,6 +60,32 @@ const addIntegrationToStore = (
}); });
}; };
const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => {
const integration = httpIntegrationCreate?.integration;
if (!integration) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.project.alertManagementHttpIntegrations.nodes = [
integration,
...draftData.project.alertManagementHttpIntegrations.nodes,
];
});
store.writeQuery({
query,
variables,
data,
});
};
const onError = (data, message) => { const onError = (data, message) => {
createFlash({ message }); createFlash({ message });
throw new Error(data.errors); throw new Error(data.errors);
...@@ -82,3 +108,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => ...@@ -82,3 +108,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) =>
addIntegrationToStore(store, query, data, variables); addIntegrationToStore(store, query, data, variables);
} }
}; };
export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, ADD_INTEGRATION_ERROR);
} else {
addHttpIntegrationToStore(store, query, data, variables);
}
};
import { isEqual } from 'lodash';
/** /**
* Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any)
* creates an object in a form convenient to build UI && interact with it * creates an object in a form convenient to build UI && interact with it
...@@ -10,16 +11,19 @@ ...@@ -10,16 +11,19 @@
export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
return gitlabFields.map((gitlabField) => { return gitlabFields.map((gitlabField) => {
// find fields from payload that match gitlab alert field by type // find fields from payload that match gitlab alert field by type
const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type)); const mappingFields = payloadFields.filter(({ type }) =>
gitlabField.types.includes(type.toLowerCase()),
);
// find the mapping that was previously stored // find the mapping that was previously stored
const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name); const foundMapping = savedMapping.find(
({ fieldName }) => fieldName.toLowerCase() === gitlabField.name,
const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; );
const { path: mapping, fallbackPath: fallback } = foundMapping || {};
return { return {
mapping: payloadAlertPaths, mapping,
fallback: fallbackAlertPaths, fallback,
searchTerm: '', searchTerm: '',
fallbackSearchTerm: '', fallbackSearchTerm: '',
mappingFields, mappingFields,
...@@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { ...@@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
*/ */
export const transformForSave = (mappingData) => { export const transformForSave = (mappingData) => {
return mappingData.reduce((acc, field) => { return mappingData.reduce((acc, field) => {
const mapped = field.mappingFields.find(({ name }) => name === field.mapping); const mapped = field.mappingFields.find(({ path }) => isEqual(path, field.mapping));
if (mapped) { if (mapped) {
const { path, type, label } = mapped; const { path, type, label } = mapped;
acc.push({ acc.push({
...@@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => { ...@@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => {
return acc; return acc;
}, []); }, []);
}; };
/**
* Adds `name` prop to each provided by BE parsed payload field
* @param {Object} payload - parsed sample payload
*
* @return {Object} same as input with an extra `name` property which basically serves as a key to make a match
*/
export const getPayloadFields = (payload) => {
return payload.map((field) => ({ ...field, name: field.path.join('_') }));
};
import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue';
import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations'; import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import alertFields from '../mocks/alertFields.json'; import alertFields from '../mocks/alert_fields.json';
import parsedMapping from '../mocks/parsed_mapping.json';
describe('AlertMappingBuilder', () => { describe('AlertMappingBuilder', () => {
let wrapper; let wrapper;
...@@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => { ...@@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => {
function mountComponent() { function mountComponent() {
wrapper = shallowMount(AlertMappingBuilder, { wrapper = shallowMount(AlertMappingBuilder, {
propsData: { propsData: {
parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes, parsedPayload: parsedMapping.payloadAlerFields,
savedMapping: parsedMapping.storedMapping.nodes, savedMapping: parsedMapping.payloadAttributeMappings,
alertFields, alertFields,
}, },
}); });
...@@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => { ...@@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => {
const findColumnInRow = (row, column) => const findColumnInRow = (row, column) =>
wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column); wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column);
const getDropdownContent = (dropdown, types) => {
const searchBox = dropdown.findComponent(GlSearchBoxByType);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const mappingOptions = parsedMapping.payloadAlerFields.filter(({ type }) =>
types.includes(type),
);
return { searchBox, dropdownItems, mappingOptions };
};
it('renders column captions', () => { it('renders column captions', () => {
expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle);
expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle);
...@@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => { ...@@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => {
it('renders mapping dropdown for each field', () => { it('renders mapping dropdown for each field', () => {
alertFields.forEach(({ types }, index) => { alertFields.forEach(({ types }, index) => {
const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown);
const searchBox = dropdown.findComponent(GlSearchBoxByType); const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
const mappingOptions = nodes.filter(({ type }) => types.includes(type));
expect(dropdown.exists()).toBe(true); expect(dropdown.exists()).toBe(true);
expect(searchBox.exists()).toBe(true); expect(searchBox.exists()).toBe(true);
...@@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => { ...@@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => {
expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks));
if (numberOfFallbacks) { if (numberOfFallbacks) {
const searchBox = dropdown.findComponent(GlSearchBoxByType); const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
const mappingOptions = nodes.filter(({ type }) => types.includes(type));
expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks));
expect(dropdownItems).toHaveLength(mappingOptions.length); expect(dropdownItems).toHaveLength(mappingOptions.length);
} }
......
...@@ -11,7 +11,8 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -11,7 +11,8 @@ import waitForPromises from 'helpers/wait_for_promises';
import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
import { typeSet } from '~/alerts_settings/constants'; import { typeSet } from '~/alerts_settings/constants';
import alertFields from '../mocks/alertFields.json'; import alertFields from '../mocks/alert_fields.json';
import parsedMapping from '../mocks/parsed_mapping.json';
import { defaultAlertSettingsConfig } from './util'; import { defaultAlertSettingsConfig } from './util';
describe('AlertsSettingsForm', () => { describe('AlertsSettingsForm', () => {
...@@ -39,6 +40,9 @@ describe('AlertsSettingsForm', () => { ...@@ -39,6 +40,9 @@ describe('AlertsSettingsForm', () => {
multiIntegrations, multiIntegrations,
}, },
mocks: { mocks: {
$apollo: {
query: jest.fn(),
},
$toast: { $toast: {
show: mockToastShow, show: mockToastShow,
}, },
...@@ -146,7 +150,7 @@ describe('AlertsSettingsForm', () => { ...@@ -146,7 +150,7 @@ describe('AlertsSettingsForm', () => {
enableIntegration(0, integrationName); enableIntegration(0, integrationName);
const sampleMapping = { field: 'test' }; const sampleMapping = parsedMapping.payloadAttributeMappings;
findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping);
findForm().trigger('submit'); findForm().trigger('submit');
...@@ -157,7 +161,7 @@ describe('AlertsSettingsForm', () => { ...@@ -157,7 +161,7 @@ describe('AlertsSettingsForm', () => {
name: integrationName, name: integrationName,
active: true, active: true,
payloadAttributeMappings: sampleMapping, payloadAttributeMappings: sampleMapping,
payloadExample: null, payloadExample: '{}',
}, },
}, },
]); ]);
...@@ -275,34 +279,47 @@ describe('AlertsSettingsForm', () => { ...@@ -275,34 +279,47 @@ describe('AlertsSettingsForm', () => {
}); });
describe('Test payload section for HTTP integration', () => { describe('Test payload section for HTTP integration', () => {
const validSamplePayload = JSON.stringify(alertFields);
const emptySamplePayload = '{}';
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
multipleHttpIntegrationsCustomMapping: true, multipleHttpIntegrationsCustomMapping: true,
props: { data: {
currentIntegration: { currentIntegration: {
type: typeSet.http, type: typeSet.http,
payloadExample: validSamplePayload,
payloadAttributeMappings: [],
}, },
alertFields, active: false,
resetPayloadAndMappingConfirmed: false,
}, },
props: { alertFields },
}); });
}); });
describe.each` describe.each`
active | resetSamplePayloadConfirmed | disabled active | resetPayloadAndMappingConfirmed | disabled
${true} | ${true} | ${undefined} ${true} | ${true} | ${undefined}
${false} | ${true} | ${'disabled'} ${false} | ${true} | ${'disabled'}
${true} | ${false} | ${'disabled'} ${true} | ${false} | ${'disabled'}
${false} | ${false} | ${'disabled'} ${false} | ${false} | ${'disabled'}
`('', ({ active, resetSamplePayloadConfirmed, disabled }) => { `('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => {
const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; const payloadResetMsg = resetPayloadAndMappingConfirmed
? 'was confirmed'
: 'was not confirmed';
const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled';
const activeState = active ? 'active' : 'not active'; const activeState = active ? 'active' : 'not active';
it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => {
wrapper.setData({ wrapper.setData({
customMapping: { samplePayload: true }, currentIntegration: {
type: typeSet.http,
payloadExample: validSamplePayload,
payloadAttributeMappings: [],
},
active, active,
resetSamplePayloadConfirmed, resetPayloadAndMappingConfirmed,
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled); expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled);
...@@ -311,20 +328,27 @@ describe('AlertsSettingsForm', () => { ...@@ -311,20 +328,27 @@ describe('AlertsSettingsForm', () => {
describe('action buttons for sample payload', () => { describe('action buttons for sample payload', () => {
describe.each` describe.each`
resetSamplePayloadConfirmed | samplePayload | caption resetPayloadAndMappingConfirmed | payloadExample | caption
${false} | ${true} | ${'Edit payload'} ${false} | ${validSamplePayload} | ${'Edit payload'}
${true} | ${false} | ${'Submit payload'} ${true} | ${emptySamplePayload} | ${'Submit payload'}
${true} | ${true} | ${'Submit payload'} ${true} | ${validSamplePayload} | ${'Submit payload'}
${false} | ${false} | ${'Submit payload'} ${false} | ${emptySamplePayload} | ${'Submit payload'}
`('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => {
const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided';
const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; const payloadResetMsg = resetPayloadAndMappingConfirmed
? 'was confirmed'
: 'was not confirmed';
it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => {
wrapper.setData({ wrapper.setData({
selectedIntegration: typeSet.http, selectedIntegration: typeSet.http,
customMapping: { samplePayload }, currentIntegration: {
resetSamplePayloadConfirmed, payloadExample,
type: typeSet.http,
active: true,
payloadAttributeMappings: [],
},
resetPayloadAndMappingConfirmed,
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(findActionBtn().text()).toBe(caption); expect(findActionBtn().text()).toBe(caption);
...@@ -333,16 +357,20 @@ describe('AlertsSettingsForm', () => { ...@@ -333,16 +357,20 @@ describe('AlertsSettingsForm', () => {
}); });
describe('Parsing payload', () => { describe('Parsing payload', () => {
it('displays a toast message on successful parse', async () => { beforeEach(() => {
jest.useFakeTimers();
wrapper.setData({ wrapper.setData({
selectedIntegration: typeSet.http, selectedIntegration: typeSet.http,
customMapping: { samplePayload: false }, resetPayloadAndMappingConfirmed: true,
}); });
await wrapper.vm.$nextTick(); });
it('displays a toast message on successful parse', async () => {
jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({
data: {
project: { alertManagementPayloadFields: [] },
},
});
findActionBtn().vm.$emit('click'); findActionBtn().vm.$emit('click');
jest.advanceTimersByTime(1000);
await waitForPromises(); await waitForPromises();
...@@ -350,6 +378,16 @@ describe('AlertsSettingsForm', () => { ...@@ -350,6 +378,16 @@ describe('AlertsSettingsForm', () => {
'Sample payload has been parsed. You can now map the fields.', 'Sample payload has been parsed. You can now map the fields.',
); );
}); });
it('displays an error message under payload field on unsuccessful parse', async () => {
const errorMessage = 'Error parsing paylod';
jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({ message: errorMessage });
findActionBtn().vm.$emit('click');
await waitForPromises();
expect(findTestPayloadSection().find('.invalid-feedback').text()).toBe(errorMessage);
});
}); });
}); });
......
...@@ -14,6 +14,8 @@ import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutat ...@@ -14,6 +14,8 @@ import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutat
import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
import updateCurrentHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql';
import updateCurrentPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql';
import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql';
...@@ -31,7 +33,8 @@ import { ...@@ -31,7 +33,8 @@ import {
updateHttpVariables, updateHttpVariables,
createPrometheusVariables, createPrometheusVariables,
updatePrometheusVariables, updatePrometheusVariables,
ID, HTTP_ID,
PROMETHEUS_ID,
errorMsg, errorMsg,
getIntegrationsQueryResponse, getIntegrationsQueryResponse,
destroyIntegrationResponse, destroyIntegrationResponse,
...@@ -50,8 +53,30 @@ describe('AlertsSettingsWrapper', () => { ...@@ -50,8 +53,30 @@ describe('AlertsSettingsWrapper', () => {
let fakeApollo; let fakeApollo;
let destroyIntegrationHandler; let destroyIntegrationHandler;
useMockIntersectionObserver(); useMockIntersectionObserver();
const httpMappingData = {
payloadExample: '{"test: : "field"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
};
const httpIntegrations = {
list: [
{
id: mockIntegrations[0].id,
...httpMappingData,
},
{
id: mockIntegrations[1].id,
...httpMappingData,
},
{
id: mockIntegrations[2].id,
httpMappingData,
},
],
};
const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon);
const findIntegrationsList = () => wrapper.findComponent(IntegrationsList);
const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr');
async function destroyHttpIntegration(localWrapper) { async function destroyHttpIntegration(localWrapper) {
...@@ -197,13 +222,13 @@ describe('AlertsSettingsWrapper', () => { ...@@ -197,13 +222,13 @@ describe('AlertsSettingsWrapper', () => {
}); });
wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
type: typeSet.http, type: typeSet.http,
variables: { id: ID }, variables: { id: HTTP_ID },
}); });
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetHttpTokenMutation, mutation: resetHttpTokenMutation,
variables: { variables: {
id: ID, id: HTTP_ID,
}, },
}); });
}); });
...@@ -232,7 +257,7 @@ describe('AlertsSettingsWrapper', () => { ...@@ -232,7 +257,7 @@ describe('AlertsSettingsWrapper', () => {
it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => {
createComponent({ createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[3] },
loading: false, loading: false,
}); });
...@@ -261,13 +286,13 @@ describe('AlertsSettingsWrapper', () => { ...@@ -261,13 +286,13 @@ describe('AlertsSettingsWrapper', () => {
}); });
wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', { wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
type: typeSet.prometheus, type: typeSet.prometheus,
variables: { id: ID }, variables: { id: PROMETHEUS_ID },
}); });
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetPrometheusTokenMutation, mutation: resetPrometheusTokenMutation,
variables: { variables: {
id: ID, id: PROMETHEUS_ID,
}, },
}); });
}); });
...@@ -328,6 +353,42 @@ describe('AlertsSettingsWrapper', () => { ...@@ -328,6 +353,42 @@ describe('AlertsSettingsWrapper', () => {
mock.restore(); mock.restore();
}); });
}); });
it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => {
createComponent({
data: {
integrations: { list: mockIntegrations },
currentIntegration: mockIntegrations[0],
httpIntegrations,
},
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateCurrentHttpIntegrationMutation,
variables: { ...mockIntegrations[0], ...httpMappingData },
});
});
it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => {
createComponent({
data: {
integrations: { list: mockIntegrations },
currentIntegration: mockIntegrations[3],
httpIntegrations,
},
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateCurrentPrometheusIntegrationMutation,
variables: mockIntegrations[3],
});
});
}); });
describe('with mocked Apollo client', () => { describe('with mocked Apollo client', () => {
......
const projectPath = ''; const projectPath = '';
export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; export const HTTP_ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
export const PROMETHEUS_ID = 'gid://gitlab/PrometheusService/12';
export const errorMsg = 'Something went wrong'; export const errorMsg = 'Something went wrong';
export const createHttpVariables = { export const createHttpVariables = {
name: 'Test Pre', name: 'Test Pre',
active: true, active: true,
projectPath, projectPath,
type: 'HTTP',
}; };
export const updateHttpVariables = { export const updateHttpVariables = {
name: 'Test Pre', name: 'Test Pre',
active: true, active: true,
id: ID, id: HTTP_ID,
type: 'HTTP',
}; };
export const createPrometheusVariables = { export const createPrometheusVariables = {
apiUrl: 'https://test-pre.com', apiUrl: 'https://test-pre.com',
active: true, active: true,
projectPath, projectPath,
type: 'PROMETHEUS',
}; };
export const updatePrometheusVariables = { export const updatePrometheusVariables = {
apiUrl: 'https://test-pre.com', apiUrl: 'https://test-pre.com',
active: true, active: true,
id: ID, id: PROMETHEUS_ID,
type: 'PROMETHEUS',
}; };
export const getIntegrationsQueryResponse = { export const getIntegrationsQueryResponse = {
...@@ -99,6 +104,9 @@ export const destroyIntegrationResponse = { ...@@ -99,6 +104,9 @@ export const destroyIntegrationResponse = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf', token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null, apiUrl: null,
payloadExample: '{"field": "value"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
}, },
}, },
}, },
...@@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = { ...@@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf', token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null, apiUrl: null,
payloadExample: '{"field": "value"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
}, },
}, },
}, },
......
{
"payloadAlerFields": [
{
"path": [
"dashboardId"
],
"label": "Dashboard Id",
"type": "string"
},
{
"path": [
"evalMatches"
],
"label": "Eval Matches",
"type": "array"
},
{
"path": [
"createdAt"
],
"label": "Created At",
"type": "datetime"
},
{
"path": [
"imageUrl"
],
"label": "Image Url",
"type": "string"
},
{
"path": [
"message"
],
"label": "Message",
"type": "string"
},
{
"path": [
"orgId"
],
"label": "Org Id",
"type": "string"
},
{
"path": [
"panelId"
],
"label": "Panel Id",
"type": "string"
},
{
"path": [
"ruleId"
],
"label": "Rule Id",
"type": "string"
},
{
"path": [
"ruleName"
],
"label": "Rule Name",
"type": "string"
},
{
"path": [
"ruleUrl"
],
"label": "Rule Url",
"type": "string"
},
{
"path": [
"state"
],
"label": "State",
"type": "string"
},
{
"path": [
"title"
],
"label": "Title",
"type": "string"
},
{
"path": [
"tags",
"tag"
],
"label": "Tags",
"type": "string"
}
],
"payloadAttributeMappings": [
{
"fieldName": "title",
"label": "Title",
"type": "STRING",
"path": ["title"]
},
{
"fieldName": "description",
"label": "description",
"type": "STRING",
"path": ["description"]
},
{
"fieldName": "hosts",
"label": "Host",
"type": "ARRAY",
"path": ["hosts", "host"]
},
{
"fieldName": "startTime",
"label": "Created Atd",
"type": "STRING",
"path": ["time", "createdAt"]
}
]
}
import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations';
import { import alertFields from '../mocks/alert_fields.json';
getMappingData, import parsedMapping from '../mocks/parsed_mapping.json';
getPayloadFields,
transformForSave,
} from '~/alerts_settings/utils/mapping_transformations';
import alertFields from '../mocks/alertFields.json';
describe('Mapping Transformation Utilities', () => { describe('Mapping Transformation Utilities', () => {
const nameField = { const nameField = {
label: 'Name', label: 'Name',
path: ['alert', 'name'], path: ['alert', 'name'],
type: 'string', type: 'STRING',
}; };
const dashboardField = { const dashboardField = {
label: 'Dashboard Id', label: 'Dashboard Id',
path: ['alert', 'dashboardId'], path: ['alert', 'dashboardId'],
type: 'string', type: 'STRING',
}; };
describe('getMappingData', () => { describe('getMappingData', () => {
it('should return mapping data', () => { it('should return mapping data', () => {
const result = getMappingData( const result = getMappingData(
alertFields, alertFields,
getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)), parsedMapping.payloadAlerFields.slice(0, 3),
parsedMapping.storedMapping.nodes.slice(0, 3), parsedMapping.payloadAttributeMappings.slice(0, 3),
); );
result.forEach((data, index) => { result.forEach((data, index) => {
...@@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => { ...@@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => {
const mockMappingData = [ const mockMappingData = [
{ {
name: fieldName, name: fieldName,
mapping: 'alert_name', mapping: ['alert', 'name'],
mappingFields: getPayloadFields([dashboardField, nameField]), mappingFields: [dashboardField, nameField],
}, },
]; ];
const result = transformForSave(mockMappingData); const result = transformForSave(mockMappingData);
...@@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => { ...@@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => {
{ {
name: fieldName, name: fieldName,
mapping: null, mapping: null,
mappingFields: getPayloadFields([nameField, dashboardField]), mappingFields: [nameField, dashboardField],
}, },
]; ];
const result = transformForSave(mockMappingData); const result = transformForSave(mockMappingData);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
describe('getPayloadFields', () => {
it('should add name field to each payload field', () => {
const result = getPayloadFields([nameField, dashboardField]);
expect(result).toEqual([
{ ...nameField, name: 'alert_name' },
{ ...dashboardField, name: 'alert_dashboardId' },
]);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment