Commit c1462c63 authored by Janis Altherr's avatar Janis Altherr Committed by Kushal Pandya

Add the pipeline wizard step component

Signed-off-by: default avatarJanis Altherr <jaltherr@gitlab.com>
parent 4bd9a354
<script>
import { GlAlert } from '@gitlab/ui';
import { isNode, isDocument, parseDocument, Document } from 'yaml';
import { merge } from '~/lib/utils/yaml';
import { s__ } from '~/locale';
import { logError } from '~/lib/logger';
import InputWrapper from './input.vue';
import StepNav from './step_nav.vue';
export default {
name: 'PipelineWizardStep',
i18n: {
errors: {
cloneErrorUserMessage: s__(
'PipelineWizard|There was an unexpected error trying to set up the template. The error has been logged.',
),
},
},
components: {
StepNav,
InputWrapper,
GlAlert,
},
props: {
// As the inputs prop we expect to receive an array of instructions
// on how to display the input fields that will be used to obtain the
// user's input. Each input instruction needs a target prop, specifying
// the placeholder in the template that will be replaced by the user's
// input. The selected widget may require additional validation for the
// input object.
inputs: {
type: Array,
required: true,
validator: (value) =>
value.every((i) => {
return i?.target && i?.widget;
}),
},
template: {
type: null,
required: true,
validator: (v) => isNode(v),
},
hasPreviousStep: {
type: Boolean,
required: false,
default: false,
},
compiled: {
type: Object,
required: true,
validator: (v) => isDocument(v),
},
},
data() {
return {
wasCompiled: false,
validate: false,
inputValidStates: Array(this.inputs.length).fill(null),
error: null,
};
},
computed: {
inputValidStatesThatAreNotNull() {
return this.inputValidStates?.filter((s) => s !== null);
},
areAllInputValidStatesNull() {
return !this.inputValidStatesThatAreNotNull?.length;
},
isValid() {
return this.areAllInputValidStatesNull || this.inputValidStatesThatAreNotNull.every((s) => s);
},
},
methods: {
forceClone(yamlNode) {
try {
// document.clone() will only clone the root document object,
// but the references to the child nodes inside will be retained.
// So in order to ensure a full clone, we need to stringify
// and parse until there's a better implementation in the
// yaml package.
return parseDocument(new Document(yamlNode).toString());
} catch (e) {
// eslint-disable-next-line @gitlab/require-i18n-strings
logError('An unexpected error occurred while trying to clone a template', e);
this.error = this.$options.i18n.errors.cloneErrorUserMessage;
return null;
}
},
compile() {
if (this.wasCompiled) return;
// NOTE: This modifies this.compiled without triggering reactivity.
// this is done on purpose, see
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81412#note_862972703
// for more information
merge(this.compiled, this.forceClone(this.template));
this.wasCompiled = true;
},
onUpdate(c) {
this.$emit('update:compiled', c);
},
onPrevClick() {
this.$emit('back');
},
async onNextClick() {
this.validate = true;
await this.$nextTick();
if (this.isValid) {
this.$emit('next');
}
},
onInputValidationStateChange(inputId, value) {
this.$set(this.inputValidStates, inputId, value);
},
onHighlight(path) {
this.$emit('update:highlight', path);
},
},
};
</script>
<template>
<div>
<gl-alert v-if="error" class="gl-mb-4" variant="danger">
{{ error }}
</gl-alert>
<input-wrapper
v-for="(input, i) in inputs"
:key="input.target"
:compiled="compiled"
:target="input.target"
:template="template"
:validate="validate"
:widget="input.widget"
class="gl-mb-2"
v-bind="input"
@highlight="onHighlight"
@update:valid="(validationState) => onInputValidationStateChange(i, validationState)"
@update:compiled="onUpdate"
@beforeUpdate:compiled.once="compile"
/>
<step-nav
:next-button-enabled="isValid"
:show-back-button="hasPreviousStep"
show-next-button
@back="onPrevClick"
@next="onNextClick"
/>
</div>
</template>
......@@ -26915,6 +26915,9 @@ msgstr ""
msgid "PipelineWizard|There was a problem while checking whether your file already exists in the specified branch."
msgstr ""
msgid "PipelineWizard|There was an unexpected error trying to set up the template. The error has been logged."
msgstr ""
msgid "Pipelines"
msgstr ""
......
import { parseDocument, Document } from 'yaml';
import { omit } from 'lodash';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PipelineWizardStep from '~/pipeline_wizard/components/step.vue';
import InputWrapper from '~/pipeline_wizard/components/input.vue';
import StepNav from '~/pipeline_wizard/components/step_nav.vue';
import {
stepInputs,
stepTemplate,
compiledYamlBeforeSetup,
compiledYamlAfterInitialLoad,
compiledYaml,
} from '../mock/yaml';
describe('Pipeline Wizard - Step Page', () => {
const inputs = parseDocument(stepInputs).toJS();
let wrapper;
let input1;
let input2;
const getInputWrappers = () => wrapper.findAllComponents(InputWrapper);
const forEachInputWrapper = (cb) => {
getInputWrappers().wrappers.forEach(cb);
};
const getStepNav = () => {
return wrapper.findComponent(StepNav);
};
const mockNextClick = () => {
getStepNav().vm.$emit('next');
};
const mockPrevClick = () => {
getStepNav().vm.$emit('back');
};
const expectFalsyAttributeValue = (testedWrapper, attributeName) => {
expect([false, null, undefined]).toContain(testedWrapper.attributes(attributeName));
};
const findInputWrappers = () => {
const inputWrappers = wrapper.findAllComponents(InputWrapper);
input1 = inputWrappers.at(0);
input2 = inputWrappers.at(1);
};
const createComponent = (props = {}) => {
const template = parseDocument(stepTemplate).get('template');
const defaultProps = {
inputs,
template,
};
wrapper = shallowMountExtended(PipelineWizardStep, {
propsData: {
...defaultProps,
compiled: parseDocument(compiledYamlBeforeSetup),
...props,
},
});
};
afterEach(async () => {
await wrapper.destroy();
});
describe('input children', () => {
beforeEach(() => {
createComponent();
});
it('mounts an inputWrapper for each input type', () => {
forEachInputWrapper((inputWrapper, i) =>
expect(inputWrapper.attributes('widget')).toBe(inputs[i].widget),
);
});
it('passes all unused props to the inputWrapper', () => {
const pickChildProperties = (from) => {
return omit(from, ['target', 'widget']);
};
forEachInputWrapper((inputWrapper, i) => {
const expectedProps = pickChildProperties(inputs[i]);
Object.entries(expectedProps).forEach(([key, value]) => {
expect(inputWrapper.attributes(key.toLowerCase())).toEqual(value.toString());
});
});
});
});
const yamlDocument = new Document({ foo: { bar: 'baz' } });
const yamlNode = yamlDocument.get('foo');
describe('prop validation', () => {
describe.each`
componentProp | required | valid | invalid
${'inputs'} | ${true} | ${[inputs, []]} | ${[['invalid'], [null], [{}, {}]]}
${'template'} | ${true} | ${[yamlNode]} | ${['invalid', null, { foo: 1 }, yamlDocument]}
${'compiled'} | ${true} | ${[yamlDocument]} | ${['invalid', null, { foo: 1 }, yamlNode]}
`('testing `$componentProp` prop', ({ componentProp, required, valid, invalid }) => {
it('expects prop to be required', () => {
expect(PipelineWizardStep.props[componentProp].required).toEqual(required);
});
it('prop validators return false for invalid types', () => {
const validatorFunc = PipelineWizardStep.props[componentProp].validator;
invalid.forEach((invalidType) => {
expect(validatorFunc(invalidType)).toBe(false);
});
});
it('prop validators return true for valid types', () => {
const validatorFunc = PipelineWizardStep.props[componentProp].validator;
valid.forEach((validType) => {
expect(validatorFunc(validType)).toBe(true);
});
});
});
});
describe('navigation', () => {
it('shows the next button', () => {
createComponent();
expect(getStepNav().attributes('nextbuttonenabled')).toEqual('true');
});
it('does not show a back button if hasPreviousStep is false', () => {
createComponent({ hasPreviousStep: false });
expectFalsyAttributeValue(getStepNav(), 'showbackbutton');
});
it('shows a back button if hasPreviousStep is true', () => {
createComponent({ hasPreviousStep: true });
expect(getStepNav().attributes('showbackbutton')).toBe('true');
});
it('lets "back" event bubble upwards', async () => {
createComponent();
await mockPrevClick();
await nextTick();
expect(wrapper.emitted().back).toBeTruthy();
});
it('lets "next" event bubble upwards', async () => {
createComponent();
await mockNextClick();
await nextTick();
expect(wrapper.emitted().next).toBeTruthy();
});
});
describe('validation', () => {
beforeEach(() => {
createComponent({ hasNextPage: true });
findInputWrappers();
});
it('sets invalid once one input field has an invalid value', async () => {
input1.vm.$emit('update:valid', true);
input2.vm.$emit('update:valid', false);
await mockNextClick();
expectFalsyAttributeValue(getStepNav(), 'nextbuttonenabled');
});
it('returns to valid state once the invalid input is valid again', async () => {
input1.vm.$emit('update:valid', true);
input2.vm.$emit('update:valid', false);
await mockNextClick();
expectFalsyAttributeValue(getStepNav(), 'nextbuttonenabled');
input2.vm.$emit('update:valid', true);
await nextTick();
expect(getStepNav().attributes('nextbuttonenabled')).toBe('true');
});
it('passes validate state to all input wrapper children when next is clicked', async () => {
forEachInputWrapper((inputWrapper) => {
expectFalsyAttributeValue(inputWrapper, 'validate');
});
await mockNextClick();
expect(input1.attributes('validate')).toBe('true');
});
it('not emitting a valid state is considered valid', async () => {
// input1 does not emit a update:valid event
input2.vm.$emit('update:valid', true);
await mockNextClick();
expect(getStepNav().attributes('nextbuttonenabled')).toBe('true');
});
});
describe('template compilation', () => {
beforeEach(() => {
createComponent();
findInputWrappers();
});
it('injects the template when an input wrapper emits a beforeUpdate:compiled event', async () => {
input1.vm.$emit('beforeUpdate:compiled');
expect(wrapper.vm.compiled.toString()).toBe(compiledYamlAfterInitialLoad);
});
it('lets the "update:compiled" event bubble upwards', async () => {
const compiled = parseDocument(compiledYaml);
await input1.vm.$emit('update:compiled', compiled);
const updateEvents = wrapper.emitted()['update:compiled'];
const latestUpdateEvent = updateEvents[updateEvents.length - 1];
expect(latestUpdateEvent[0].toString()).toBe(compiled.toString());
});
});
});
export const stepInputs = `
- label: "Build Steps"
description: "Enter the steps necessary for your application."
widget: text
target: $BUILD_STEPS
- label: "Select a deployment branch"
description: "Select the branch we should use to generate your site from."
widget: text
target: $BRANCH
pattern: "^[a-z]+$"
invalidFeedback: "This field may only contain lowercase letters"
required: true
`;
export const stepTemplate = `template:
pages:
script: $BUILD_STEPS
artifacts:
paths:
- public
only:
- $BRANCH
`;
export const compiledYamlBeforeSetup = `abc: def`;
export const compiledYamlAfterInitialLoad = `abc: def
pages:
script: $BUILD_STEPS
artifacts:
paths:
- public
only:
- $BRANCH
`;
export const compiledYaml = `abc: def
pages:
script: foo
artifacts:
paths:
- public
only:
- bar
`;
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