Commit 77dee58e authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '329659-migrate-runner-update-form-vue-ui' into 'master'

Create a Vue form for the runner UI details

See merge request gitlab-org/gitlab!63044
parents dc7bd834 f279d291
......@@ -3,7 +3,7 @@ import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
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 = {
I18N_EDIT: __('Edit'),
......@@ -76,7 +76,7 @@ export default {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: updateRunnerMutation,
mutation: runnerUpdateMutation,
variables: {
input: {
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';
import { s__ } from '~/locale';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
const badge = {
const BADGE_DATA = {
[INSTANCE_TYPE]: {
variant: 'success',
text: s__('Runners|shared'),
......@@ -25,21 +25,22 @@ export default {
props: {
type: {
type: String,
required: true,
required: false,
default: null,
validator(type) {
return Boolean(BADGE_DATA[type]);
},
},
computed: {
variant() {
return badge[this.type]?.variant;
},
text() {
return badge[this.type]?.text;
computed: {
badge() {
return BADGE_DATA[this.type];
},
},
};
</script>
<template>
<gl-badge v-if="text" :variant="variant" v-bind="$attrs">
{{ text }}
<gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs">
{{ badge.text }}
</gl-badge>
</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';
export const STATUS_OFFLINE = 'OFFLINE';
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
export const CREATED_DESC = 'CREATED_DESC';
......
#import "~/runner/graphql/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
runner(id: $id) {
id
runnerType
...RunnerDetails
}
}
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!) {
runnerUpdate(input: $input) {
runner {
...RunnerNode
...RunnerDetails
}
errors
}
......
<script>
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerTypeAlert from '../components/runner_type_alert.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 getRunnerQuery from '../graphql/get_runner.query.graphql';
export default {
components: {
RunnerTypeAlert,
RunnerTypeBadge,
RunnerUpdateForm,
},
i18n: {
I18N_DETAILS_TITLE,
......@@ -19,7 +23,7 @@ export default {
},
data() {
return {
runner: {},
runner: null,
};
},
apollo: {
......@@ -35,9 +39,15 @@ export default {
};
</script>
<template>
<div>
<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>
<runner-type-alert v-if="runner" :type="runner.runnerType" />
<runner-update-form :runner="runner" class="gl-my-5" />
</div>
</template>
......@@ -10,10 +10,8 @@
%h2.page-title
= s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
= render 'shared/runners/runner_type_badge', runner: @runner
= render 'shared/runners/runner_type_alert', runner: @runner
.gl-mb-6
= 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?
.row
......
......@@ -8946,6 +8946,9 @@ msgstr ""
msgid "Copy ID"
msgstr ""
msgid "Copy IP Address"
msgstr ""
msgid "Copy KRB5 clone URL"
msgstr ""
......@@ -28153,6 +28156,9 @@ msgstr ""
msgid "Runners|Download latest binary"
msgstr ""
msgid "Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project."
msgstr ""
msgid "Runners|IP Address"
msgstr ""
......
......@@ -54,6 +54,10 @@ RSpec.describe Admin::RunnersController do
describe '#show' do
render_views
before do
stub_feature_flags(runner_detailed_view_vue_ui: false)
end
let_it_be(:project) { create(:project) }
let_it_be(:project_two) { create(:project) }
......
......@@ -58,7 +58,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
describe GraphQL::Query, type: :request do
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
post_graphql(query, current_user: admin, variables: {
......
......@@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.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';
......@@ -101,7 +101,7 @@ describe('RunnerTypeCell', () => {
it(`The apollo mutation to set active to ${newActiveValue} is called`, () => {
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledWith({
mutation: updateRunnerMutation,
mutation: runnerUpdateMutation,
variables: {
input: {
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', () => {
expect(findBadge().props('variant')).toBe(variant);
});
it('does not display a badge when type is unknown', () => {
it('validation fails for an incorrect type', () => {
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);
});
......
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