Commit 11b52207 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch...

Merge branch '333232-expand-runner-graphql-api-and-update-form-with-com-admin-context-fields' into 'master'

Add runner update form with .com fields [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!64952
parents bad4c239 bf51728e
...@@ -7,36 +7,16 @@ import { ...@@ -7,36 +7,16 @@ import {
GlFormInputGroup, GlFormInputGroup,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee_else_ce/runner/runner_details/runner_update_form_utils';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { captureException } from '~/runner/sentry_utils'; import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql'; 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 { export default {
name: 'RunnerUpdateForm', name: 'RunnerUpdateForm',
components: { components: {
...@@ -45,6 +25,8 @@ export default { ...@@ -45,6 +25,8 @@ export default {
GlFormCheckbox, GlFormCheckbox,
GlFormGroup, GlFormGroup,
GlFormInputGroup, GlFormInputGroup,
RunnerUpdateCostFactorFields: () =>
import('ee_component/runner/components/runner_update_cost_factor_fields.vue'),
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -69,18 +51,6 @@ export default { ...@@ -69,18 +51,6 @@ export default {
readonlyIpAddress() { readonlyIpAddress() {
return this.runner?.ipAddress; 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: { watch: {
runner(newVal, oldVal) { runner(newVal, oldVal) {
...@@ -100,9 +70,7 @@ export default { ...@@ -100,9 +70,7 @@ export default {
}, },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: runnerUpdateMutation, mutation: runnerUpdateMutation,
variables: { variables: modelToUpdateMutationVariables(this.model),
input: this.updateMutationInput,
},
}); });
if (errors?.length) { if (errors?.length) {
...@@ -218,6 +186,8 @@ export default { ...@@ -218,6 +186,8 @@ export default {
<gl-form-input-group v-model="model.tagList" /> <gl-form-input-group v-model="model.tagList" />
</gl-form-group> </gl-form-group>
<runner-update-cost-factor-fields v-model="model" />
<div class="form-actions"> <div class="form-actions">
<gl-button <gl-button
type="submit" type="submit"
......
#import "~/runner/graphql/runner_details.fragment.graphql" #import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) { query getRunner($id: CiRunnerID!) {
runner(id: $id) { runner(id: $id) {
......
#import "./runner_details_shared.fragment.graphql"
fragment RunnerDetails on CiRunner { fragment RunnerDetails on CiRunner {
id ...RunnerDetailsShared
runnerType
active
accessLevel
runUntagged
locked
ipAddress
description
maximumTimeout
tagList
} }
fragment RunnerDetailsShared on CiRunner {
id
runnerType
active
accessLevel
runUntagged
locked
ipAddress
description
maximumTimeout
tagList
}
#import "~/runner/graphql/runner_details.fragment.graphql" #import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
mutation runnerUpdate($input: RunnerUpdateInput!) { mutation runnerUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) { runnerUpdate(input: $input) {
......
export 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 const modelToUpdateMutationVariables = (model) => {
const { maximumTimeout, tagList } = model;
return {
input: {
...model,
maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null,
tagList: tagList
?.split(',')
.map((tag) => tag.trim())
.filter((tag) => Boolean(tag)),
},
};
};
<script>
import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
export default {
components: {
GlFormGroup,
GlFormInputGroup,
},
props: {
value: {
required: false,
type: Object,
default: null,
},
},
computed: {
isSaas() {
// Using dot_com is discouraged but no clear alternative
// is available. These fields should be available in any
// SaaS setup.
// https://gitlab.com/gitlab-org/gitlab/-/issues/225101
return gon?.dot_com;
},
},
methods: {
parseNumber(val) {
const n = parseFloat(val);
return Number.isNaN(n) ? val : n;
},
onInputPublicProjectMinutesCostFactor(val) {
this.$emit('input', {
...this.value,
publicProjectsMinutesCostFactor: this.parseNumber(val),
});
},
onInputPrivateProjectMinutesCostFactor(val) {
this.$emit('input', {
...this.value,
privateProjectsMinutesCostFactor: this.parseNumber(val),
});
},
},
};
</script>
<template>
<div v-if="isSaas && value">
<gl-form-group
data-testid="runner-field-public-projects-cost-factor"
:label="__('Public projects Minutes cost factor')"
>
<gl-form-input-group
:value="value.publicProjectsMinutesCostFactor"
type="number"
@input="onInputPublicProjectMinutesCostFactor"
/>
</gl-form-group>
<gl-form-group
data-testid="runner-field-private-projects-cost-factor"
:label="__('Private projects Minutes cost factor')"
>
<gl-form-input-group
:value="value.privateProjectsMinutesCostFactor"
type="number"
@input="onInputPrivateProjectMinutesCostFactor"
/>
</gl-form-group>
</div>
</template>
#import "~/runner/graphql/runner_details_shared.fragment.graphql"
fragment RunnerDetails on CiRunner {
...RunnerDetailsShared
publicProjectsMinutesCostFactor
privateProjectsMinutesCostFactor
}
import {
modelToUpdateMutationVariables as cemodelToUpdateMutationVariables,
runnerToModel as ceRunnerToModel,
} from '~/runner/runner_details/runner_update_form_utils';
export const runnerToModel = (runner) => {
return {
...ceRunnerToModel(runner),
privateProjectsMinutesCostFactor: runner?.privateProjectsMinutesCostFactor,
publicProjectsMinutesCostFactor: runner?.publicProjectsMinutesCostFactor,
};
};
export const modelToUpdateMutationVariables = (model) => {
const { privateProjectsMinutesCostFactor, publicProjectsMinutesCostFactor } = model;
return {
input: {
...cemodelToUpdateMutationVariables(model).input,
privateProjectsMinutesCostFactor:
privateProjectsMinutesCostFactor !== '' ? privateProjectsMinutesCostFactor : null,
publicProjectsMinutesCostFactor:
publicProjectsMinutesCostFactor !== '' ? publicProjectsMinutesCostFactor : null,
},
};
};
import { GlForm } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
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 { runnerData } from 'jest/runner/mock_data';
import createFlash, { FLASH_TYPES } from '~/flash';
import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
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 findPrivateProjectsCostFactor = () =>
wrapper.findByTestId('runner-field-private-projects-cost-factor');
const findPublicProjectsCostFactor = () =>
wrapper.findByTestId('runner-field-public-projects-cost-factor');
const findPrivateProjectsCostFactorInput = () => findPrivateProjectsCostFactor().find('input');
const findPublicProjectsCostFactorInput = () => findPublicProjectsCostFactor().find('input');
const findSubmit = () => wrapper.find('[type="submit"]');
const findSubmitDisabledAttr = () => findSubmit().attributes('disabled');
const submitForm = () => findForm().trigger('submit');
const submitFormAndWait = () => submitForm().then(waitForPromises);
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: [],
},
},
});
});
});
afterEach(() => {
wrapper.destroy();
});
describe('When on .com', () => {
beforeEach(() => {
gon.dot_com = true;
createComponent();
});
it('the form contains CI minute cost factors', () => {
expect(findPrivateProjectsCostFactor().exists()).toBe(true);
expect(findPublicProjectsCostFactor().exists()).toBe(true);
});
describe('On submit, runner gets updated', () => {
it.each`
test | initialValue | findInput | value | submitted
${'private minutes'} | ${{ privateProjectsMinutesCostFactor: 1 }} | ${findPrivateProjectsCostFactorInput} | ${'1.5'} | ${{ privateProjectsMinutesCostFactor: 1.5 }}
${'private minutes to null'} | ${{ privateProjectsMinutesCostFactor: 1 }} | ${findPrivateProjectsCostFactorInput} | ${''} | ${{ privateProjectsMinutesCostFactor: null }}
${'public minutes'} | ${{ publicProjectsMinutesCostFactor: 0 }} | ${findPublicProjectsCostFactorInput} | ${'0.5'} | ${{ publicProjectsMinutesCostFactor: 0.5 }}
${'public minutes to null'} | ${{ publicProjectsMinutesCostFactor: 0 }} | ${findPublicProjectsCostFactorInput} | ${''} | ${{ publicProjectsMinutesCostFactor: null }}
`("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,
});
});
});
});
describe('When not on .com', () => {
beforeEach(() => {
gon.dot_com = false;
createComponent();
});
it('the form does not contain CI minute cost factors', () => {
expect(findPrivateProjectsCostFactor().exists()).toBe(false);
expect(findPublicProjectsCostFactor().exists()).toBe(false);
});
});
});
import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee/runner/runner_details/runner_update_form_utils';
const mockRunnerId = 'gid://gitlab/Ci::Runner/1';
const mockPrivateFactor = 1;
const mockPublicFactor = 0.5;
describe('ee/runner/runner_details/runner_update_form_utils', () => {
describe('runnerToModel', () => {
it('collects project minutes factor', () => {
expect(
runnerToModel({
id: mockRunnerId,
privateProjectsMinutesCostFactor: mockPrivateFactor,
publicProjectsMinutesCostFactor: mockPublicFactor,
}),
).toMatchObject({
id: mockRunnerId,
privateProjectsMinutesCostFactor: mockPrivateFactor,
publicProjectsMinutesCostFactor: mockPublicFactor,
});
});
it('collects null project minutes factor', () => {
expect(
runnerToModel({
id: mockRunnerId,
privateProjectsMinutesCostFactor: undefined,
publicProjectsMinutesCostFactor: undefined,
}),
).toMatchObject({
id: mockRunnerId,
privateProjectsMinutesCostFactor: undefined,
publicProjectsMinutesCostFactor: undefined,
});
});
it('collects null runner', () => {
expect(runnerToModel(null)).toMatchObject({
privateProjectsMinutesCostFactor: undefined,
publicProjectsMinutesCostFactor: undefined,
});
});
});
describe('modelToUpdateMutationVariables', () => {
it('gets project minutes factor as input', () => {
expect(
modelToUpdateMutationVariables({
id: mockRunnerId,
privateProjectsMinutesCostFactor: mockPrivateFactor,
publicProjectsMinutesCostFactor: mockPublicFactor,
}),
).toMatchObject({
input: {
id: mockRunnerId,
privateProjectsMinutesCostFactor: mockPrivateFactor,
publicProjectsMinutesCostFactor: mockPublicFactor,
},
});
});
it('gets empty project minutes factor as input', () => {
expect(
modelToUpdateMutationVariables({
privateProjectsMinutesCostFactor: '',
publicProjectsMinutesCostFactor: '',
}),
).toMatchObject({
input: {
privateProjectsMinutesCostFactor: null,
publicProjectsMinutesCostFactor: null,
},
});
});
});
});
...@@ -207,13 +207,11 @@ describe('RunnerUpdateForm', () => { ...@@ -207,13 +207,11 @@ describe('RunnerUpdateForm', () => {
}); });
it.each` it.each`
value | submitted value | submitted
${''} | ${{ tagList: [] }} ${''} | ${{ tagList: [] }}
${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }}
${'with spaces'} | ${{ tagList: ['with spaces'] }} ${'with spaces'} | ${{ tagList: ['with spaces'] }}
${',,,,, commas'} | ${{ tagList: ['commas'] }} ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }}
${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }}
${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }}
`('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => {
const runner = { ...mockRunner, tagList: ['tag1'] }; const runner = { ...mockRunner, tagList: ['tag1'] };
createComponent({ props: { runner } }); createComponent({ props: { runner } });
......
import { ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants';
import {
modelToUpdateMutationVariables,
runnerToModel,
} from '~/runner/runner_details/runner_update_form_utils';
const mockId = 'gid://gitlab/Ci::Runner/1';
const mockDescription = 'Runner Desc.';
const mockRunner = {
id: mockId,
description: mockDescription,
maximumTimeout: 100,
accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
active: true,
locked: true,
runUntagged: true,
tagList: ['tag-1', 'tag-2'],
};
const mockModel = {
...mockRunner,
tagList: 'tag-1, tag-2',
};
describe('~/runner/runner_details/runner_update_form_utils', () => {
describe('runnerToModel', () => {
it('collects all model data', () => {
expect(runnerToModel(mockRunner)).toEqual(mockModel);
});
it('does not collect other data', () => {
const model = runnerToModel({
...mockRunner,
unrelated: 'unrelatedValue',
});
expect(model.unrelated).toEqual(undefined);
});
it('tag list defaults to an empty string', () => {
const model = runnerToModel({
...mockRunner,
tagList: undefined,
});
expect(model.tagList).toEqual('');
});
});
describe('modelToUpdateMutationVariables', () => {
it('collects all model data', () => {
expect(modelToUpdateMutationVariables(mockModel)).toEqual({
input: {
...mockRunner,
},
});
});
it('collects a nullable timeout from the model', () => {
const variables = modelToUpdateMutationVariables({
...mockModel,
maximumTimeout: '',
});
expect(variables).toEqual({
input: {
...mockRunner,
maximumTimeout: null,
},
});
});
it.each`
tagList | tagListInput
${''} | ${[]}
${'tag1, tag2'} | ${['tag1', 'tag2']}
${'with spaces'} | ${['with spaces']}
${',,,,, commas'} | ${['commas']}
${'more ,,,,, commas'} | ${['more', 'commas']}
${' trimmed , trimmed2 '} | ${['trimmed', 'trimmed2']}
`('collect tags separated by commas for "$value"', ({ tagList, tagListInput }) => {
const variables = modelToUpdateMutationVariables({
...mockModel,
tagList,
});
expect(variables).toEqual({
input: {
...mockRunner,
tagList: tagListInput,
},
});
});
});
});
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