Commit f279d291 authored by Miguel Rincon's avatar Miguel Rincon

Create a Vue form for the runner UI details

This change defines a new form for runners in Vue UI which follows the
design guidelines, this form will later replace the HAML form
to update runner details.
parent b3ebe15e
...@@ -3,7 +3,7 @@ import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
import updateRunnerMutation from '~/runner/graphql/update_runner.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
const i18n = { const i18n = {
I18N_EDIT: __('Edit'), I18N_EDIT: __('Edit'),
...@@ -76,7 +76,7 @@ export default { ...@@ -76,7 +76,7 @@ export default {
runnerUpdate: { errors }, runnerUpdate: { errors },
}, },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: updateRunnerMutation, mutation: runnerUpdateMutation,
variables: { variables: {
input: { input: {
id: this.runner.id, id: this.runner.id,
......
<script>
import { GlAlert, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
const ALERT_DATA = {
[INSTANCE_TYPE]: {
title: s__(
'Runners|This runner is available to all groups and projects in your GitLab instance.',
),
message: s__(
'Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner.',
),
variant: 'success',
anchor: 'shared-runners',
},
[GROUP_TYPE]: {
title: s__('Runners|This runner is available to all projects and subgroups in a group.'),
message: s__(
'Runners|Use Group runners when you want all projects in a group to have access to a set of runners.',
),
variant: 'success',
anchor: 'group-runners',
},
[PROJECT_TYPE]: {
title: s__('Runners|This runner is associated with specific projects.'),
message: s__(
'Runners|You can set up a specific runner to be used by multiple projects but you cannot make this a shared runner.',
),
variant: 'info',
anchor: 'specific-runners',
},
};
export default {
components: {
GlAlert,
GlLink,
},
props: {
type: {
type: String,
required: false,
default: null,
validator(type) {
return Boolean(ALERT_DATA[type]);
},
},
},
computed: {
alert() {
return ALERT_DATA[this.type];
},
helpHref() {
return helpPagePath('ci/runners/runners_scope', { anchor: this.alert.anchor });
},
},
};
</script>
<template>
<gl-alert v-if="alert" :variant="alert.variant" :title="alert.title" :dismissible="false">
{{ alert.message }}
<gl-link :href="helpHref">{{ __('Learn more.') }}</gl-link>
</gl-alert>
</template>
...@@ -3,7 +3,7 @@ import { GlBadge } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlBadge } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
const badge = { const BADGE_DATA = {
[INSTANCE_TYPE]: { [INSTANCE_TYPE]: {
variant: 'success', variant: 'success',
text: s__('Runners|shared'), text: s__('Runners|shared'),
...@@ -25,21 +25,22 @@ export default { ...@@ -25,21 +25,22 @@ export default {
props: { props: {
type: { type: {
type: String, type: String,
required: true, required: false,
default: null,
validator(type) {
return Boolean(BADGE_DATA[type]);
},
}, },
}, },
computed: { computed: {
variant() { badge() {
return badge[this.type]?.variant; return BADGE_DATA[this.type];
},
text() {
return badge[this.type]?.text;
}, },
}, },
}; };
</script> </script>
<template> <template>
<gl-badge v-if="text" :variant="variant" v-bind="$attrs"> <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs">
{{ text }} {{ badge.text }}
</gl-badge> </gl-badge>
</template> </template>
<script>
import {
GlButton,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInputGroup,
GlTooltipDirective,
} from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
import { __ } from '~/locale';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
const runnerToModel = (runner) => {
const {
id,
description,
maximumTimeout,
accessLevel,
active,
locked,
runUntagged,
tagList = [],
} = runner || {};
return {
id,
description,
maximumTimeout,
accessLevel,
active,
locked,
runUntagged,
tagList: tagList.join(', '),
};
};
export default {
components: {
GlButton,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInputGroup,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
runner: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
saving: false,
model: runnerToModel(this.runner),
};
},
computed: {
canBeLockedToProject() {
return this.runner?.runnerType === PROJECT_TYPE;
},
readonlyIpAddress() {
return this.runner?.ipAddress;
},
updateMutationInput() {
const { maximumTimeout, tagList } = this.model;
return {
...this.model,
maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null,
tagList: tagList
.split(',')
.map((tag) => tag.trim())
.filter((tag) => Boolean(tag)),
};
},
},
watch: {
runner(newVal, oldVal) {
if (oldVal === null) {
this.model = runnerToModel(newVal);
}
},
},
methods: {
async onSubmit() {
this.saving = true;
try {
const {
data: {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerUpdateMutation,
variables: {
input: this.updateMutationInput,
},
});
if (errors?.length) {
this.onError(new Error(errors[0]));
return;
}
this.onSuccess();
} catch (e) {
this.onError(e);
} finally {
this.saving = false;
}
},
onError(error) {
const { message } = error;
createFlash({ message });
},
onSuccess() {
createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS });
this.model = runnerToModel(this.runner);
},
},
ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED,
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<gl-form-checkbox
v-model="model.active"
data-testid="runner-field-paused"
:value="false"
:unchecked-value="true"
>
{{ __('Paused') }}
<template #help>
{{ __("Paused runners don't accept new jobs") }}
</template>
</gl-form-checkbox>
<gl-form-checkbox
v-model="model.accessLevel"
data-testid="runner-field-protected"
:value="$options.ACCESS_LEVEL_REF_PROTECTED"
:unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
>
{{ __('Protected') }}
<template #help>
{{ __('This runner will only run on pipelines triggered on protected branches') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged">
{{ __('Run untagged jobs') }}
<template #help>
{{ __('Indicates whether this runner can pick jobs without tags') }}
</template>
</gl-form-checkbox>
<gl-form-checkbox
v-model="model.locked"
data-testid="runner-field-locked"
:disabled="!canBeLockedToProject"
>
{{ __('Lock to current projects') }}
<template #help>
{{ __('When a runner is locked, it cannot be assigned to other projects') }}
</template>
</gl-form-checkbox>
<gl-form-group :label="__('IP Address')" data-testid="runner-field-ip-address">
<gl-form-input-group :value="readonlyIpAddress" readonly select-on-click>
<template #append>
<gl-button
v-gl-tooltip.hover
:title="__('Copy IP Address')"
:aria-label="__('Copy IP Address')"
:data-clipboard-text="readonlyIpAddress"
icon="copy-to-clipboard"
class="d-inline-flex"
/>
</template>
</gl-form-input-group>
</gl-form-group>
<gl-form-group :label="__('Description')" data-testid="runner-field-description">
<gl-form-input-group v-model="model.description" />
</gl-form-group>
<gl-form-group
data-testid="runner-field-max-timeout"
:label="__('Maximum job timeout')"
:description="
s__(
'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.',
)
"
>
<gl-form-input-group v-model.number="model.maximumTimeout" type="number" />
</gl-form-group>
<gl-form-group
data-testid="runner-field-tags"
:label="__('Tags')"
:description="
__('You can set up jobs to only use runners with specific tags. Separate tags with commas.')
"
>
<gl-form-input-group v-model="model.tagList" />
</gl-form-group>
<div class="form-actions">
<gl-button
type="submit"
variant="confirm"
class="js-no-auto-disable"
:loading="saving || !runner"
>
{{ __('Save changes') }}
</gl-button>
</div>
</gl-form>
</template>
...@@ -31,6 +31,11 @@ export const STATUS_ONLINE = 'ONLINE'; ...@@ -31,6 +31,11 @@ export const STATUS_ONLINE = 'ONLINE';
export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
// CiRunnerAccessLevel
export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED';
export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED';
// CiRunnerSort // CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC'; export const CREATED_DESC = 'CREATED_DESC';
......
#import "~/runner/graphql/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) { query getRunner($id: CiRunnerID!) {
runner(id: $id) { runner(id: $id) {
id ...RunnerDetails
runnerType
} }
} }
fragment RunnerDetails on CiRunner {
id
runnerType
active
accessLevel
runUntagged
locked
ipAddress
description
maximumTimeout
tagList
}
#import "~/runner/graphql/runner_node.fragment.graphql" #import "~/runner/graphql/runner_details.fragment.graphql"
mutation runnerUpdate($input: RunnerUpdateInput!) { mutation runnerUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) { runnerUpdate(input: $input) {
runner { runner {
...RunnerNode ...RunnerDetails
} }
errors errors
} }
......
<script> <script>
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerTypeAlert from '../components/runner_type_alert.vue';
import RunnerTypeBadge from '../components/runner_type_badge.vue'; import RunnerTypeBadge from '../components/runner_type_badge.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants'; import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants';
import getRunnerQuery from '../graphql/get_runner.query.graphql'; import getRunnerQuery from '../graphql/get_runner.query.graphql';
export default { export default {
components: { components: {
RunnerTypeAlert,
RunnerTypeBadge, RunnerTypeBadge,
RunnerUpdateForm,
}, },
i18n: { i18n: {
I18N_DETAILS_TITLE, I18N_DETAILS_TITLE,
...@@ -19,7 +23,7 @@ export default { ...@@ -19,7 +23,7 @@ export default {
}, },
data() { data() {
return { return {
runner: {}, runner: null,
}; };
}, },
apollo: { apollo: {
...@@ -35,9 +39,15 @@ export default { ...@@ -35,9 +39,15 @@ export default {
}; };
</script> </script>
<template> <template>
<h2 class="page-title"> <div>
{{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} <h2 class="page-title">
{{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
<runner-type-badge v-if="runner.runnerType" :type="runner.runnerType" /> <runner-type-badge v-if="runner" :type="runner.runnerType" />
</h2> </h2>
<runner-type-alert v-if="runner" :type="runner.runnerType" />
<runner-update-form :runner="runner" class="gl-my-5" />
</div>
</template> </template>
...@@ -10,11 +10,9 @@ ...@@ -10,11 +10,9 @@
%h2.page-title %h2.page-title
= s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
= render 'shared/runners/runner_type_badge', runner: @runner = render 'shared/runners/runner_type_badge', runner: @runner
= render 'shared/runners/runner_type_alert', runner: @runner
= render 'shared/runners/runner_type_alert', runner: @runner .gl-mb-6
= render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
.gl-mb-6
= render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
.row .row
.col-md-6 .col-md-6
......
...@@ -8949,6 +8949,9 @@ msgstr "" ...@@ -8949,6 +8949,9 @@ msgstr ""
msgid "Copy ID" msgid "Copy ID"
msgstr "" msgstr ""
msgid "Copy IP Address"
msgstr ""
msgid "Copy KRB5 clone URL" msgid "Copy KRB5 clone URL"
msgstr "" msgstr ""
...@@ -28159,6 +28162,9 @@ msgstr "" ...@@ -28159,6 +28162,9 @@ msgstr ""
msgid "Runners|Download latest binary" msgid "Runners|Download latest binary"
msgstr "" msgstr ""
msgid "Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project."
msgstr ""
msgid "Runners|IP Address" msgid "Runners|IP Address"
msgstr "" msgstr ""
......
...@@ -54,6 +54,10 @@ RSpec.describe Admin::RunnersController do ...@@ -54,6 +54,10 @@ RSpec.describe Admin::RunnersController do
describe '#show' do describe '#show' do
render_views render_views
before do
stub_feature_flags(runner_detailed_view_vue_ui: false)
end
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:project_two) { create(:project) } let_it_be(:project_two) { create(:project) }
......
...@@ -58,7 +58,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do ...@@ -58,7 +58,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
describe GraphQL::Query, type: :request do describe GraphQL::Query, type: :request do
get_runner_query_name = 'get_runner.query.graphql' get_runner_query_name = 'get_runner.query.graphql'
let_it_be(:query) { get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") } let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_query_name}", [
'runner/graphql/runner_details.fragment.graphql'
])
end
it "#{fixtures_path}#{get_runner_query_name}.json" do it "#{fixtures_path}#{get_runner_query_name}.json" do
post_graphql(query, current_user: admin, variables: { post_graphql(query, current_user: admin, variables: {
......
...@@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql'; import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import updateRunnerMutation from '~/runner/graphql/update_runner.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
const mockId = '1'; const mockId = '1';
...@@ -101,7 +101,7 @@ describe('RunnerTypeCell', () => { ...@@ -101,7 +101,7 @@ describe('RunnerTypeCell', () => {
it(`The apollo mutation to set active to ${newActiveValue} is called`, () => { it(`The apollo mutation to set active to ${newActiveValue} is called`, () => {
expect(mutate).toHaveBeenCalledTimes(1); expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledWith({ expect(mutate).toHaveBeenCalledWith({
mutation: updateRunnerMutation, mutation: runnerUpdateMutation,
variables: { variables: {
input: { input: {
id: `gid://gitlab/Ci::Runner/${mockId}`, id: `gid://gitlab/Ci::Runner/${mockId}`,
......
import { GlAlert, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
describe('RunnerTypeAlert', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
const findLink = () => wrapper.findComponent(GlLink);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerTypeAlert, {
propsData: {
type: INSTANCE_TYPE,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe.each`
type | exampleText | anchor | variant
${INSTANCE_TYPE} | ${'Shared runners are available to every project'} | ${'#shared-runners'} | ${'success'}
${GROUP_TYPE} | ${'Use Group runners when you want all projects in a group'} | ${'#group-runners'} | ${'success'}
${PROJECT_TYPE} | ${'You can set up a specific runner to be used by multiple projects'} | ${'#specific-runners'} | ${'info'}
`('When it is an $type level runner', ({ type, exampleText, anchor, variant }) => {
beforeEach(() => {
createComponent({ props: { type } });
});
it('Describes runner type', () => {
expect(wrapper.text()).toMatch(exampleText);
});
it(`Shows a ${variant} variant`, () => {
expect(findAlert().props('variant')).toBe(variant);
});
it(`Links to anchor "${anchor}"`, () => {
expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`);
});
});
describe('When runner type is not correct', () => {
it('Does not render content when type is missing', () => {
createComponent({ props: { type: undefined } });
expect(wrapper.html()).toBe('');
});
it('Validation fails for an incorrect type', () => {
expect(() => {
createComponent({ props: { type: 'NOT_A_TYPE' } });
}).toThrow();
});
});
});
...@@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => { ...@@ -32,8 +32,14 @@ describe('RunnerTypeBadge', () => {
expect(findBadge().props('variant')).toBe(variant); expect(findBadge().props('variant')).toBe(variant);
}); });
it('does not display a badge when type is unknown', () => { it('validation fails for an incorrect type', () => {
createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } }); expect(() => {
createComponent({ props: { type: 'AN_UNKNOWN_VALUE' } });
}).toThrow();
});
it('does not render content when type is missing', () => {
createComponent({ props: { type: undefined } });
expect(findBadge().exists()).toBe(false); expect(findBadge().exists()).toBe(false);
}); });
......
import { GlForm } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
ACCESS_LEVEL_REF_PROTECTED,
ACCESS_LEVEL_NOT_PROTECTED,
} from '~/runner/constants';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
import { runnerData } from '../mock_data';
jest.mock('~/flash');
const mockRunner = runnerData.data.runner;
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('RunnerUpdateForm', () => {
let wrapper;
let runnerUpdateHandler;
const findForm = () => wrapper.findComponent(GlForm);
const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused');
const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected');
const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged');
const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked');
const findIpInput = () => wrapper.findByTestId('runner-field-ip-address').find('input');
const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input');
const findMaxJobTimeoutInput = () =>
wrapper.findByTestId('runner-field-max-timeout').find('input');
const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input');
const findSubmit = () => wrapper.find('[type="submit"]');
const findSubmitDisabledAttr = () => findSubmit().attributes('disabled');
const submitForm = () => findForm().trigger('submit');
const submitFormAndWait = () => submitForm().then(waitForPromises);
const getFieldsModel = () => ({
active: !findPausedCheckbox().element.checked,
accessLevel: findProtectedCheckbox().element.checked
? ACCESS_LEVEL_REF_PROTECTED
: ACCESS_LEVEL_NOT_PROTECTED,
runUntagged: findRunUntaggedCheckbox().element.checked,
locked: findLockedCheckbox().element.checked,
ipAddress: findIpInput().element.value,
maximumTimeout: findMaxJobTimeoutInput().element.value || null,
tagList: findTagsInput().element.value.split(',').filter(Boolean),
});
const createComponent = ({ props } = {}) => {
wrapper = extendedWrapper(
mount(RunnerUpdateForm, {
localVue,
propsData: {
runner: mockRunner,
...props,
},
apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]),
}),
);
};
const expectToHaveSubmittedRunnerContaining = (submittedRunner) => {
expect(runnerUpdateHandler).toHaveBeenCalledTimes(1);
expect(runnerUpdateHandler).toHaveBeenCalledWith({
input: expect.objectContaining(submittedRunner),
});
expect(createFlash).toHaveBeenLastCalledWith({
message: expect.stringContaining('saved'),
type: FLASH_TYPES.SUCCESS,
});
expect(findSubmitDisabledAttr()).toBeUndefined();
};
beforeEach(() => {
runnerUpdateHandler = jest.fn().mockImplementation(({ input }) => {
return Promise.resolve({
data: {
runnerUpdate: {
runner: {
...mockRunner,
...input,
},
errors: [],
},
},
});
});
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Form has a submit button', () => {
expect(findSubmit().exists()).toBe(true);
});
it('Form fields match data', () => {
expect(mockRunner).toMatchObject(getFieldsModel());
});
it('Form prevent multiple submissions', async () => {
await submitForm();
expect(findSubmitDisabledAttr()).toBe('disabled');
});
it('Updates runner with no changes', async () => {
await submitFormAndWait();
// Some fields are not submitted
const { ipAddress, runnerType, ...submitted } = mockRunner;
expectToHaveSubmittedRunnerContaining(submitted);
});
describe('When data is being loaded', () => {
beforeEach(() => {
createComponent({ props: { runner: null } });
});
it('Form cannot be submitted', () => {
expect(findSubmit().props('loading')).toBe(true);
});
it('Form is updated when data loads', async () => {
wrapper.setProps({
runner: mockRunner,
});
await nextTick();
expect(mockRunner).toMatchObject(getFieldsModel());
});
});
it.each`
runnerType | attrDisabled | outcome
${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'}
${GROUP_TYPE} | ${'disabled'} | ${'disabled'}
${PROJECT_TYPE} | ${undefined} | ${'enabled'}
`(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => {
const runner = { ...mockRunner, runnerType };
createComponent({ props: { runner } });
expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled);
});
describe('On submit, runner gets updated', () => {
it.each`
test | initialValue | findCheckbox | checked | submitted
${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }}
${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }}
${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }}
${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }}
${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }}
${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }}
${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }}
${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }}
`('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => {
const runner = { ...mockRunner, ...initialValue };
createComponent({ props: { runner } });
await findCheckbox().setChecked(checked);
await submitFormAndWait();
expectToHaveSubmittedRunnerContaining({
id: runner.id,
...submitted,
});
});
it.each`
test | initialValue | findInput | value | submitted
${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }}
${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }}
${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }}
`("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => {
const runner = { ...mockRunner, ...initialValue };
createComponent({ props: { runner } });
await findInput().setValue(value);
await submitFormAndWait();
expectToHaveSubmittedRunnerContaining({
id: runner.id,
...submitted,
});
});
it.each`
value | submitted
${''} | ${{ tagList: [] }}
${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }}
${'with spaces'} | ${{ tagList: ['with spaces'] }}
${',,,,, commas'} | ${{ tagList: ['commas'] }}
${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }}
${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }}
`('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => {
const runner = { ...mockRunner, tagList: ['tag1'] };
createComponent({ props: { runner } });
await findTagsInput().setValue(value);
await submitFormAndWait();
expectToHaveSubmittedRunnerContaining({
id: runner.id,
...submitted,
});
});
});
describe('On error', () => {
beforeEach(() => {
createComponent();
});
it('On network error, error message is shown', async () => {
runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong'));
await submitFormAndWait();
expect(createFlash).toHaveBeenLastCalledWith({
message: 'Network error: Something went wrong',
});
expect(findSubmitDisabledAttr()).toBeUndefined();
});
it('On validation error, error message is shown', async () => {
runnerUpdateHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: mockRunner,
errors: ['A value is invalid'],
},
},
});
await submitFormAndWait();
expect(createFlash).toHaveBeenLastCalledWith({
message: 'A value is invalid',
});
expect(findSubmitDisabledAttr()).toBeUndefined();
});
});
});
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