Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
commit41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch)
tree9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/assets/javascripts/pipeline_wizard
parent0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff)
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipeline_wizard')
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/commit.vue4
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input.vue99
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue149
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/list.vue195
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue185
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue65
-rw-r--r--app/assets/javascripts/pipeline_wizard/validators.js4
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'));