Commit 58314d40 authored by Justin Ho Tuan Duong's avatar Justin Ho Tuan Duong Committed by Peter Hegman

Add frontend for connection section

When the integrationFormSections feature flag is
enabled and `sections` is provided from the backend,
we now add a separate "Connection details" section.

For now, this section will include ActiveCheckbox and
some of the DynamicFields which have been filtered
by the backend.

We use dynamic imports here since not all integrations
have this sections defined yet and there will be more sections
that are integration specific (for example, Jira).
parent b8965389
...@@ -25,3 +25,11 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s ...@@ -25,3 +25,11 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s
export const settingsTabTitle = __('Settings'); export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings'); export const overridesTabTitle = s__('Integrations|Projects using custom settings');
export const integrationFormSections = {
CONNECTION: 'connection',
};
export const integrationFormSectionComponents = {
[integrationFormSections.CONNECTION]: 'IntegrationSectionConnection',
};
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
I18N_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE,
integrationLevels, integrationLevels,
integrationFormSectionComponents,
} from '~/integrations/constants'; } from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
...@@ -34,6 +35,10 @@ export default { ...@@ -34,6 +35,10 @@ export default {
DynamicField, DynamicField,
ConfirmationModal, ConfirmationModal,
ResetConfirmationModal, ResetConfirmationModal,
IntegrationSectionConnection: () =>
import(
/* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
),
GlButton, GlButton,
GlForm, GlForm,
}, },
...@@ -80,9 +85,23 @@ export default { ...@@ -80,9 +85,23 @@ export default {
disableButtons() { disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting); return Boolean(this.isSaving || this.isResetting || this.isTesting);
}, },
sectionsEnabled() {
return this.glFeatures.integrationFormSections;
},
hasSections() {
return this.sectionsEnabled && this.customState.sections.length !== 0;
},
fieldsWithoutSection() {
return this.sectionsEnabled
? this.propsSource.fields.filter((field) => !field.section)
: this.propsSource.fields;
},
}, },
methods: { methods: {
...mapActions(['setOverride', 'requestJiraIssueTypes']), ...mapActions(['setOverride', 'requestJiraIssueTypes']),
fieldsForSection(section) {
return this.propsSource.fields.filter((field) => field.section === section.type);
},
form() { form() {
return this.$refs.integrationForm.$el; return this.$refs.integrationForm.$el;
}, },
...@@ -158,6 +177,7 @@ export default { ...@@ -158,6 +177,7 @@ export default {
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
}, },
csrf, csrf,
integrationFormSectionComponents,
}; };
</script> </script>
...@@ -186,15 +206,40 @@ export default { ...@@ -186,15 +206,40 @@ export default {
@change="setOverride" @change="setOverride"
/> />
<template v-if="hasSections">
<div
v-for="section in customState.sections"
:key="section.type"
class="gl-border-b gl-mb-5"
data-testid="integration-section"
>
<div class="row">
<div class="col-lg-4">
<h4 class="gl-mt-0">{{ section.title }}</h4>
<p v-safe-html="section.description"></p>
</div>
<div class="col-lg-8">
<component
:is="$options.integrationFormSectionComponents[section.type]"
:fields="fieldsForSection(section)"
:is-validated="isValidated"
@toggle-integration-active="onToggleIntegrationState"
/>
</div>
</div>
</div>
</template>
<div class="row"> <div class="row">
<div class="col-lg-4"></div> <div class="col-lg-4"></div>
<div class="col-lg-8"> <div class="col-lg-8">
<!-- helpHtml is trusted input --> <!-- helpHtml is trusted input -->
<div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> <div v-if="helpHtml && !hasSections" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div>
<active-checkbox <active-checkbox
v-if="propsSource.showActive" v-if="propsSource.showActive && !hasSections"
:key="`${currentKey}-active-checkbox`" :key="`${currentKey}-active-checkbox`"
@toggle-integration-active="onToggleIntegrationState" @toggle-integration-active="onToggleIntegrationState"
/> />
...@@ -211,7 +256,7 @@ export default { ...@@ -211,7 +256,7 @@ export default {
:type="propsSource.type" :type="propsSource.type"
/> />
<dynamic-field <dynamic-field
v-for="field in propsSource.fields" v-for="field in fieldsWithoutSection"
:key="`${currentKey}-${field.name}`" :key="`${currentKey}-${field.name}`"
v-bind="field" v-bind="field"
:is-validated="isValidated" :is-validated="isValidated"
......
<script>
import { mapGetters } from 'vuex';
import ActiveCheckbox from '../active_checkbox.vue';
import DynamicField from '../dynamic_field.vue';
export default {
name: 'IntegrationSectionConnection',
components: {
ActiveCheckbox,
DynamicField,
},
props: {
fields: {
type: Array,
required: false,
default: () => [],
},
isValidated: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapGetters(['currentKey', 'propsSource']),
},
};
</script>
<template>
<div>
<active-checkbox
v-if="propsSource.showActive"
:key="`${currentKey}-active-checkbox`"
@toggle-integration-active="$emit('toggle-integration-active', $event)"
/>
<dynamic-field
v-for="field in fields"
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
/>
</div>
</template>
...@@ -14,6 +14,8 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field ...@@ -14,6 +14,8 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
import { import {
integrationLevels, integrationLevels,
I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE,
...@@ -22,7 +24,7 @@ import { ...@@ -22,7 +24,7 @@ import {
import { createStore } from '~/integrations/edit/store'; import { createStore } from '~/integrations/edit/store';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { mockIntegrationProps, mockField } from '../mock_data'; import { mockIntegrationProps, mockField, mockSectionConnection } from '../mock_data';
jest.mock('@sentry/browser'); jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/url_utility');
...@@ -78,6 +80,11 @@ describe('IntegrationForm', () => { ...@@ -78,6 +80,11 @@ describe('IntegrationForm', () => {
const findGlForm = () => wrapper.findComponent(GlForm); const findGlForm = () => wrapper.findComponent(GlForm);
const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); const findRedirectToField = () => wrapper.findByTestId('redirect-to-field');
const findDynamicField = () => wrapper.findComponent(DynamicField); const findDynamicField = () => wrapper.findComponent(DynamicField);
const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
const findAllSections = () => wrapper.findAllByTestId('integration-section');
const findConnectionSection = () => findAllSections().at(0);
const findConnectionSectionComponent = () =>
findConnectionSection().findComponent(IntegrationSectionConnection);
beforeEach(() => { beforeEach(() => {
mockAxios = new MockAdapter(axios); mockAxios = new MockAdapter(axios);
...@@ -253,23 +260,32 @@ describe('IntegrationForm', () => { ...@@ -253,23 +260,32 @@ describe('IntegrationForm', () => {
}); });
describe('fields is present', () => { describe('fields is present', () => {
it('renders DynamicField for each field', () => { it('renders DynamicField for each field without a section', () => {
const fields = [ const sectionFields = [
{ name: 'username', type: 'text' }, { name: 'username', type: 'text', section: mockSectionConnection.type },
{ name: 'API token', type: 'password' }, { name: 'API token', type: 'password', section: mockSectionConnection.type },
];
const nonSectionFields = [
{ name: 'branch', type: 'text' },
{ name: 'labels', type: 'select' },
]; ];
createComponent({ createComponent({
provide: {
glFeatures: { integrationFormSections: true },
},
customStateProps: { customStateProps: {
fields, sections: [mockSectionConnection],
fields: [...sectionFields, ...nonSectionFields],
}, },
}); });
const dynamicFields = wrapper.findAll(DynamicField); const dynamicFields = findAllDynamicFields();
expect(dynamicFields).toHaveLength(2); expect(dynamicFields).toHaveLength(2);
dynamicFields.wrappers.forEach((field, index) => { dynamicFields.wrappers.forEach((field, index) => {
expect(field.props()).toMatchObject(fields[index]); expect(field.props()).toMatchObject(nonSectionFields[index]);
}); });
}); });
}); });
...@@ -344,6 +360,83 @@ describe('IntegrationForm', () => { ...@@ -344,6 +360,83 @@ describe('IntegrationForm', () => {
}); });
}); });
describe('when integration has sections', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { integrationFormSections: true },
},
customStateProps: {
sections: [mockSectionConnection],
},
});
});
it('renders the expected number of sections', () => {
expect(findAllSections().length).toBe(1);
});
it('renders title, description and the correct dynamic component', () => {
const connectionSection = findConnectionSection();
expect(connectionSection.find('h4').text()).toBe(mockSectionConnection.title);
expect(connectionSection.find('p').text()).toBe(mockSectionConnection.description);
expect(findConnectionSectionComponent().exists()).toBe(true);
});
it('passes only fields with section type', () => {
const sectionFields = [
{ name: 'username', type: 'text', section: mockSectionConnection.type },
{ name: 'API token', type: 'password', section: mockSectionConnection.type },
];
const nonSectionFields = [
{ name: 'branch', type: 'text' },
{ name: 'labels', type: 'select' },
];
createComponent({
provide: {
glFeatures: { integrationFormSections: true },
},
customStateProps: {
sections: [mockSectionConnection],
fields: [...sectionFields, ...nonSectionFields],
},
});
expect(findConnectionSectionComponent().props('fields')).toEqual(sectionFields);
});
describe.each`
formActive | novalidate
${true} | ${undefined}
${false} | ${'true'}
`(
'when `toggle-integration-active` is emitted with $formActive',
({ formActive, novalidate }) => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { integrationFormSections: true },
},
customStateProps: {
sections: [mockSectionConnection],
showActive: true,
initialActivated: false,
},
});
findConnectionSectionComponent().vm.$emit('toggle-integration-active', formActive);
});
it(`sets noValidate to ${novalidate}`, () => {
expect(findGlForm().attributes('novalidate')).toBe(novalidate);
});
},
);
});
describe('ActiveCheckbox', () => { describe('ActiveCheckbox', () => {
describe.each` describe.each`
showActive showActive
...@@ -368,7 +461,7 @@ describe('IntegrationForm', () => { ...@@ -368,7 +461,7 @@ describe('IntegrationForm', () => {
`( `(
'when `toggle-integration-active` is emitted with $formActive', 'when `toggle-integration-active` is emitted with $formActive',
({ formActive, novalidate }) => { ({ formActive, novalidate }) => {
beforeEach(async () => { beforeEach(() => {
createComponent({ createComponent({
customStateProps: { customStateProps: {
showActive: true, showActive: true,
...@@ -376,7 +469,7 @@ describe('IntegrationForm', () => { ...@@ -376,7 +469,7 @@ describe('IntegrationForm', () => {
}, },
}); });
await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive); findActiveCheckbox().vm.$emit('toggle-integration-active', formActive);
}); });
it(`sets noValidate to ${novalidate}`, () => { it(`sets noValidate to ${novalidate}`, () => {
......
import { shallowMount } from '@vue/test-utils';
import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { createStore } from '~/integrations/edit/store';
import { mockIntegrationProps } from '../../mock_data';
describe('IntegrationSectionConnection', () => {
let wrapper;
const createComponent = ({ customStateProps = {}, props = {} } = {}) => {
const store = createStore({
customState: { ...mockIntegrationProps, ...customStateProps },
});
wrapper = shallowMount(IntegrationSectionConnection, {
propsData: { ...props },
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
describe('template', () => {
describe('ActiveCheckbox', () => {
describe.each`
showActive
${true}
${false}
`('when `showActive` is $showActive', ({ showActive }) => {
it(`${showActive ? 'renders' : 'does not render'} ActiveCheckbox`, () => {
createComponent({
customStateProps: {
showActive,
},
});
expect(findActiveCheckbox().exists()).toBe(showActive);
});
});
});
describe('DynamicField', () => {
it('renders DynamicField for each field', () => {
const fields = [
{ name: 'username', type: 'text' },
{ name: 'API token', type: 'password' },
];
createComponent({
props: {
fields,
},
});
const dynamicFields = findAllDynamicFields();
expect(dynamicFields).toHaveLength(2);
dynamicFields.wrappers.forEach((field, index) => {
expect(field.props()).toMatchObject(fields[index]);
});
});
it('does not render DynamicField when field is empty', () => {
createComponent();
expect(findAllDynamicFields()).toHaveLength(0);
});
});
});
});
...@@ -10,6 +10,7 @@ export const mockIntegrationProps = { ...@@ -10,6 +10,7 @@ export const mockIntegrationProps = {
}, },
jiraIssuesProps: {}, jiraIssuesProps: {},
triggerEvents: [], triggerEvents: [],
sections: [],
fields: [], fields: [],
type: '', type: '',
inheritFromId: 25, inheritFromId: 25,
...@@ -30,3 +31,9 @@ export const mockField = { ...@@ -30,3 +31,9 @@ export const mockField = {
type: 'text', type: 'text',
value: '1', value: '1',
}; };
export const mockSectionConnection = {
type: 'connection',
title: 'Connection details',
description: 'Learn more on how to configure this integration.',
};
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