Commit 45f078f2 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'pipeline-wizard' into 'master'

Add a Pipeline Wizard

See merge request gitlab-org/gitlab!76128
parents 5fd785d6 3b1ea744
...@@ -195,7 +195,7 @@ export default { ...@@ -195,7 +195,7 @@ export default {
data-testid="branch_selector_group" data-testid="branch_selector_group"
label-for="branch" label-for="branch"
> >
<ref-selector id="branch" v-model="branch" data-testid="branch" :project-id="projectPath" /> <ref-selector id="branch" v-model="branch" :project-id="projectPath" data-testid="branch" />
</gl-form-group> </gl-form-group>
<gl-alert <gl-alert
v-if="!!commitError" v-if="!!commitError"
...@@ -206,7 +206,7 @@ export default { ...@@ -206,7 +206,7 @@ export default {
> >
{{ commitError }} {{ commitError }}
</gl-alert> </gl-alert>
<step-nav show-back-button v-bind="$props" @back="$emit('go-back')"> <step-nav show-back-button v-bind="$props" @back="$emit('back')">
<template #after> <template #after>
<gl-button <gl-button
:disabled="isCommitButtonEnabled" :disabled="isCommitButtonEnabled"
......
<script>
import { GlProgressBar } from '@gitlab/ui';
import { Document } from 'yaml';
import { merge } from '~/lib/utils/yaml';
import { __ } from '~/locale';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
import YamlEditor from './editor.vue';
import WizardStep from './step.vue';
import CommitStep from './commit.vue';
export const i18n = {
stepNofN: __('Step %{currentStep} of %{stepCount}'),
draft: __('Draft: %{filename}'),
overlayMessage: __(`Start inputting changes and we will generate a
YAML-file for you to add to your repository`),
};
export default {
name: 'PipelineWizardWrapper',
i18n,
components: {
GlProgressBar,
YamlEditor,
WizardStep,
CommitStep,
},
props: {
steps: {
type: Object,
required: true,
validator: isValidStepSeq,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
filename: {
type: String,
required: true,
},
},
data() {
return {
highlightPath: null,
currentStepIndex: 0,
// TODO: In order to support updating existing pipelines, the below
// should contain a parsed version of an existing .gitlab-ci.yml.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/355306
compiled: new Document({}),
showPlaceholder: true,
pipelineBlob: null,
placeholder: this.getPlaceholder(),
};
},
computed: {
currentStepConfig() {
return this.steps.get(this.currentStepIndex);
},
currentStepInputs() {
return this.currentStepConfig.get('inputs').toJSON();
},
currentStepTemplate() {
return this.currentStepConfig.get('template', true);
},
currentStep() {
return this.currentStepIndex + 1;
},
stepCount() {
return this.steps.items.length + 1;
},
progress() {
return Math.ceil((this.currentStep / (this.stepCount + 1)) * 100);
},
isLastStep() {
return this.currentStep === this.stepCount;
},
},
watch: {
isLastStep(value) {
if (value) this.resetHighlight();
},
},
methods: {
resetHighlight() {
this.highlightPath = null;
},
onUpdate() {
this.showPlaceholder = false;
},
onEditorUpdate(blob) {
// TODO: In a later iteration, we could add a loopback allowing for
// changes from the editor to flow back into the model
// see https://gitlab.com/gitlab-org/gitlab/-/issues/355312
this.pipelineBlob = blob;
},
getPlaceholder() {
const doc = new Document({});
this.steps.items.forEach((tpl) => {
merge(doc, tpl.get('template').clone());
});
return doc;
},
},
};
</script>
<template>
<div class="row gl-mt-8">
<main class="col-md-6 gl-pr-8">
<header class="gl-mb-5">
<h3 class="text-secondary gl-mt-0" data-testid="step-count">
{{ sprintf($options.i18n.stepNofN, { currentStep, stepCount }) }}
</h3>
<gl-progress-bar :value="progress" variant="success" />
</header>
<section class="gl-mb-4">
<commit-step
v-if="isLastStep"
ref="step"
:default-branch="defaultBranch"
:file-content="pipelineBlob"
:filename="filename"
:project-path="projectPath"
@back="currentStepIndex--"
/>
<wizard-step
v-else
:key="currentStepIndex"
ref="step"
:compiled.sync="compiled"
:has-next-step="currentStepIndex < steps.items.length"
:has-previous-step="currentStepIndex > 0"
:highlight.sync="highlightPath"
:inputs="currentStepInputs"
:template="currentStepTemplate"
@back="currentStepIndex--"
@next="currentStepIndex++"
@update:compiled="onUpdate"
/>
</section>
</main>
<aside class="col-md-6 gl-pt-3">
<div
class="gl-border-1 gl-border-gray-100 gl-border-solid border-radius-default gl-bg-gray-10"
>
<h6 class="gl-p-2 gl-px-4 text-secondary" data-testid="editor-header">
{{ sprintf($options.i18n.draft, { filename }) }}
</h6>
<div class="gl-relative gl-overflow-hidden">
<yaml-editor
:aria-hidden="showPlaceholder"
:doc="showPlaceholder ? placeholder : compiled"
:filename="filename"
:highlight="highlightPath"
class="gl-w-full"
@update:yaml="onEditorUpdate"
/>
<div
v-if="showPlaceholder"
class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 gl-filter-blur-1"
data-testid="placeholder-overlay"
>
<div
class="gl-absolute gl-top-0 gl-right-0 gl-bottom-0 gl-left-0 bg-white gl-opacity-5 gl-z-index-2"
></div>
<div
class="gl-relative gl-h-full gl-display-flex gl-align-items-center gl-justify-content-center gl-z-index-3"
>
<div class="gl-max-w-34">
<h4 data-testid="filename">{{ filename }}</h4>
<p data-testid="description">
{{ $options.i18n.overlayMessage }}
</p>
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</template>
<script>
import { parseDocument } from 'yaml';
import WizardWrapper from './components/wrapper.vue';
export default {
name: 'PipelineWizard',
components: {
WizardWrapper,
},
props: {
template: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
defaultFilename: {
type: String,
required: false,
default: '.gitlab-ci.yml',
},
},
computed: {
parsedTemplate() {
return this.template ? parseDocument(this.template) : null;
},
title() {
return this.parsedTemplate?.get('title');
},
description() {
return this.parsedTemplate?.get('description');
},
filename() {
return this.parsedTemplate?.get('filename') || this.defaultFilename;
},
steps() {
return this.parsedTemplate?.get('steps');
},
},
};
</script>
<template>
<div>
<div class="gl-my-8">
<h2 class="gl-mb-4" data-testid="title">{{ title }}</h2>
<p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description">
{{ description }}
</p>
</div>
<wizard-wrapper
v-if="steps"
:default-branch="defaultBranch"
:filename="filename"
:project-path="projectPath"
:steps="steps"
/>
</div>
</template>
import { isSeq } from 'yaml';
export const isValidStepSeq = (v) =>
isSeq(v) && v.items.every((s) => s.get('inputs') && s.get('template'));
...@@ -342,4 +342,27 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 ...@@ -342,4 +342,27 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
} }
} }
/* End gitlab-ui#1709 */ /* End gitlab-ui#1709 */
/*
* The below two styles will be moved to @gitlab/ui by
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1750
*/
.gl-max-w-34 {
max-width: 34 * $grid-size;
}
.gl-max-w-80 {
max-width: 80 * $grid-size;
}
/*
* The below style will be moved to @gitlab/ui by
* https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1751
*/
.gl-filter-blur-1 {
backdrop-filter: blur(2px);
/* stylelint-disable property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px); // still required by Safari
}
...@@ -363,6 +363,10 @@ module.exports = { ...@@ -363,6 +363,10 @@ module.exports = {
name: '[name].[contenthash:8].[ext]', name: '[name].[contenthash:8].[ext]',
}, },
}, },
{
test: /\.(yml|yaml)$/,
loader: 'raw-loader',
},
], ],
}, },
......
...@@ -13130,6 +13130,9 @@ msgstr "" ...@@ -13130,6 +13130,9 @@ msgstr ""
msgid "Draft" msgid "Draft"
msgstr "" msgstr ""
msgid "Draft: %{filename}"
msgstr ""
msgid "Drag your designs here or %{linkStart}click to upload%{linkEnd}." msgid "Drag your designs here or %{linkStart}click to upload%{linkEnd}."
msgstr "" msgstr ""
...@@ -35006,6 +35009,9 @@ msgstr "" ...@@ -35006,6 +35009,9 @@ msgstr ""
msgid "Start free trial" msgid "Start free trial"
msgstr "" msgstr ""
msgid "Start inputting changes and we will generate a YAML-file for you to add to your repository"
msgstr ""
msgid "Start merge train" msgid "Start merge train"
msgstr "" msgstr ""
...@@ -35273,6 +35279,9 @@ msgstr "" ...@@ -35273,6 +35279,9 @@ msgstr ""
msgid "Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." msgid "Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments."
msgstr "" msgstr ""
msgid "Step %{currentStep} of %{stepCount}"
msgstr ""
msgid "Step 1." msgid "Step 1."
msgstr "" msgstr ""
......
import { Document, parseDocument } from 'yaml';
import { GlProgressBar } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PipelineWizardWrapper, { i18n } from '~/pipeline_wizard/components/wrapper.vue';
import WizardStep from '~/pipeline_wizard/components/step.vue';
import CommitStep from '~/pipeline_wizard/components/commit.vue';
import YamlEditor from '~/pipeline_wizard/components/editor.vue';
import { sprintf } from '~/locale';
import { steps as stepsYaml } from '../mock/yaml';
describe('Pipeline Wizard - wrapper.vue', () => {
let wrapper;
const steps = parseDocument(stepsYaml).toJS();
const getAsYamlNode = (value) => new Document(value).contents;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(PipelineWizardWrapper, {
propsData: {
projectPath: '/user/repo',
defaultBranch: 'main',
filename: '.gitlab-ci.yml',
steps: getAsYamlNode(steps),
...props,
},
});
};
const getEditorContent = () => {
return wrapper.getComponent(YamlEditor).attributes().doc.toString();
};
const getStepWrapper = () => wrapper.getComponent(WizardStep);
const getGlProgressBarWrapper = () => wrapper.getComponent(GlProgressBar);
describe('display', () => {
afterEach(() => {
wrapper.destroy();
});
it('shows the steps', () => {
createComponent();
expect(getStepWrapper().exists()).toBe(true);
});
it('shows the progress bar', () => {
createComponent();
const expectedMessage = sprintf(i18n.stepNofN, {
currentStep: 1,
stepCount: 3,
});
expect(wrapper.findByTestId('step-count').text()).toBe(expectedMessage);
expect(getGlProgressBarWrapper().exists()).toBe(true);
});
it('shows the editor', () => {
createComponent();
expect(wrapper.findComponent(YamlEditor).exists()).toBe(true);
});
it('shows the editor header with the default filename', () => {
createComponent();
const expectedMessage = sprintf(i18n.draft, {
filename: '.gitlab-ci.yml',
});
expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage);
});
it('shows the editor header with a custom filename', async () => {
const filename = 'my-file.yml';
createComponent({
filename,
});
const expectedMessage = sprintf(i18n.draft, {
filename,
});
expect(wrapper.findByTestId('editor-header').text()).toBe(expectedMessage);
});
});
describe('steps', () => {
const totalSteps = steps.length + 1;
// **Note** on `expectProgressBarValue`
// Why are we expecting 50% here and not 66% or even 100%?
// The reason is mostly a UX thing.
// First, we count the commit step as an extra step, so that would
// be 66% by now (2 of 3).
// But then we add yet another one to the calc, because when we
// arrived on the second step's page, it's not *completed* (which is
// what the progress bar indicates). So in that case we're at 33%.
// Lastly, we want to start out with the progress bar not at zero,
// because UX research indicates that makes a process like this less
// intimidating, so we're always adding one step to the value bar
// (but not to the step counter. Now we're back at 50%.
describe.each`
step | navigationEventChain | expectStepNumber | expectCommitStepShown | expectStepDef | expectProgressBarValue
${'initial step'} | ${[]} | ${1} | ${false} | ${steps[0]} | ${25}
${'second step'} | ${['next']} | ${2} | ${false} | ${steps[1]} | ${50}
${'commit step'} | ${['next', 'next']} | ${3} | ${true} | ${null} | ${75}
${'stepping back'} | ${['next', 'back']} | ${1} | ${false} | ${steps[0]} | ${25}
${'clicking next>next>back'} | ${['next', 'next', 'back']} | ${2} | ${false} | ${steps[1]} | ${50}
${'clicking all the way through and back'} | ${['next', 'next', 'back', 'back']} | ${1} | ${false} | ${steps[0]} | ${25}
`(
'$step',
({
navigationEventChain,
expectStepNumber,
expectCommitStepShown,
expectStepDef,
expectProgressBarValue,
}) => {
beforeAll(async () => {
createComponent();
for (const emittedValue of navigationEventChain) {
wrapper.findComponent({ ref: 'step' }).vm.$emit(emittedValue);
// We have to wait for the next step to be mounted
// before we can emit the next event, so we have to await
// inside the loop.
// eslint-disable-next-line no-await-in-loop
await nextTick();
}
});
afterAll(() => {
wrapper.destroy();
});
if (expectCommitStepShown) {
it('does not show the step wrapper', async () => {
expect(wrapper.findComponent(WizardStep).exists()).toBe(false);
});
it('shows the commit step page', () => {
expect(wrapper.findComponent(CommitStep).exists()).toBe(true);
});
} else {
it('passes the correct step config to the step component', async () => {
expect(getStepWrapper().props('inputs')).toMatchObject(expectStepDef.inputs);
});
it('does not show the commit step page', () => {
expect(wrapper.findComponent(CommitStep).exists()).toBe(false);
});
}
it('updates the progress bar', () => {
expect(getGlProgressBarWrapper().attributes('value')).toBe(`${expectProgressBarValue}`);
});
it('updates the step number', () => {
const expectedMessage = sprintf(i18n.stepNofN, {
currentStep: expectStepNumber,
stepCount: totalSteps,
});
expect(wrapper.findByTestId('step-count').text()).toBe(expectedMessage);
});
},
);
});
describe('editor overlay', () => {
beforeAll(() => {
createComponent();
});
afterAll(() => {
wrapper.destroy();
});
it('initially shows a placeholder', async () => {
const editorContent = getEditorContent();
await nextTick();
expect(editorContent).toBe('foo: $FOO\nbar: $BAR\n');
});
it('shows an overlay with help text after setup', () => {
expect(wrapper.findByTestId('placeholder-overlay').exists()).toBe(true);
expect(wrapper.findByTestId('filename').text()).toBe('.gitlab-ci.yml');
expect(wrapper.findByTestId('description').text()).toBe(i18n.overlayMessage);
});
it('does not show overlay when content has changed', async () => {
const newCompiledDoc = new Document({ faa: 'bur' });
await getStepWrapper().vm.$emit('update:compiled', newCompiledDoc);
await nextTick();
const overlay = wrapper.findByTestId('placeholder-overlay');
expect(overlay.exists()).toBe(false);
});
});
describe('editor updates', () => {
beforeAll(() => {
createComponent();
});
afterAll(() => {
wrapper.destroy();
});
it('editor reflects changes', async () => {
const newCompiledDoc = new Document({ faa: 'bur' });
await getStepWrapper().vm.$emit('update:compiled', newCompiledDoc);
expect(getEditorContent()).toBe(newCompiledDoc.toString());
});
});
describe('line highlights', () => {
beforeAll(() => {
createComponent();
});
afterAll(() => {
wrapper.destroy();
});
it('highlight requests by the step get passed on to the editor', async () => {
const highlight = 'foo';
await getStepWrapper().vm.$emit('update:highlight', highlight);
expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(highlight);
});
it('removes the highlight when clicking through to the commit step', async () => {
// Simulate clicking through all steps until the last one
await Promise.all(
steps.map(async () => {
await getStepWrapper().vm.$emit('next');
await nextTick();
}),
);
expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(null);
});
});
});
...@@ -43,3 +43,43 @@ pages: ...@@ -43,3 +43,43 @@ pages:
only: only:
- bar - bar
`; `;
export const steps = `
- inputs:
- label: foo
target: $FOO
widget: text
template:
foo: $FOO
- inputs:
- label: bar
target: $BAR
widget: text
template:
bar: $BAR
`;
export const fullTemplate = `
title: some title
description: some description
filename: foo.yml
steps:
- inputs:
- widget: text
label: foo
target: $BAR
template:
foo: $BAR
`;
export const fullTemplateWithoutFilename = `
title: some title
description: some description
steps:
- inputs:
- widget: text
label: foo
target: $BAR
template:
foo: $BAR
`;
import { parseDocument } from 'yaml';
import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
import PipelineWizardWrapper from '~/pipeline_wizard/components/wrapper.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
fullTemplate as template,
fullTemplateWithoutFilename as templateWithoutFilename,
} from './mock/yaml';
const projectPath = 'foo/bar';
const defaultBranch = 'main';
describe('PipelineWizard', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(PipelineWizard, {
propsData: {
projectPath,
defaultBranch,
template,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('mounts without error', () => {
const consoleSpy = jest.spyOn(console, 'error');
createComponent();
expect(consoleSpy).not.toHaveBeenCalled();
expect(wrapper.exists()).toBe(true);
});
it('mounts the wizard wrapper', () => {
createComponent();
expect(wrapper.findComponent(PipelineWizardWrapper).exists()).toBe(true);
});
it('passes the correct steps prop to the wizard wrapper', () => {
createComponent();
expect(wrapper.findComponent(PipelineWizardWrapper).props('steps')).toEqual(
parseDocument(template).get('steps'),
);
});
it('passes all other expected props to the wizard wrapper', () => {
createComponent();
expect(wrapper.findComponent(PipelineWizardWrapper).props()).toEqual(
expect.objectContaining({
defaultBranch,
projectPath,
filename: parseDocument(template).get('filename'),
}),
);
});
it('passes ".gitlab-ci.yml" as default filename to the wizard wrapper', () => {
createComponent({ template: templateWithoutFilename });
expect(wrapper.findComponent(PipelineWizardWrapper).attributes('filename')).toBe(
'.gitlab-ci.yml',
);
});
it('allows overriding the defaultFilename with `defaultFilename` prop', () => {
const defaultFilename = 'foobar.yml';
createComponent({
template: templateWithoutFilename,
defaultFilename,
});
expect(wrapper.findComponent(PipelineWizardWrapper).attributes('filename')).toBe(
defaultFilename,
);
});
it('displays the title', () => {
createComponent();
expect(wrapper.findByTestId('title').text()).toBe(
parseDocument(template).get('title').toString(),
);
});
it('displays the description', () => {
createComponent();
expect(wrapper.findByTestId('description').text()).toBe(
parseDocument(template).get('description').toString(),
);
});
});
import { Document, parseDocument } from 'yaml';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
import { steps as stepsYaml } from './mock/yaml';
describe('prop validation', () => {
const steps = parseDocument(stepsYaml).toJS();
const getAsYamlNode = (value) => new Document(value).contents;
it('allows passing yaml nodes to the steps prop', () => {
const validSteps = getAsYamlNode(steps);
expect(isValidStepSeq(validSteps)).toBe(true);
});
it.each`
scenario | stepsValue
${'not a seq'} | ${{ foo: 'bar' }}
${'a step missing an input'} | ${[{ template: 'baz: boo' }]}
${'an empty seq'} | ${[]}
`('throws an error when passing $scenario to the steps prop', ({ stepsValue }) => {
expect(isValidStepSeq(stepsValue)).toBe(false);
});
});
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