Commit c155d2b1 authored by Andrew Fontaine's avatar Andrew Fontaine

Migrate New Environments Form to Vue

To allow for richer frontend validations and to align better with the
pajamas design system, I've migrated the new environments form to vue.

I've decided to do a full migration rather than using
`parseRailsFormFields` because I found out about it too late, although
it would have likely been easier.

The form migration is about as bare-bones as possible, no vuex or other
state management, and I use the existing internal API for speed.

Next is to port the Edit Environment form, which is happening separately
as it might involve a little bit of vuex or other state management. As
it is only 2 fields, I suspect it won't, but we will see.

Changelog: changed
parent 20be4c30
<script>
import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { isAbsolute } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
export default {
components: {
GlButton,
GlForm,
GlFormGroup,
GlFormInput,
GlLink,
GlSprintf,
},
props: {
environment: {
required: true,
type: Object,
},
title: {
required: true,
type: String,
},
cancelPath: {
required: true,
type: String,
},
},
i18n: {
header: __('Environments'),
helpMessage: __(
'Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}.',
),
nameLabel: __('Name'),
nameFeedback: __('This field is required'),
urlLabel: __('External URL'),
urlFeedback: __('The URL should start with http:// or https://'),
save: __('Save'),
cancel: __('Cancel'),
},
helpPagePath: helpPagePath('ci/environments/index.md'),
data() {
return {
errors: {
name: null,
url: null,
},
};
},
methods: {
onChange(env) {
this.$emit('change', env);
},
validateUrl() {
this.errors.url = isAbsolute(this.environment.externalUrl);
},
validateName() {
this.errors.name = this.environment.name !== '';
},
},
};
</script>
<template>
<div>
<h3 class="page-title">
{{ title }}
</h3>
<hr />
<div class="row gl-mt-3 gl-mb-3">
<div class="col-lg-3">
<h4 class="gl-mt-0">
{{ $options.i18n.header }}
</h4>
<p>
<gl-sprintf :message="$options.i18n.helpMessage">
<template #link="{ content }">
<gl-link :href="$options.helpPagePath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</div>
<gl-form
id="new_environment"
:aria-label="title"
class="col-lg-9"
@submit.prevent="$emit('submit')"
>
<gl-form-group
:label="$options.i18n.nameLabel"
label-for="environment_name"
:state="errors.name"
:invalid-feedback="$options.i18n.nameFeedback"
>
<gl-form-input
id="environment_name"
:value="environment.name"
:state="errors.name"
name="environment[name]"
required
@input="onChange({ ...environment, name: $event })"
@blur="validateName"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.urlLabel"
:state="errors.url"
:invalid-feedback="$options.i18n.urlFeedback"
label-for="environment_external_url"
>
<gl-form-input
id="environment_external_url"
:value="environment.externalUrl"
:state="errors.url"
name="environment[external_url]"
type="url"
@input="onChange({ ...environment, externalUrl: $event })"
@blur="validateUrl"
/>
</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 :href="cancelPath">{{ $options.i18n.cancel }}</gl-button>
</div>
</gl-form>
</div>
</div>
</template>
<script>
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
export default {
components: {
EnvironmentForm,
},
inject: ['projectEnvironmentsPath'],
data() {
return {
environment: {
name: '',
externalUrl: '',
},
};
},
methods: {
onChange(env) {
this.environment = env;
},
onSubmit() {
axios
.post(this.projectEnvironmentsPath, {
name: this.environment.name,
external_url: this.environment.externalUrl,
})
.then(({ data: { path } }) => visitUrl(path))
.catch((error) => {
const message = error.response.data.message[0];
createFlash({ message });
});
},
},
};
</script>
<template>
<environment-form
:cancel-path="projectEnvironmentsPath"
:environment="environment"
:title="__('New environment')"
@change="onChange($event)"
@submit="onSubmit"
/>
</template>
import Vue from 'vue';
import NewEnvironment from './components/new_environment.vue';
export default (el) =>
new Vue({
el,
provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath },
render(h) {
return h(NewEnvironment);
},
});
import mountNew from '~/environments/new';
mountNew(document.getElementById('js-new-environment'));
......@@ -87,9 +87,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment = project.environments.create(environment_params)
if @environment.persisted?
redirect_to project_environment_path(project, @environment)
render json: { environment: @environment, path: project_environment_path(project, @environment) }
else
render :new
render json: { message: @environment.errors.full_messages }, status: :bad_request
end
end
......
......@@ -2,7 +2,4 @@
- page_title _("New Environment")
- add_page_specific_style 'page_bundles/environments'
%h3.page-title
= _("New environment")
%hr
= render 'form'
#js-new-environment{ data: { project_environments_path: project_environments_path(@project) } }
......@@ -12403,6 +12403,9 @@ 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 ""
msgid "Environments in %{name}"
msgstr ""
......@@ -32455,6 +32458,9 @@ msgstr ""
msgid "The URL of the Jenkins server."
msgstr ""
msgid "The URL should start with http:// or https://"
msgstr ""
msgid "The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., \"http://localhost:9200, http://localhost:9201\")."
msgstr ""
......@@ -33420,6 +33426,9 @@ msgstr ""
msgid "This feature requires local storage to be enabled"
msgstr ""
msgid "This field is required"
msgstr ""
msgid "This field is required."
msgstr ""
......
......@@ -786,6 +786,31 @@ RSpec.describe Projects::EnvironmentsController do
end
end
describe 'POST #create' do
subject { post :create, params: params }
context "when environment params are valid" do
let(:params) { { namespace_id: project.namespace, project_id: project, environment: { name: 'foo', external_url: 'https://foo.example.com' } } }
it 'returns ok and the path to the newly created environment' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['path']).to eq("/#{project.full_path}/-/environments/#{json_response['environment']['id']}")
end
end
context "when environment params are invalid" do
let(:params) { { namespace_id: project.namespace, project_id: project, environment: { name: 'foo/', external_url: '/foo.example.com' } } }
it 'returns bad request' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
......
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: {
environment: { name: '', externalUrl: '' },
title: 'environment',
cancelPath: '/cancel',
},
};
describe('~/environments/components/form.vue', () => {
let wrapper;
const createWrapper = (opts = {}) =>
mountExtended(EnvironmentForm, {
...DEFAULT_OPTS,
...opts,
});
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it('links to documentation regarding environments', () => {
const link = wrapper.findByRole('link', { name: 'More information' });
expect(link.attributes('href')).toBe('/help/ci/environments/index.md');
});
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);
});
describe('name input', () => {
let name;
beforeEach(() => {
name = wrapper.findByLabelText('Name');
});
it('should emit changes to the name', async () => {
await name.setValue('test');
await name.trigger('blur');
expect(wrapper.emitted('change')).toEqual([[{ name: 'test', externalUrl: '' }]]);
});
it('should validate that the name is required', async () => {
await name.setValue('');
await name.trigger('blur');
expect(wrapper.findByText('This field is required').exists()).toBe(true);
expect(name.attributes('aria-invalid')).toBe('true');
});
});
describe('url input', () => {
let url;
beforeEach(() => {
url = wrapper.findByLabelText('External URL');
});
it('should emit changes to the url', async () => {
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('submits when the form does', async () => {
await wrapper.findByRole('form', { title: 'environment' }).trigger('submit');
expect(wrapper.emitted('submit')).toEqual([[]]);
});
});
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NewEnvironment from '~/environments/components/new_environment.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/flash');
const DEFAULT_OPTS = {
provide: { projectEnvironmentsPath: '/projects/environments' },
};
describe('~/environments/components/new.vue', () => {
let wrapper;
let mock;
let name;
let url;
let form;
const createWrapper = (opts = {}) =>
mountExtended(NewEnvironment, {
...DEFAULT_OPTS,
...opts,
});
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createWrapper();
name = wrapper.findByLabelText('Name');
url = wrapper.findByLabelText('External URL');
form = wrapper.findByRole('form', { name: 'New environment' });
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
const fillForm = async (expected, response) => {
mock
.onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, {
name: expected.name,
external_url: expected.url,
})
.reply(...response);
await name.setValue(expected.name);
await url.setValue(expected.url);
await form.trigger('submit');
await waitForPromises();
};
it('sets the title to New environment', () => {
const header = wrapper.findByRole('heading', { name: 'New environment' });
expect(header.exists()).toBe(true);
});
it.each`
input | value
${() => name} | ${'test'}
${() => url} | ${'https://example.org'}
`('it changes the value of the input to $value', async ({ input, value }) => {
await input().setValue(value);
expect(input().element.value).toBe(value);
});
it('submits the new environment on submit', async () => {
const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [200, { path: '/test' }]);
expect(visitUrl).toHaveBeenCalledWith('/test');
});
it('shows errors on error', async () => {
const expected = { name: 'test', url: 'https://google.ca' };
await fillForm(expected, [400, { message: ['name taken'] }]);
expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
});
});
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