Commit 9b63a678 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Paul Slaughter

Enhance UX on Environment Form with Loading Icon

The loading icon adds nice feedback to the user that _something_ is
happening, but we only unset it in the error case as the good case (in
both editing and creating) navigate us to a new page anyway.

We also add some clientside validations following the pajamas guideline.

Finally, delete the HAML form. We don't need it anymore.

Changelog: changed
parent 16220bcf
...@@ -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