diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-18 23:02:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-18 23:02:30 +0300 |
commit | 41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch) | |
tree | 9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/assets/javascripts/pipeline_wizard | |
parent | 0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff) |
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipeline_wizard')
7 files changed, 699 insertions, 2 deletions
diff --git a/app/assets/javascripts/pipeline_wizard/components/commit.vue b/app/assets/javascripts/pipeline_wizard/components/commit.vue index 518b41c66b1..e68458a494f 100644 --- a/app/assets/javascripts/pipeline_wizard/components/commit.vue +++ b/app/assets/javascripts/pipeline_wizard/components/commit.vue @@ -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" diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input.vue new file mode 100644 index 00000000000..9a0c8026648 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/input.vue @@ -0,0 +1,99 @@ +<script> +import { isNode, isDocument, isSeq, visit } from 'yaml'; +import { capitalize } from 'lodash'; +import TextWidget from '~/pipeline_wizard/components/widgets/text.vue'; +import ListWidget from '~/pipeline_wizard/components/widgets/list.vue'; + +const widgets = { + TextWidget, + ListWidget, +}; + +function isNullOrUndefined(v) { + return [undefined, null].includes(v); +} + +export default { + components: { + ...widgets, + }, + props: { + template: { + type: Object, + required: true, + validator: (v) => isNode(v), + }, + compiled: { + type: Object, + required: true, + validator: (v) => isDocument(v) || isNode(v), + }, + target: { + type: String, + required: true, + validator: (v) => /^\$.*/g.test(v), + }, + widget: { + type: String, + required: true, + validator: (v) => { + return Object.keys(widgets).includes(`${capitalize(v)}Widget`); + }, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + path() { + let res; + visit(this.template, (seqKey, node, path) => { + if (node && node.value === this.target) { + // `path` is an array of objects (all the node's parents) + // So this reducer will reduce it to an array of the path's keys, + // e.g. `[ 'foo', 'bar', '0' ]` + res = path.reduce((p, { key }) => (key ? [...p, `${key}`] : p), []); + const parent = path[path.length - 1]; + if (isSeq(parent)) { + res.push(seqKey); + } + } + }); + return res; + }, + }, + methods: { + compile(v) { + if (!this.path) return; + if (isNullOrUndefined(v)) { + this.compiled.deleteIn(this.path); + } + this.compiled.setIn(this.path, v); + }, + onModelChange(v) { + this.$emit('beforeUpdate:compiled'); + this.compile(v); + this.$emit('update:compiled', this.compiled); + this.$emit('highlight', this.path); + }, + onValidationStateChange(v) { + this.$emit('update:valid', v); + }, + }, +}; +</script> + +<template> + <div> + <component + :is="`${widget}-widget`" + ref="widget" + :validate="validate" + v-bind="$attrs" + @input="onModelChange" + @update:valid="onValidationStateChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue new file mode 100644 index 00000000000..c6f793e4cc5 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/step.vue @@ -0,0 +1,149 @@ +<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> diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue new file mode 100644 index 00000000000..a5ce56daf07 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/widgets/list.vue @@ -0,0 +1,195 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const VALIDATION_STATE = { + NO_VALIDATION: null, + INVALID: false, + VALID: true, +}; + +export const i18n = { + addStepButtonLabel: s__('PipelineWizardListWidget|add another step'), + removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'), + invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'), + errors: { + needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'), + }, +}; + +export default { + i18n, + name: 'ListWidget', + components: { + GlButton, + GlFormGroup, + GlFormInputGroup, + }, + props: { + label: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + default: null, + }, + placeholder: { + type: String, + required: false, + default: null, + }, + default: { + type: Array, + required: false, + default: null, + }, + invalidFeedback: { + type: String, + required: false, + default: i18n.invalidFeedback, + }, + id: { + type: String, + required: false, + default: () => uniqueId('listWidget-'), + }, + pattern: { + type: String, + required: false, + default: null, + }, + required: { + type: Boolean, + required: false, + default: false, + }, + validate: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + touched: false, + value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)], + }; + }, + computed: { + sanitizedValue() { + // Filter out empty steps + return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || []; + }, + hasAnyValue() { + return this.value.some(({ value }) => Boolean(value)); + }, + needsAnyValue() { + return this.required && !this.value.some(({ value }) => Boolean(value)); + }, + inputFieldStates() { + return this.value.map(this.getValidationStateForValue); + }, + inputGroupState() { + return this.showValidationState + ? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID) + : VALIDATION_STATE.NO_VALIDATION; + }, + showValidationState() { + return this.touched || this.validate; + }, + feedback() { + return this.needsAnyValue + ? this.$options.i18n.errors.needsAnyValueError + : this.invalidFeedback; + }, + }, + async created() { + if (this.default) { + // emit an updated default value + await this.$nextTick(); + this.$emit('input', this.sanitizedValue); + } + }, + methods: { + addInputField() { + this.value.push(this.getAsValueEntry(null)); + }, + getAsValueEntry(value) { + return { + id: uniqueId('listValue-'), + value, + }; + }, + getValidationStateForValue({ value }, fieldIndex) { + // If we require a value to be set, mark the first + // field as invalid, but not all of them. + if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID; + if (!value) return VALIDATION_STATE.NO_VALIDATION; + return this.passesPatternValidation(value) + ? VALIDATION_STATE.VALID + : VALIDATION_STATE.INVALID; + }, + passesPatternValidation(v) { + return !this.pattern || new RegExp(this.pattern).test(v); + }, + async onValueUpdate() { + await this.$nextTick(); + this.$emit('input', this.sanitizedValue); + }, + onTouch() { + this.touched = true; + }, + removeValue(index) { + this.value.splice(index, 1); + this.onValueUpdate(); + }, + }, +}; +</script> + +<template> + <div class="gl-mb-6"> + <gl-form-group + :invalid-feedback="feedback" + :label="label" + :label-description="description" + :state="inputGroupState" + class="gl-mb-2" + > + <gl-form-input-group + v-for="(item, i) in value" + :key="item.id" + v-model.trim="value[i].value" + :placeholder="i === 0 ? placeholder : undefined" + :state="inputFieldStates[i]" + class="gl-mb-2" + type="text" + @blur="onTouch" + @input="onValueUpdate" + > + <template v-if="value.length > 1" #append> + <gl-button + :aria-label="$options.i18n.removeStepButtonLabel" + category="secondary" + data-testid="remove-step-button" + icon="remove" + @click="removeValue" + /> + </template> + </gl-form-input-group> + </gl-form-group> + <gl-button + category="tertiary" + data-testid="add-step-button" + icon="plus" + size="small" + variant="confirm" + @click="addInputField" + > + {{ $options.i18n.addStepButtonLabel }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue new file mode 100644 index 00000000000..b7207576ddc --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -0,0 +1,185 @@ +<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> diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue new file mode 100644 index 00000000000..7200b4e3782 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -0,0 +1,65 @@ +<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> diff --git a/app/assets/javascripts/pipeline_wizard/validators.js b/app/assets/javascripts/pipeline_wizard/validators.js new file mode 100644 index 00000000000..57cd56b23a5 --- /dev/null +++ b/app/assets/javascripts/pipeline_wizard/validators.js @@ -0,0 +1,4 @@ +import { isSeq } from 'yaml'; + +export const isValidStepSeq = (v) => + isSeq(v) && v.items.every((s) => s.get('inputs') && s.get('template')); |