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();
}); });
describe('default', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('links to documentation regarding environments', () => { it('links to documentation regarding environments', () => {
const link = wrapper.findByRole('link', { name: 'More information' }); const link = wrapper.findByRole('link', { name: 'More information' });
expect(link.attributes('href')).toBe('/help/ci/environments/index.md'); expect(link.attributes('href')).toBe('/help/ci/environments/index.md');
...@@ -36,7 +38,7 @@ describe('~/environments/components/form.vue', () => { ...@@ -36,7 +38,7 @@ describe('~/environments/components/form.vue', () => {
it('links the cancel button to the cancel path', () => { it('links the cancel button to the cancel path', () => {
const cancel = wrapper.findByRole('link', { name: 'Cancel' }); const cancel = wrapper.findByRole('link', { name: 'Cancel' });
expect(cancel.attributes('href')).toBe(DEFAULT_OPTS.propsData.cancelPath); expect(cancel.attributes('href')).toBe(DEFAULT_PROPS.cancelPath);
}); });
describe('name input', () => { describe('name input', () => {
...@@ -94,4 +96,10 @@ describe('~/environments/components/form.vue', () => { ...@@ -94,4 +96,10 @@ describe('~/environments/components/form.vue', () => {
expect(wrapper.emitted('submit')).toEqual([[]]); expect(wrapper.emitted('submit')).toEqual([[]]);
}); });
});
it('shows a loading icon while loading', () => {
wrapper = createWrapper({ loading: true });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
}); });
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