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 {
name: this.environment.name,
externalUrl: this.environment.external_url,
},
loading: false,
};
},
methods: {
......@@ -28,6 +29,7 @@ export default {
this.formEnvironment = environment;
},
onSubmit() {
this.loading = true;
axios
.put(this.updateEnvironmentPath, {
id: this.environment.id,
......@@ -38,6 +40,7 @@ export default {
.catch((error) => {
const message = error.response.data.message[0];
createFlash({ message });
this.loading = false;
});
},
},
......@@ -48,6 +51,7 @@ export default {
:cancel-path="projectEnvironmentsPath"
:environment="formEnvironment"
:title="__('Edit environment')"
:loading="loading"
@change="onChange"
@submit="onSubmit"
/>
......
......@@ -26,6 +26,11 @@ export default {
required: true,
type: String,
},
loading: {
required: false,
type: Boolean,
default: false,
},
},
i18n: {
header: __('Environments'),
......@@ -42,21 +47,26 @@ export default {
helpPagePath: helpPagePath('ci/environments/index.md'),
data() {
return {
errors: {
visited: {
name: null,
url: null,
},
};
},
computed: {
valid() {
return {
name: this.visited.name && this.environment.name !== '',
url: this.visited.url && isAbsolute(this.environment.externalUrl),
};
},
},
methods: {
onChange(env) {
this.$emit('change', env);
},
validateUrl() {
this.errors.url = isAbsolute(this.environment.externalUrl);
},
validateName() {
this.errors.name = this.environment.name !== '';
visit(field) {
this.visited[field] = true;
},
},
};
......@@ -89,40 +99,45 @@ export default {
<gl-form-group
:label="$options.i18n.nameLabel"
label-for="environment_name"
:state="errors.name"
:state="valid.name"
:invalid-feedback="$options.i18n.nameFeedback"
>
<gl-form-input
id="environment_name"
:value="environment.name"
:state="errors.name"
:state="valid.name"
name="environment[name]"
required
@input="onChange({ ...environment, name: $event })"
@blur="validateName"
@blur="visit('name')"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.urlLabel"
:state="errors.url"
:state="valid.url"
:invalid-feedback="$options.i18n.urlFeedback"
label-for="environment_external_url"
>
<gl-form-input
id="environment_external_url"
:value="environment.externalUrl"
:state="errors.url"
:state="valid.url"
name="environment[external_url]"
type="url"
@input="onChange({ ...environment, externalUrl: $event })"
@blur="validateUrl"
@blur="visit('url')"
/>
</gl-form-group>
<div class="form-actions">
<gl-button type="submit" variant="confirm" name="commit" class="js-no-auto-disable">{{
$options.i18n.save
}}</gl-button>
<gl-button
:loading="loading"
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>
</div>
</gl-form>
......
......@@ -15,6 +15,7 @@ export default {
name: '',
externalUrl: '',
},
loading: false,
};
},
methods: {
......@@ -22,6 +23,7 @@ export default {
this.environment = env;
},
onSubmit() {
this.loading = true;
axios
.post(this.projectEnvironmentsPath, {
name: this.environment.name,
......@@ -31,6 +33,7 @@ export default {
.catch((error) => {
const message = error.response.data.message[0];
createFlash({ message });
this.loading = false;
});
},
},
......@@ -41,6 +44,7 @@ export default {
:cancel-path="projectEnvironmentsPath"
:environment="environment"
:title="__('New environment')"
:loading="loading"
@change="onChange($event)"
@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 ""
msgid "Environments Dashboard"
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}."
msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -43,7 +44,9 @@ describe('~/environments/components/edit.vue', () => {
wrapper.destroy();
});
const fillForm = async (expected, response) => {
const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
const submitForm = async (expected, response) => {
mock
.onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, {
name: expected.name,
......@@ -72,10 +75,20 @@ describe('~/environments/components/edit.vue', () => {
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 () => {
const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [200, { path: '/test' }]);
await submitForm(expected, [200, { path: '/test' }]);
expect(visitUrl).toHaveBeenCalledWith('/test');
});
......@@ -83,8 +96,9 @@ describe('~/environments/components/edit.vue', () => {
it('shows errors on error', async () => {
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(showsLoading()).toBe(false);
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import EnvironmentForm from '~/environments/components/environment_form.vue';
jest.mock('~/lib/utils/csrf');
const DEFAULT_OPTS = {
propsData: {
const DEFAULT_PROPS = {
environment: { name: '', externalUrl: '' },
title: 'environment',
cancelPath: '/cancel',
},
};
describe('~/environments/components/form.vue', () => {
let wrapper;
const createWrapper = (opts = {}) =>
const createWrapper = (propsData = {}) =>
mountExtended(EnvironmentForm, {
...DEFAULT_OPTS,
...opts,
});
beforeEach(() => {
wrapper = createWrapper();
propsData: {
...DEFAULT_PROPS,
...propsData,
},
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
wrapper = createWrapper();
});
it('links to documentation regarding environments', () => {
const link = wrapper.findByRole('link', { name: 'More information' });
expect(link.attributes('href')).toBe('/help/ci/environments/index.md');
......@@ -36,7 +38,7 @@ describe('~/environments/components/form.vue', () => {
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);
expect(cancel.attributes('href')).toBe(DEFAULT_PROPS.cancelPath);
});
describe('name input', () => {
......@@ -94,4 +96,10 @@ describe('~/environments/components/form.vue', () => {
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 { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -39,7 +40,9 @@ describe('~/environments/components/new.vue', () => {
wrapper.destroy();
});
const fillForm = async (expected, response) => {
const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
const submitForm = async (expected, response) => {
mock
.onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, {
name: expected.name,
......@@ -68,10 +71,20 @@ describe('~/environments/components/new.vue', () => {
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 () => {
const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [200, { path: '/test' }]);
await submitForm(expected, [200, { path: '/test' }]);
expect(visitUrl).toHaveBeenCalledWith('/test');
});
......@@ -79,8 +92,9 @@ describe('~/environments/components/new.vue', () => {
it('shows errors on error', async () => {
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(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