Commit 38c501ee authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'afontaine/enhance-environment-form' into 'master'

Enhance UX on Environment Form with Loading Icon

See merge request gitlab-org/gitlab!66750
parents fdc8979e 9b63a678
...@@ -21,6 +21,7 @@ export default { ...@@ -21,6 +21,7 @@ export default {
name: this.environment.name, name: this.environment.name,
externalUrl: this.environment.external_url, externalUrl: this.environment.external_url,
}, },
loading: false,
}; };
}, },
methods: { methods: {
...@@ -28,6 +29,7 @@ export default { ...@@ -28,6 +29,7 @@ export default {
this.formEnvironment = environment; this.formEnvironment = environment;
}, },
onSubmit() { onSubmit() {
this.loading = true;
axios axios
.put(this.updateEnvironmentPath, { .put(this.updateEnvironmentPath, {
id: this.environment.id, id: this.environment.id,
...@@ -38,6 +40,7 @@ export default { ...@@ -38,6 +40,7 @@ export default {
.catch((error) => { .catch((error) => {
const message = error.response.data.message[0]; const message = error.response.data.message[0];
createFlash({ message }); createFlash({ message });
this.loading = false;
}); });
}, },
}, },
...@@ -48,6 +51,7 @@ export default { ...@@ -48,6 +51,7 @@ export default {
:cancel-path="projectEnvironmentsPath" :cancel-path="projectEnvironmentsPath"
:environment="formEnvironment" :environment="formEnvironment"
:title="__('Edit environment')" :title="__('Edit environment')"
:loading="loading"
@change="onChange" @change="onChange"
@submit="onSubmit" @submit="onSubmit"
/> />
......
...@@ -26,6 +26,11 @@ export default { ...@@ -26,6 +26,11 @@ export default {
required: true, required: true,
type: String, type: String,
}, },
loading: {
required: false,
type: Boolean,
default: false,
},
}, },
i18n: { i18n: {
header: __('Environments'), header: __('Environments'),
...@@ -42,21 +47,26 @@ export default { ...@@ -42,21 +47,26 @@ export default {
helpPagePath: helpPagePath('ci/environments/index.md'), helpPagePath: helpPagePath('ci/environments/index.md'),
data() { data() {
return { return {
errors: { visited: {
name: null, name: null,
url: null, url: null,
}, },
}; };
}, },
computed: {
valid() {
return {
name: this.visited.name && this.environment.name !== '',
url: this.visited.url && isAbsolute(this.environment.externalUrl),
};
},
},
methods: { methods: {
onChange(env) { onChange(env) {
this.$emit('change', env); this.$emit('change', env);
}, },
validateUrl() { visit(field) {
this.errors.url = isAbsolute(this.environment.externalUrl); this.visited[field] = true;
},
validateName() {
this.errors.name = this.environment.name !== '';
}, },
}, },
}; };
...@@ -89,40 +99,45 @@ export default { ...@@ -89,40 +99,45 @@ export default {
<gl-form-group <gl-form-group
:label="$options.i18n.nameLabel" :label="$options.i18n.nameLabel"
label-for="environment_name" label-for="environment_name"
:state="errors.name" :state="valid.name"
:invalid-feedback="$options.i18n.nameFeedback" :invalid-feedback="$options.i18n.nameFeedback"
> >
<gl-form-input <gl-form-input
id="environment_name" id="environment_name"
:value="environment.name" :value="environment.name"
:state="errors.name" :state="valid.name"
name="environment[name]" name="environment[name]"
required required
@input="onChange({ ...environment, name: $event })" @input="onChange({ ...environment, name: $event })"
@blur="validateName" @blur="visit('name')"
/> />
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
:label="$options.i18n.urlLabel" :label="$options.i18n.urlLabel"
:state="errors.url" :state="valid.url"
:invalid-feedback="$options.i18n.urlFeedback" :invalid-feedback="$options.i18n.urlFeedback"
label-for="environment_external_url" label-for="environment_external_url"
> >
<gl-form-input <gl-form-input
id="environment_external_url" id="environment_external_url"
:value="environment.externalUrl" :value="environment.externalUrl"
:state="errors.url" :state="valid.url"
name="environment[external_url]" name="environment[external_url]"
type="url" type="url"
@input="onChange({ ...environment, externalUrl: $event })" @input="onChange({ ...environment, externalUrl: $event })"
@blur="validateUrl" @blur="visit('url')"
/> />
</gl-form-group> </gl-form-group>
<div class="form-actions"> <div class="form-actions">
<gl-button type="submit" variant="confirm" name="commit" class="js-no-auto-disable">{{ <gl-button
$options.i18n.save :loading="loading"
}}</gl-button> type="submit"
variant="confirm"
name="commit"
class="js-no-auto-disable"
>{{ $options.i18n.save }}</gl-button
>
<gl-button :href="cancelPath">{{ $options.i18n.cancel }}</gl-button> <gl-button :href="cancelPath">{{ $options.i18n.cancel }}</gl-button>
</div> </div>
</gl-form> </gl-form>
......
...@@ -15,6 +15,7 @@ export default { ...@@ -15,6 +15,7 @@ export default {
name: '', name: '',
externalUrl: '', externalUrl: '',
}, },
loading: false,
}; };
}, },
methods: { methods: {
...@@ -22,6 +23,7 @@ export default { ...@@ -22,6 +23,7 @@ export default {
this.environment = env; this.environment = env;
}, },
onSubmit() { onSubmit() {
this.loading = true;
axios axios
.post(this.projectEnvironmentsPath, { .post(this.projectEnvironmentsPath, {
name: this.environment.name, name: this.environment.name,
...@@ -31,6 +33,7 @@ export default { ...@@ -31,6 +33,7 @@ export default {
.catch((error) => { .catch((error) => {
const message = error.response.data.message[0]; const message = error.response.data.message[0];
createFlash({ message }); createFlash({ message });
this.loading = false;
}); });
}, },
}, },
...@@ -41,6 +44,7 @@ export default { ...@@ -41,6 +44,7 @@ export default {
:cancel-path="projectEnvironmentsPath" :cancel-path="projectEnvironmentsPath"
:environment="environment" :environment="environment"
:title="__('New environment')" :title="__('New environment')"
:loading="loading"
@change="onChange($event)" @change="onChange($event)"
@submit="onSubmit" @submit="onSubmit"
/> />
......
.row.gl-mt-3.gl-mb-3
.col-lg-3
%h4.gl-mt-0
= _("Environments")
%p
- link_to_read_more = link_to(_("More information"), help_page_path("ci/environments/index.md"))
= _("Environments allow you to track deployments of your application %{link_to_read_more}.").html_safe % { link_to_read_more: link_to_read_more }
= form_for [@project, @environment], html: { class: 'col-lg-9' } do |f|
= form_errors(@environment)
.form-group
= f.label :name, _('Name'), class: 'label-bold'
= f.text_field :name, required: true, class: 'form-control'
.form-group
= f.label :external_url, _('External URL'), class: 'label-bold'
= f.url_field :external_url, class: 'form-control'
.form-actions
= f.submit _('Save'), class: 'gl-button btn btn-confirm'
= link_to _('Cancel'), project_environments_path(@project), class: 'gl-button btn btn-cancel'
...@@ -12470,9 +12470,6 @@ msgstr "" ...@@ -12470,9 +12470,6 @@ msgstr ""
msgid "Environments Dashboard" msgid "Environments Dashboard"
msgstr "" msgstr ""
msgid "Environments allow you to track deployments of your application %{link_to_read_more}."
msgstr ""
msgid "Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}." msgid "Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}."
msgstr "" msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -43,7 +44,9 @@ describe('~/environments/components/edit.vue', () => { ...@@ -43,7 +44,9 @@ describe('~/environments/components/edit.vue', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const fillForm = async (expected, response) => { const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
const submitForm = async (expected, response) => {
mock mock
.onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, { .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, {
name: expected.name, name: expected.name,
...@@ -72,10 +75,20 @@ describe('~/environments/components/edit.vue', () => { ...@@ -72,10 +75,20 @@ describe('~/environments/components/edit.vue', () => {
expect(input().element.value).toBe(value); expect(input().element.value).toBe(value);
}); });
it('shows loader after form is submitted', async () => {
const expected = { name: 'test', url: 'https://google.ca' };
expect(showsLoading()).toBe(false);
await submitForm(expected, [200, { path: '/test' }]);
expect(showsLoading()).toBe(true);
});
it('submits the updated environment on submit', async () => { it('submits the updated environment on submit', async () => {
const expected = { name: 'test', url: 'https://google.ca' }; const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [200, { path: '/test' }]); await submitForm(expected, [200, { path: '/test' }]);
expect(visitUrl).toHaveBeenCalledWith('/test'); expect(visitUrl).toHaveBeenCalledWith('/test');
}); });
...@@ -83,8 +96,9 @@ describe('~/environments/components/edit.vue', () => { ...@@ -83,8 +96,9 @@ describe('~/environments/components/edit.vue', () => {
it('shows errors on error', async () => { it('shows errors on error', async () => {
const expected = { name: 'test', url: 'https://google.ca' }; const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [400, { message: ['name taken'] }]); await submitForm(expected, [400, { message: ['name taken'] }]);
expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
expect(showsLoading()).toBe(false);
}); });
}); });
import { GlLoadingIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import EnvironmentForm from '~/environments/components/environment_form.vue'; import EnvironmentForm from '~/environments/components/environment_form.vue';
jest.mock('~/lib/utils/csrf'); jest.mock('~/lib/utils/csrf');
const DEFAULT_OPTS = { const DEFAULT_PROPS = {
propsData: { environment: { name: '', externalUrl: '' },
environment: { name: '', externalUrl: '' }, title: 'environment',
title: 'environment', cancelPath: '/cancel',
cancelPath: '/cancel',
},
}; };
describe('~/environments/components/form.vue', () => { describe('~/environments/components/form.vue', () => {
let wrapper; let wrapper;
const createWrapper = (opts = {}) => const createWrapper = (propsData = {}) =>
mountExtended(EnvironmentForm, { mountExtended(EnvironmentForm, {
...DEFAULT_OPTS, propsData: {
...opts, ...DEFAULT_PROPS,
...propsData,
},
}); });
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('links to documentation regarding environments', () => { describe('default', () => {
const link = wrapper.findByRole('link', { name: 'More information' }); beforeEach(() => {
expect(link.attributes('href')).toBe('/help/ci/environments/index.md'); wrapper = createWrapper();
}); });
it('links the cancel button to the cancel path', () => {
const cancel = wrapper.findByRole('link', { name: 'Cancel' });
expect(cancel.attributes('href')).toBe(DEFAULT_OPTS.propsData.cancelPath); it('links to documentation regarding environments', () => {
}); const link = wrapper.findByRole('link', { name: 'More information' });
expect(link.attributes('href')).toBe('/help/ci/environments/index.md');
});
describe('name input', () => { it('links the cancel button to the cancel path', () => {
let name; const cancel = wrapper.findByRole('link', { name: 'Cancel' });
beforeEach(() => { expect(cancel.attributes('href')).toBe(DEFAULT_PROPS.cancelPath);
name = wrapper.findByLabelText('Name');
}); });
it('should emit changes to the name', async () => { describe('name input', () => {
await name.setValue('test'); let name;
await name.trigger('blur');
expect(wrapper.emitted('change')).toEqual([[{ name: 'test', externalUrl: '' }]]); beforeEach(() => {
}); name = wrapper.findByLabelText('Name');
});
it('should validate that the name is required', async () => { it('should emit changes to the name', async () => {
await name.setValue(''); await name.setValue('test');
await name.trigger('blur'); await name.trigger('blur');
expect(wrapper.findByText('This field is required').exists()).toBe(true); expect(wrapper.emitted('change')).toEqual([[{ name: 'test', externalUrl: '' }]]);
expect(name.attributes('aria-invalid')).toBe('true'); });
});
});
describe('url input', () => { it('should validate that the name is required', async () => {
let url; await name.setValue('');
await name.trigger('blur');
beforeEach(() => { expect(wrapper.findByText('This field is required').exists()).toBe(true);
url = wrapper.findByLabelText('External URL'); expect(name.attributes('aria-invalid')).toBe('true');
});
}); });
it('should emit changes to the url', async () => { describe('url input', () => {
await url.setValue('https://example.com'); let url;
await url.trigger('blur');
beforeEach(() => {
url = wrapper.findByLabelText('External URL');
});
expect(wrapper.emitted('change')).toEqual([ it('should emit changes to the url', async () => {
[{ name: '', externalUrl: 'https://example.com' }], await url.setValue('https://example.com');
]); await url.trigger('blur');
expect(wrapper.emitted('change')).toEqual([
[{ name: '', externalUrl: 'https://example.com' }],
]);
});
it('should validate that the url is required', async () => {
await url.setValue('example.com');
await url.trigger('blur');
expect(wrapper.findByText('The URL should start with http:// or https://').exists()).toBe(
true,
);
expect(url.attributes('aria-invalid')).toBe('true');
});
}); });
it('should validate that the url is required', async () => { it('submits when the form does', async () => {
await url.setValue('example.com'); await wrapper.findByRole('form', { title: 'environment' }).trigger('submit');
await url.trigger('blur');
expect(wrapper.findByText('The URL should start with http:// or https://').exists()).toBe( expect(wrapper.emitted('submit')).toEqual([[]]);
true,
);
expect(url.attributes('aria-invalid')).toBe('true');
}); });
}); });
it('submits when the form does', async () => { it('shows a loading icon while loading', () => {
await wrapper.findByRole('form', { title: 'environment' }).trigger('submit'); wrapper = createWrapper({ loading: true });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.emitted('submit')).toEqual([[]]);
}); });
}); });
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
...@@ -39,7 +40,9 @@ describe('~/environments/components/new.vue', () => { ...@@ -39,7 +40,9 @@ describe('~/environments/components/new.vue', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const fillForm = async (expected, response) => { const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
const submitForm = async (expected, response) => {
mock mock
.onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, { .onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, {
name: expected.name, name: expected.name,
...@@ -68,10 +71,20 @@ describe('~/environments/components/new.vue', () => { ...@@ -68,10 +71,20 @@ describe('~/environments/components/new.vue', () => {
expect(input().element.value).toBe(value); expect(input().element.value).toBe(value);
}); });
it('shows loader after form is submitted', async () => {
const expected = { name: 'test', url: 'https://google.ca' };
expect(showsLoading()).toBe(false);
await submitForm(expected, [200, { path: '/test' }]);
expect(showsLoading()).toBe(true);
});
it('submits the new environment on submit', async () => { it('submits the new environment on submit', async () => {
const expected = { name: 'test', url: 'https://google.ca' }; const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [200, { path: '/test' }]); await submitForm(expected, [200, { path: '/test' }]);
expect(visitUrl).toHaveBeenCalledWith('/test'); expect(visitUrl).toHaveBeenCalledWith('/test');
}); });
...@@ -79,8 +92,9 @@ describe('~/environments/components/new.vue', () => { ...@@ -79,8 +92,9 @@ describe('~/environments/components/new.vue', () => {
it('shows errors on error', async () => { it('shows errors on error', async () => {
const expected = { name: 'test', url: 'https://google.ca' }; const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [400, { message: ['name taken'] }]); await submitForm(expected, [400, { message: ['name taken'] }]);
expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' }); expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
expect(showsLoading()).toBe(false);
}); });
}); });
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