Commit 8487fa04 authored by Robert May's avatar Robert May

Merge branch '347506-integration-form-haml-to-vue' into 'master'

Move integration settings form from HAML to Vue

See merge request gitlab-org/gitlab!77934
parents a36e640b ca65f0aa
......@@ -26,3 +26,5 @@ export const I18N_SUCCESSFUL_CONNECTION_MESSAGE = s__('Integrations|Connection s
export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings');
export const INTEGRATION_FORM_SELECTOR = '.js-integration-settings-form';
<script>
import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml, GlForm } from '@gitlab/ui';
import axios from 'axios';
import * as Sentry from '@sentry/browser';
import { mapState, mapActions, mapGetters } from 'vuex';
......@@ -9,9 +9,11 @@ import {
I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
I18N_DEFAULT_ERROR_MESSAGE,
I18N_SUCCESSFUL_CONNECTION_MESSAGE,
INTEGRATION_FORM_SELECTOR,
integrationLevels,
} from '~/integrations/constants';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import csrf from '~/lib/utils/csrf';
import eventHub from '../event_hub';
import { testIntegrationSettings } from '../api';
import ActiveCheckbox from './active_checkbox.vue';
......@@ -35,6 +37,7 @@ export default {
ConfirmationModal,
ResetConfirmationModal,
GlButton,
GlForm,
},
directives: {
GlModal: GlModalDirective,
......@@ -42,10 +45,6 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
formSelector: {
type: String,
required: true,
},
helpHtml: {
type: String,
required: false,
......@@ -84,10 +83,28 @@ export default {
disableButtons() {
return Boolean(this.isSaving || this.isResetting || this.isTesting);
},
useVueForm() {
return this.glFeatures?.vueIntegrationForm;
},
formContainerProps() {
return this.useVueForm
? {
ref: 'integrationForm',
method: 'post',
class: 'gl-mb-3 gl-show-field-errors integration-settings-form',
action: this.propsSource.formPath,
novalidate: !this.integrationActive,
}
: {};
},
formContainer() {
return this.useVueForm ? GlForm : 'div';
},
},
mounted() {
// this form element is defined in Haml
this.form = document.querySelector(this.formSelector);
this.form = this.useVueForm
? this.$refs.integrationForm.$el
: document.querySelector(INTEGRATION_FORM_SELECTOR);
},
methods: {
...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']),
......@@ -152,7 +169,7 @@ export default {
},
onToggleIntegrationState(integrationActive) {
this.integrationActive = integrationActive;
if (!this.form) {
if (!this.form || this.useVueForm) {
return;
}
......@@ -169,11 +186,23 @@ export default {
ADD_TAGS: ['use'], // to support icon SVGs
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
csrf,
};
</script>
<template>
<div class="gl-mb-3">
<component :is="formContainer" v-bind="formContainerProps">
<template v-if="useVueForm">
<input type="hidden" name="_method" value="put" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<input
type="hidden"
name="redirect_to"
:value="propsSource.redirectTo"
data-testid="redirect-to-field"
/>
</template>
<override-dropdown
v-if="defaultState !== null"
:inherit-from-id="defaultState.id"
......@@ -282,5 +311,5 @@ export default {
</div>
</div>
</div>
</div>
</component>
</template>
......@@ -28,9 +28,11 @@ function parseDatasetToProps(data) {
cancelPath,
testPath,
resetPath,
formPath,
vulnerabilitiesIssuetype,
jiraIssueTransitionAutomatic,
jiraIssueTransitionId,
redirectTo,
...booleanAttributes
} = data;
const {
......@@ -57,6 +59,7 @@ function parseDatasetToProps(data) {
canTest,
testPath,
resetPath,
formPath,
triggerFieldsProps: {
initialTriggerCommit: commitEvents,
initialTriggerMergeRequest: mergeRequestEvents,
......@@ -82,10 +85,11 @@ function parseDatasetToProps(data) {
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
id: parseInt(id, 10),
redirectTo,
};
}
export default function initIntegrationSettingsForm(formSelector) {
export default function initIntegrationSettingsForm() {
const customSettingsEl = document.querySelector('.js-vue-integration-settings');
const defaultSettingsEl = document.querySelector('.js-vue-default-integration-settings');
......@@ -115,7 +119,6 @@ export default function initIntegrationSettingsForm(formSelector) {
return createElement(IntegrationForm, {
props: {
helpHtml,
formSelector,
},
});
},
......
import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
initIntegrationSettingsForm('.js-integration-settings-form');
initIntegrationSettingsForm();
const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
......
import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
initIntegrationSettingsForm('.js-integration-settings-form');
initIntegrationSettingsForm();
const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
......
......@@ -2,7 +2,7 @@ import initIntegrationSettingsForm from '~/integrations/edit';
import PrometheusAlerts from '~/prometheus_alerts';
import CustomMetrics from '~/prometheus_metrics/custom_metrics';
initIntegrationSettingsForm('.js-integration-settings-form');
initIntegrationSettingsForm();
const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring';
const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector);
......
......@@ -8,6 +8,9 @@ module Integrations::Actions
include IntegrationsHelper
before_action :integration, only: [:edit, :update, :overrides, :test]
before_action do
push_frontend_feature_flag(:vue_integration_form, current_user, default_enabled: :yaml)
end
urgency :low, [:test]
end
......
......@@ -12,6 +12,9 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :web_hook_logs, only: [:edit, :update]
before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update]
before_action :redirect_deprecated_prometheus_integration, only: [:update]
before_action do
push_frontend_feature_flag(:vue_integration_form, current_user, default_enabled: :yaml)
end
respond_to :html
......
......@@ -90,7 +90,9 @@ module IntegrationsHelper
cancel_path: scoped_integrations_path(project: project, group: group),
can_test: integration.testable?.to_s,
test_path: scoped_test_integration_path(integration, project: project, group: group),
reset_path: scoped_reset_integration_path(integration, group: group)
reset_path: scoped_reset_integration_path(integration, group: group),
form_path: scoped_integration_path(integration, project: project, group: group),
redirect_to: request.referer
}
if integration.is_a?(Integrations::Jira)
......@@ -226,6 +228,10 @@ module IntegrationsHelper
name: integration.to_param
}
end
def vue_integration_form_enabled?
Feature.enabled?(:vue_integration_form, current_user, default_enabled: :yaml)
end
end
IntegrationsHelper.prepend_mod_with('IntegrationsHelper')
......
......@@ -6,8 +6,11 @@
- if integration.operating?
= sprite_icon('check', css_class: 'gl-text-green-500')
= form_for(integration, as: :service, url: scoped_integration_path(integration, project: @project, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_integration_path(@project, integration) } }) do |form|
= render 'shared/service_settings', form: form, integration: integration
- if vue_integration_form_enabled?
= render 'shared/integration_settings', integration: integration
- else
= form_for(integration, as: :service, url: scoped_integration_path(integration, project: @project, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => test_project_integration_path(@project, integration), testid: 'integration-form' } }) do |form|
= render 'shared/integration_settings', form: form, integration: integration
%input{ id: 'services_redirect_to', type: 'hidden', name: 'redirect_to', value: request.referer }
- if lookup_context.template_exists?('show', "projects/services/#{integration.to_param}", true)
......
= form_errors(integration)
.service-settings
%div{ data: { testid: "integration-settings-form" } }
- if @default_integration
.js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group, project: @project) }
.js-vue-integration-settings{ data: integration_form_data(integration, group: @group, project: @project) }
......
- integration = local_assigns.fetch(:integration)
= form_for integration, as: :service, url: scoped_integration_path(integration, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration, group: @group) } } do |form|
= render 'shared/service_settings', form: form, integration: integration
= form_for integration, as: :service, url: scoped_integration_path(integration, group: @group), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'test-url' => scoped_test_integration_path(integration, group: @group), testid: 'integration-form' } } do |form|
= render 'shared/integration_settings', form: form, integration: integration
......@@ -7,4 +7,7 @@
= @integration.title
= render 'shared/integrations/tabs', integration: @integration, active_tab: 'edit' do
- if vue_integration_form_enabled?
= render 'shared/integration_settings', integration: @integration
- else
= render 'shared/integrations/form', integration: @integration
---
name: vue_integration_form
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77934
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350444
milestone: '14.7'
type: development
group: group::integrations
default_enabled: false
......@@ -22,7 +22,7 @@ RSpec.describe 'Slack application' do
it 'I can edit slack integration' do
visit slack_application_form_path
within '.js-integration-settings-form' do
within '[data-testid="integration-settings-form"]' do
click_link 'Edit'
end
......@@ -31,7 +31,7 @@ RSpec.describe 'Slack application' do
expect(page).to have_content('The project alias was updated successfully')
within '.js-integration-settings-form' do
within '[data-testid="integration-settings-form"]' do
expect(page).to have_content('alias-edited')
end
end
......
......@@ -19,4 +19,19 @@ RSpec.describe 'User activates the instance-level Mattermost Slash Command integ
expect(page).to have_link('Settings', href: edit_path)
expect(page).to have_link('Projects using custom settings', href: overrides_path)
end
it 'does not render integration form element' do
expect(page).not_to have_selector('[data-testid="integration-form"]')
end
context 'when `vue_integration_form` feature flag is disabled' do
before do
stub_feature_flags(vue_integration_form: false)
visit_instance_integration('Mattermost slash commands')
end
it 'renders integration form element' do
expect(page).to have_selector('[data-testid="integration-form"]')
end
end
end
......@@ -41,7 +41,7 @@ RSpec.describe 'User activates Jira', :js do
fill_in 'service_password', with: 'password'
click_test_integration
page.within('.service-settings') do
page.within('[data-testid="integration-settings-form"]') do
expect(page).to have_content('This field is required.')
end
end
......
import { GlForm } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import * as Sentry from '@sentry/browser';
import { setHTMLFixture } from 'helpers/fixtures';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
......@@ -36,12 +37,18 @@ describe('IntegrationForm', () => {
let dispatch;
let mockAxios;
let mockForm;
let vueIntegrationFormFeatureFlag;
const createForm = () => {
mockForm = document.createElement('form');
jest.spyOn(document, 'querySelector').mockReturnValue(mockForm);
};
const createComponent = ({
customStateProps = {},
featureFlags = {},
initialState = {},
props = {},
mountFn = shallowMountExtended,
} = {}) => {
const store = createStore({
customState: { ...mockIntegrationProps, ...customStateProps },
......@@ -49,11 +56,12 @@ describe('IntegrationForm', () => {
});
dispatch = jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMountExtended(IntegrationForm, {
propsData: { ...props, formSelector: '.test' },
provide: {
glFeatures: featureFlags,
},
if (!vueIntegrationFormFeatureFlag) {
createForm();
}
wrapper = mountFn(IntegrationForm, {
propsData: { ...props },
store,
stubs: {
OverrideDropdown,
......@@ -67,16 +75,14 @@ describe('IntegrationForm', () => {
show: mockToastShow,
},
},
provide: {
glFeatures: {
vueIntegrationForm: vueIntegrationFormFeatureFlag,
},
},
});
};
const createForm = ({ isValid = true } = {}) => {
mockForm = document.createElement('form');
jest.spyOn(document, 'querySelector').mockReturnValue(mockForm);
jest.spyOn(mockForm, 'checkValidity').mockReturnValue(isValid);
jest.spyOn(mockForm, 'submit');
};
const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown);
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal);
......@@ -88,6 +94,14 @@ describe('IntegrationForm', () => {
const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
const findTriggerFields = () => wrapper.findComponent(TriggerFields);
const findGlForm = () => wrapper.findComponent(GlForm);
const findRedirectToField = () => wrapper.findByTestId('redirect-to-field');
const findFormElement = () => (vueIntegrationFormFeatureFlag ? findGlForm().element : mockForm);
const mockFormFunctions = ({ checkValidityReturn }) => {
jest.spyOn(findFormElement(), 'checkValidity').mockReturnValue(checkValidityReturn);
jest.spyOn(findFormElement(), 'submit');
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
......@@ -223,6 +237,7 @@ describe('IntegrationForm', () => {
createComponent({
customStateProps: { type: 'jira', testPath: '/test' },
mountFn: mountExtended,
});
});
......@@ -341,6 +356,19 @@ describe('IntegrationForm', () => {
});
});
});
describe('when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled', () => {
it('renders hidden fields', () => {
vueIntegrationFormFeatureFlag = true;
createComponent({
customStateProps: {
redirectTo: '/services',
},
});
expect(findRedirectToField().attributes('value')).toBe('/services');
});
});
});
describe('ActiveCheckbox', () => {
......@@ -361,41 +389,57 @@ describe('IntegrationForm', () => {
});
describe.each`
formActive | novalidate
${true} | ${null}
${false} | ${'true'}
formActive | vueIntegrationFormEnabled | novalidate
${true} | ${true} | ${null}
${false} | ${true} | ${'novalidate'}
${true} | ${false} | ${null}
${false} | ${false} | ${'true'}
`(
'when `toggle-integration-active` is emitted with $formActive',
({ formActive, novalidate }) => {
'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled and `toggle-integration-active` is emitted with $formActive',
({ formActive, vueIntegrationFormEnabled, novalidate }) => {
beforeEach(async () => {
createForm();
vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled;
createComponent({
customStateProps: {
showActive: true,
initialActivated: false,
},
mountFn: mountExtended,
});
mockFormFunctions({ checkValidityReturn: false });
await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive);
});
it(`sets noValidate to ${novalidate}`, () => {
expect(mockForm.getAttribute('novalidate')).toBe(novalidate);
expect(findFormElement().getAttribute('novalidate')).toBe(novalidate);
});
},
);
});
describe.each`
vueIntegrationFormEnabled
${true}
${false}
`(
'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled',
({ vueIntegrationFormEnabled }) => {
beforeEach(() => {
vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled;
});
describe('when `save` button is clicked', () => {
describe('buttons', () => {
beforeEach(async () => {
createForm();
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: true,
},
mountFn: mountExtended,
});
await findProjectSaveButton().vm.$emit('click', new Event('click'));
......@@ -419,40 +463,43 @@ describe('IntegrationForm', () => {
'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)',
({ integrationActive, checkValidityReturn }) => {
beforeEach(async () => {
createForm({ isValid: checkValidityReturn });
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: integrationActive,
},
mountFn: mountExtended,
});
mockFormFunctions({ checkValidityReturn });
await findProjectSaveButton().vm.$emit('click', new Event('click'));
});
it('submit form', () => {
expect(mockForm.submit).toHaveBeenCalledTimes(1);
it('submits form', () => {
expect(findFormElement().submit).toHaveBeenCalledTimes(1);
});
},
);
describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => {
beforeEach(async () => {
createForm({ isValid: false });
createComponent({
customStateProps: {
showActive: true,
canTest: true,
initialActivated: true,
},
mountFn: mountExtended,
});
mockFormFunctions({ checkValidityReturn: false });
await findProjectSaveButton().vm.$emit('click', new Event('click'));
});
it('does not submit form', () => {
expect(mockForm.submit).not.toHaveBeenCalled();
expect(findFormElement().submit).not.toHaveBeenCalled();
});
it('sets save button `loading` prop to `false`', () => {
......@@ -472,13 +519,14 @@ describe('IntegrationForm', () => {
describe('when `test` button is clicked', () => {
describe('when form is invalid', () => {
it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => {
createForm({ isValid: false });
createComponent({
customStateProps: {
showActive: true,
canTest: true,
},
mountFn: mountExtended,
});
mockFormFunctions({ checkValidityReturn: false });
findTestButton().vm.$emit('click', new Event('click'));
......@@ -490,14 +538,15 @@ describe('IntegrationForm', () => {
const mockTestPath = '/test';
beforeEach(() => {
createForm({ isValid: true });
createComponent({
customStateProps: {
showActive: true,
canTest: true,
testPath: mockTestPath,
},
mountFn: mountExtended,
});
mockFormFunctions({ checkValidityReturn: true });
});
describe('buttons', () => {
......@@ -548,6 +597,8 @@ describe('IntegrationForm', () => {
});
});
});
},
);
describe('when `reset-confirmation-modal` emits `reset` event', () => {
const mockResetPath = '/reset';
......
......@@ -20,6 +20,12 @@ RSpec.describe IntegrationsHelper do
end
describe '#integration_form_data' do
before do
allow(helper).to receive_messages(
request: double(referer: '/services')
)
end
let(:fields) do
[
:id,
......@@ -39,7 +45,9 @@ RSpec.describe IntegrationsHelper do
:cancel_path,
:can_test,
:test_path,
:reset_path
:reset_path,
:form_path,
:redirect_to
]
end
......@@ -61,6 +69,10 @@ RSpec.describe IntegrationsHelper do
specify do
expect(subject[:reset_path]).to eq(helper.scoped_reset_integration_path(integration))
end
specify do
expect(subject[:redirect_to]).to eq('/services')
end
end
context 'Jira service' do
......
......@@ -20,6 +20,24 @@ RSpec.describe 'projects/services/_form' do
)
end
context 'integrations form' do
it 'does not render form element' do
render
expect(rendered).not_to have_selector('[data-testid="integration-form"]')
end
context 'when vue_integration_form feature flag is disabled' do
before do
stub_feature_flags(vue_integration_form: false)
end
it 'renders form element' do
render
expect(rendered).to have_selector('[data-testid="integration-form"]')
end
context 'commit_events and merge_request_events' do
it 'display merge_request_events and commit_events descriptions' do
allow(Integrations::Redmine).to receive(:supported_events).and_return(%w(commit merge_request))
......@@ -29,4 +47,6 @@ RSpec.describe 'projects/services/_form' do
expect(rendered).to have_css("input[name='redirect_to'][value='/services']", count: 1, visible: false)
end
end
end
end
end
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