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 {
data-testid="branch_selector_group"
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-alert
v-if="!!commitError"
......@@ -206,7 +206,7 @@ export default {
>
{{ commitError }}
</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>
<gl-button
: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
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 */
/*
* 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 = {
name: '[name].[contenthash:8].[ext]',
},
},
{
test: /\.(yml|yaml)$/,
loader: 'raw-loader',
},
],
},
......
......@@ -13130,6 +13130,9 @@ msgstr ""
msgid "Draft"
msgstr ""
msgid "Draft: %{filename}"
msgstr ""
msgid "Drag your designs here or %{linkStart}click to upload%{linkEnd}."
msgstr ""
......@@ -35006,6 +35009,9 @@ msgstr ""
msgid "Start free trial"
msgstr ""
msgid "Start inputting changes and we will generate a YAML-file for you to add to your repository"
msgstr ""
msgid "Start merge train"
msgstr ""
......@@ -35273,6 +35279,9 @@ msgstr ""
msgid "Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments."
msgstr ""
msgid "Step %{currentStep} of %{stepCount}"
msgstr ""
msgid "Step 1."
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:
only:
- 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