Commit 1bbf8574 authored by Ash McKenzie's avatar Ash McKenzie

Merge branch 'justin_ho-extract-custom-integration-fields-to-vue' into 'master'

Extract integration custom fields to Vue

See merge request gitlab-org/gitlab!31430
parents c605c798 3aee417d
<script>
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
export default {
name: 'DynamicField',
components: {
GlFormGroup,
GlFormCheckbox,
GlFormInput,
GlFormSelect,
GlFormTextarea,
},
props: {
choices: {
type: Array,
required: false,
default: null,
},
help: {
type: String,
required: false,
default: null,
},
name: {
type: String,
required: true,
},
placeholder: {
type: String,
required: false,
default: null,
},
required: {
type: Boolean,
required: false,
},
title: {
type: String,
required: false,
default: null,
},
type: {
type: String,
required: true,
},
value: {
type: String,
required: false,
default: null,
},
},
data() {
return {
model: this.value,
};
},
computed: {
isCheckbox() {
return this.type === 'checkbox';
},
isPassword() {
return this.type === 'password';
},
isSelect() {
return this.type === 'select';
},
isTextarea() {
return this.type === 'textarea';
},
isNonEmptyPassword() {
return this.isPassword && !isEmpty(this.value);
},
label() {
if (this.isNonEmptyPassword) {
return sprintf(__('Enter new %{field_title}'), {
field_title: this.humanizedTitle,
});
}
return this.humanizedTitle;
},
humanizedTitle() {
return this.title || capitalize(lowerCase(this.name));
},
passwordRequired() {
return isEmpty(this.value) && this.required;
},
options() {
return this.choices.map(choice => {
return {
value: choice[1],
text: choice[0],
};
});
},
fieldId() {
return `service_${this.name}`;
},
fieldName() {
return `service[${this.name}]`;
},
sharedProps() {
return {
id: this.fieldId,
name: this.fieldName,
};
},
},
created() {
if (this.isNonEmptyPassword) {
this.model = null;
}
},
};
</script>
<template>
<gl-form-group :label="label" :label-for="fieldId" :description="help">
<template v-if="isCheckbox">
<input :name="fieldName" type="hidden" value="false" />
<gl-form-checkbox v-model="model" v-bind="sharedProps">
{{ humanizedTitle }}
</gl-form-checkbox>
</template>
<gl-form-select v-else-if="isSelect" v-model="model" v-bind="sharedProps" :options="options" />
<gl-form-textarea
v-else-if="isTextarea"
v-model="model"
v-bind="sharedProps"
:placeholder="placeholder"
:required="required"
/>
<gl-form-input
v-else-if="isPassword"
v-model="model"
v-bind="sharedProps"
:type="type"
autocomplete="new-password"
:placeholder="placeholder"
:required="passwordRequired"
/>
<gl-form-input
v-else
v-model="model"
v-bind="sharedProps"
:type="type"
:placeholder="placeholder"
:required="required"
/>
</gl-form-group>
</template>
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import ActiveToggle from './active_toggle.vue'; import ActiveToggle from './active_toggle.vue';
import JiraTriggerFields from './jira_trigger_fields.vue'; import JiraTriggerFields from './jira_trigger_fields.vue';
import TriggerFields from './trigger_fields.vue'; import TriggerFields from './trigger_fields.vue';
import DynamicField from './dynamic_field.vue';
export default { export default {
name: 'IntegrationForm', name: 'IntegrationForm',
...@@ -9,6 +10,7 @@ export default { ...@@ -9,6 +10,7 @@ export default {
ActiveToggle, ActiveToggle,
JiraTriggerFields, JiraTriggerFields,
TriggerFields, TriggerFields,
DynamicField,
}, },
props: { props: {
activeToggleProps: { activeToggleProps: {
...@@ -28,6 +30,11 @@ export default { ...@@ -28,6 +30,11 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
fields: {
type: Array,
required: false,
default: () => [],
},
type: { type: {
type: String, type: String,
required: true, required: true,
...@@ -46,5 +53,6 @@ export default { ...@@ -46,5 +53,6 @@ export default {
<active-toggle v-if="showActive" v-bind="activeToggleProps" /> <active-toggle v-if="showActive" v-bind="activeToggleProps" />
<jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" /> <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
<trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" /> <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
<dynamic-field v-for="field in fields" :key="field.name" v-bind="field" />
</div> </div>
</template> </template>
...@@ -15,7 +15,7 @@ export default el => { ...@@ -15,7 +15,7 @@ export default el => {
return result; return result;
} }
const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset; const { type, commentDetail, triggerEvents, fields, ...booleanAttributes } = el.dataset;
const { const {
showActive, showActive,
activated, activated,
...@@ -41,6 +41,7 @@ export default el => { ...@@ -41,6 +41,7 @@ export default el => {
initialCommentDetail: commentDetail, initialCommentDetail: commentDetail,
}, },
triggerEvents: JSON.parse(triggerEvents), triggerEvents: JSON.parse(triggerEvents),
fields: JSON.parse(fields),
}, },
}); });
}, },
......
...@@ -98,6 +98,28 @@ module ServicesHelper ...@@ -98,6 +98,28 @@ module ServicesHelper
end end
end end
def integration_form_refactor?
Feature.enabled?(:integration_form_refactor, @project)
end
def trigger_events_for_service
return [] unless integration_form_refactor?
ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json
end
def fields_for_service
return [] unless integration_form_refactor?
ServiceFieldSerializer.new(service: @service).represent(@service.global_fields).to_json
end
def show_service_trigger_events?
return false if @service.is_a?(JiraService) || integration_form_refactor?
@service.configurable_events.present?
end
extend self extend self
end end
......
# frozen_string_literal: true
class ServiceFieldEntity < Grape::Entity
include RequestAwareEntity
expose :type, :name, :title, :placeholder, :required, :choices, :help
expose :value do |field|
# field[:name] is not user input and so can assume is safe
value = service.public_send(field[:name]) # rubocop:disable GitlabSecurity/PublicSend
if field[:type] == 'password' && value.present?
'true'
else
value
end
end
private
def service
request.service
end
end
# frozen_string_literal: true
class ServiceFieldSerializer < BaseSerializer
entity ServiceFieldEntity
end
= form_errors(@service) = form_errors(@service)
- trigger_events = Feature.enabled?(:integration_form_refactor) ? ServiceEventSerializer.new(service: @service).represent(@service.configurable_events).to_json : []
- if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true) - if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true)
= render "projects/services/#{@service.to_param}/help", subject: @service = render "projects/services/#{@service.to_param}/help", subject: @service
...@@ -10,9 +9,9 @@ ...@@ -10,9 +9,9 @@
.service-settings .service-settings
.js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s, .js-vue-integration-settings{ data: { show_active: @service.show_active_box?.to_s, activated: (@service.active || @service.new_record?).to_s, type: @service.to_param, merge_request_events: @service.merge_requests_events.to_s,
commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events } } commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on_event_enabled.to_s, comment_detail: @service.comment_detail, trigger_events: trigger_events_for_service, fields: fields_for_service } }
- if @service.configurable_events.present? && !@service.is_a?(JiraService) && Feature.disabled?(:integration_form_refactor) - if show_service_trigger_events?
.form-group.row .form-group.row
%label.col-form-label.col-sm-2= _('Trigger') %label.col-form-label.col-sm-2= _('Trigger')
...@@ -33,5 +32,6 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on ...@@ -33,5 +32,6 @@ commit_events: @service.commit_events.to_s, enable_comments: @service.comment_on
%p.text-muted %p.text-muted
= @service.class.event_description(event) = @service.class.event_description(event)
- @service.global_fields.each do |field| - unless integration_form_refactor?
= render 'shared/field', form: form, field: field - @service.global_fields.each do |field|
= render 'shared/field', form: form, field: field
...@@ -6,6 +6,8 @@ describe 'Admin activates Prometheus' do ...@@ -6,6 +6,8 @@ describe 'Admin activates Prometheus' do
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
before do before do
stub_feature_flags(integration_form_refactor: false)
sign_in(admin) sign_in(admin)
visit(admin_application_settings_services_path) visit(admin_application_settings_services_path)
......
import { mount } from '@vue/test-utils';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
describe('DynamicField', () => {
let wrapper;
const defaultProps = {
help: 'The URL of the project',
name: 'project_url',
placeholder: 'https://jira.example.com',
title: 'Project URL',
type: 'text',
value: '1',
};
const createComponent = props => {
wrapper = mount(DynamicField, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findGlFormGroup = () => wrapper.find(GlFormGroup);
const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox);
const findGlFormInput = () => wrapper.find(GlFormInput);
const findGlFormSelect = () => wrapper.find(GlFormSelect);
const findGlFormTextarea = () => wrapper.find(GlFormTextarea);
describe('template', () => {
describe('dynamic field', () => {
describe('type is checkbox', () => {
beforeEach(() => {
createComponent({
type: 'checkbox',
});
});
it('renders GlFormCheckbox', () => {
expect(findGlFormCheckbox().exists()).toBe(true);
});
it('does not render other types of input', () => {
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is select', () => {
beforeEach(() => {
createComponent({
type: 'select',
choices: [['all', 'All details'], ['standard', 'Standard']],
});
});
it('renders findGlFormSelect', () => {
expect(findGlFormSelect().exists()).toBe(true);
expect(findGlFormSelect().findAll('option')).toHaveLength(2);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is textarea', () => {
beforeEach(() => {
createComponent({
type: 'textarea',
});
});
it('renders findGlFormTextarea', () => {
expect(findGlFormTextarea().exists()).toBe(true);
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormInput().exists()).toBe(false);
});
});
describe('type is password', () => {
beforeEach(() => {
createComponent({
type: 'password',
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes('type')).toBe('password');
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
describe('type is text', () => {
beforeEach(() => {
createComponent({
type: 'text',
required: true,
});
});
it('renders GlFormInput', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes()).toMatchObject({
type: 'text',
id: 'service_project_url',
name: 'service[project_url]',
placeholder: defaultProps.placeholder,
required: 'required',
});
});
it('does not render other types of input', () => {
expect(findGlFormCheckbox().exists()).toBe(false);
expect(findGlFormSelect().exists()).toBe(false);
expect(findGlFormTextarea().exists()).toBe(false);
});
});
});
describe('help text', () => {
it('renders description with help text', () => {
createComponent();
expect(
findGlFormGroup()
.find('small')
.text(),
).toBe(defaultProps.help);
});
});
describe('label text', () => {
it('renders label with title', () => {
createComponent();
expect(
findGlFormGroup()
.find('label')
.text(),
).toBe(defaultProps.title);
});
describe('for password field with some value (hidden by backend)', () => {
it('renders label with new password title', () => {
createComponent({
type: 'password',
value: 'true',
});
expect(
findGlFormGroup()
.find('label')
.text(),
).toBe(`Enter new ${defaultProps.title}`);
});
});
});
});
});
...@@ -3,6 +3,7 @@ import IntegrationForm from '~/integrations/edit/components/integration_form.vue ...@@ -3,6 +3,7 @@ import IntegrationForm from '~/integrations/edit/components/integration_form.vue
import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; import ActiveToggle from '~/integrations/edit/components/active_toggle.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
describe('IntegrationForm', () => { describe('IntegrationForm', () => {
let wrapper; let wrapper;
...@@ -95,5 +96,25 @@ describe('IntegrationForm', () => { ...@@ -95,5 +96,25 @@ describe('IntegrationForm', () => {
expect(findTriggerFields().props('type')).toBe(type); expect(findTriggerFields().props('type')).toBe(type);
}); });
}); });
describe('fields is present', () => {
it('renders DynamicField for each field', () => {
const fields = [
{ name: 'username', type: 'text' },
{ name: 'API token', type: 'password' },
];
createComponent({
fields,
});
const dynamicFields = wrapper.findAll(DynamicField);
expect(dynamicFields).toHaveLength(2);
dynamicFields.wrappers.forEach((field, index) => {
expect(field.props()).toMatchObject(fields[index]);
});
});
});
}); });
}); });
# frozen_string_literal: true
require 'spec_helper'
describe ServiceFieldEntity do
let(:request) { double('request') }
subject { described_class.new(field, request: request, service: service).as_json }
before do
allow(request).to receive(:service).and_return(service)
end
describe '#as_json' do
context 'Jira Service' do
let(:service) { create(:jira_service) }
context 'field with type text' do
let(:field) { service.global_fields.find { |field| field[:name] == 'username' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'text',
name: 'username',
title: 'Username or Email',
placeholder: 'Use a username for server version and an email for cloud version',
required: true,
choices: nil,
help: nil,
value: 'jira_username'
}
is_expected.to eq(expected_hash)
end
end
context 'field with type password' do
let(:field) { service.global_fields.find { |field| field[:name] == 'password' } }
it 'exposes correct attributes but hides password' do
expected_hash = {
type: 'password',
name: 'password',
title: 'Password or API token',
placeholder: 'Use a password for server version and an API token for cloud version',
required: true,
choices: nil,
help: nil,
value: 'true'
}
is_expected.to eq(expected_hash)
end
end
end
context 'EmailsOnPush Service' do
let(:service) { create(:emails_on_push_service) }
context 'field with type checkbox' do
let(:field) { service.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'checkbox',
name: 'send_from_committer_email',
title: 'Send from committer',
placeholder: nil,
required: nil,
choices: nil,
value: true
}
is_expected.to include(expected_hash)
expect(subject[:help]).to include("Send notifications from the committer's email address if the domain is part of the domain GitLab is running on")
end
end
context 'field with type select' do
let(:field) { service.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
it 'exposes correct attributes' do
expected_hash = {
type: 'select',
name: 'branches_to_be_notified',
title: nil,
placeholder: nil,
required: nil,
choices: [['All branches', 'all'], ['Default branch', 'default'], ['Protected branches', 'protected'], ['Default branch and protected branches', 'default_and_protected']],
help: nil,
value: nil
}
is_expected.to eq(expected_hash)
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