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:
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_editor')
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue42
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js17
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue167
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue164
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue57
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue32
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue107
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue18
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue61
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue17
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue56
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue68
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue48
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue254
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue72
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue78
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue45
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue70
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue92
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue188
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue126
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue154
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue26
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue89
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue69
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue235
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue56
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue72
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue83
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue26
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue156
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue72
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue145
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue301
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js127
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql21
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql29
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql13
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql13
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql18
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql5
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql7
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql7
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql5
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql13
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql41
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js86
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql23
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js146
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue440
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue181
56 files changed, 4458 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
new file mode 100644
index 00000000000..7b33d98bca0
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants';
+
+export default {
+ name: 'CodeSnippetAlert',
+ components: {
+ GlAlert,
+ },
+ inject: ['configurationPaths'],
+ props: {
+ source: {
+ type: String,
+ required: true,
+ validator: (source) => CODE_SNIPPET_SOURCES.includes(source),
+ },
+ },
+ computed: {
+ settings() {
+ return CODE_SNIPPET_SOURCE_SETTINGS[this.source];
+ },
+ configurationPath() {
+ return this.configurationPaths[this.source];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ variant="tip"
+ :title="__('Code snippet copied. Insert it in the correct location in the YAML file.')"
+ :dismiss-label="__('Dismiss')"
+ :primary-button-link="settings.docsPath"
+ :primary-button-text="__('Read documentation')"
+ :secondary-button-link="configurationPath"
+ :secondary-button-text="__('Go back to configuration')"
+ v-on="$listeners"
+ >
+ {{ __('Before inserting code, be sure to read the comment that separated each code group.') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js
new file mode 100644
index 00000000000..e4fd423249b
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js
@@ -0,0 +1,17 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from';
+export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing';
+export const CODE_SNIPPET_SOURCE_DAST = 'dast';
+
+export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING, CODE_SNIPPET_SOURCE_DAST];
+export const CODE_SNIPPET_SOURCE_SETTINGS = {
+ [CODE_SNIPPET_SOURCE_API_FUZZING]: {
+ datasetKey: 'apiFuzzingConfigurationPath',
+ docsPath: helpPagePath('user/application_security/api_fuzzing/index'),
+ },
+ [CODE_SNIPPET_SOURCE_DAST]: {
+ datasetKey: 'dastConfigurationPath',
+ docsPath: helpPagePath('user/application_security/dast/index'),
+ },
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
new file mode 100644
index 00000000000..4775836fcc6
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
@@ -0,0 +1,167 @@
+<script>
+import {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlSprintf,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlSprintf,
+ },
+ props: {
+ currentBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ defaultMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
+ isSaving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scrollToCommitForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ message: this.defaultMessage,
+ openMergeRequest: false,
+ sourceBranch: this.currentBranch,
+ };
+ },
+ computed: {
+ isCommitFormFilledOut() {
+ return this.message && this.sourceBranch;
+ },
+ isCurrentBranchSourceBranch() {
+ return this.sourceBranch === this.currentBranch;
+ },
+ isSubmitDisabled() {
+ return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile);
+ },
+ },
+ watch: {
+ scrollToCommitForm(flag) {
+ if (flag) {
+ this.scrollIntoView();
+ }
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit', {
+ message: this.message,
+ sourceBranch: this.sourceBranch,
+ openMergeRequest: this.openMergeRequest,
+ });
+ },
+ onReset() {
+ this.$emit('resetContent');
+ },
+ scrollIntoView() {
+ this.$el.scrollIntoView({ behavior: 'smooth' });
+ this.$emit('scrolled-to-commit-form');
+ },
+ },
+ i18n: {
+ commitMessage: __('Commit message'),
+ sourceBranch: __('Branch'),
+ startMergeRequest: __('Start a %{new_merge_request} with these changes'),
+ newMergeRequest: __('new merge request'),
+ commitChanges: __('Commit changes'),
+ resetContent: __('Reset'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
+ <gl-form-group
+ id="commit-group"
+ :label="$options.i18n.commitMessage"
+ label-cols-sm="2"
+ label-for="commit-message"
+ >
+ <gl-form-textarea
+ id="commit-message"
+ v-model="message"
+ class="gl-font-monospace!"
+ required
+ :placeholder="defaultMessage"
+ />
+ </gl-form-group>
+ <gl-form-group
+ id="source-branch-group"
+ :label="$options.i18n.sourceBranch"
+ label-cols-sm="2"
+ label-for="source-branch-field"
+ >
+ <gl-form-input
+ id="source-branch-field"
+ v-model="sourceBranch"
+ class="gl-font-monospace!"
+ required
+ data-qa-selector="source_branch_field"
+ />
+ <gl-form-checkbox
+ v-if="!isCurrentBranchSourceBranch"
+ v-model="openMergeRequest"
+ data-testid="new-mr-checkbox"
+ data-qa-selector="new_mr_checkbox"
+ class="gl-mt-3"
+ >
+ <gl-sprintf :message="$options.i18n.startMergeRequest">
+ <template #new_merge_request>
+ <strong>{{ $options.i18n.newMergeRequest }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-form-checkbox>
+ </gl-form-group>
+ <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
+ <gl-button
+ type="submit"
+ class="js-no-auto-disable gl-mr-3"
+ category="primary"
+ variant="confirm"
+ data-qa-selector="commit_changes_button"
+ :disabled="isSubmitDisabled"
+ :loading="isSaving"
+ >
+ {{ $options.i18n.commitChanges }}
+ </gl-button>
+ <gl-button type="reset" category="secondary" class="gl-mr-3">
+ {{ $options.i18n.resetContent }}
+ </gl-button>
+ </div>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
new file mode 100644
index 00000000000..9cbf60b1c8f
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
@@ -0,0 +1,164 @@
+<script>
+import { __, s__, sprintf } from '~/locale';
+import {
+ COMMIT_ACTION_CREATE,
+ COMMIT_ACTION_UPDATE,
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+} from '../../constants';
+import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
+import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
+import updateLastCommitBranchMutation from '../../graphql/mutations/client/update_last_commit_branch.mutation.graphql';
+import updatePipelineEtag from '../../graphql/mutations/client/update_pipeline_etag.mutation.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql';
+
+import CommitForm from './commit_form.vue';
+
+export default {
+ alertTexts: {
+ [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
+ [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
+ },
+ i18n: {
+ defaultCommitMessage: __('Update %{sourcePath} file'),
+ },
+ components: {
+ CommitForm,
+ },
+ inject: ['projectFullPath', 'ciConfigPath'],
+ props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scrollToCommitForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ commit: {},
+ isSaving: false,
+ };
+ },
+ apollo: {
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches.current.name;
+ },
+ },
+ },
+ computed: {
+ action() {
+ return this.isNewCiConfigFile ? COMMIT_ACTION_CREATE : COMMIT_ACTION_UPDATE;
+ },
+ defaultCommitMessage() {
+ return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
+ },
+ },
+ methods: {
+ async onCommitSubmit({ message, sourceBranch, openMergeRequest }) {
+ this.isSaving = true;
+
+ try {
+ const {
+ data: {
+ commitCreate: { errors, commitPipelinePath: pipelineEtag },
+ },
+ } = await this.$apollo.mutate({
+ mutation: commitCIFile,
+ variables: {
+ action: this.action,
+ projectPath: this.projectFullPath,
+ branch: sourceBranch,
+ startBranch: this.currentBranch,
+ message,
+ filePath: this.ciConfigPath,
+ content: this.ciFileContent,
+ lastCommitId: this.commitSha,
+ },
+ });
+
+ if (pipelineEtag) {
+ this.updatePipelineEtag(pipelineEtag);
+ }
+
+ if (errors?.length) {
+ this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors });
+ } else {
+ const params = openMergeRequest
+ ? {
+ type: COMMIT_SUCCESS_WITH_REDIRECT,
+ params: {
+ sourceBranch,
+ targetBranch: this.currentBranch,
+ },
+ }
+ : { type: COMMIT_SUCCESS };
+
+ this.$emit('commit', {
+ ...params,
+ });
+
+ this.updateLastCommitBranch(sourceBranch);
+ this.updateCurrentBranch(sourceBranch);
+
+ if (this.currentBranch === sourceBranch) {
+ this.$emit('updateCommitSha');
+ }
+ }
+ } catch (error) {
+ this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] });
+ } finally {
+ this.isSaving = false;
+ }
+ },
+ updateCurrentBranch(currentBranch) {
+ this.$apollo.mutate({
+ mutation: updateCurrentBranchMutation,
+ variables: { currentBranch },
+ });
+ },
+ updateLastCommitBranch(lastCommitBranch) {
+ this.$apollo.mutate({
+ mutation: updateLastCommitBranchMutation,
+ variables: { lastCommitBranch },
+ });
+ },
+ updatePipelineEtag(pipelineEtag) {
+ this.$apollo.mutate({ mutation: updatePipelineEtag, variables: { pipelineEtag } });
+ },
+ },
+};
+</script>
+
+<template>
+ <commit-form
+ :current-branch="currentBranch"
+ :default-message="defaultCommitMessage"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ :is-saving="isSaving"
+ :scroll-to-commit-form="scrollToCommitForm"
+ v-on="$listeners"
+ @submit="onCommitSubmit"
+ />
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
new file mode 100644
index 00000000000..0b57433e894
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions } from '../../../constants';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|🚀 Run your first pipeline'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|This template creates a simple test pipeline. To use it:',
+ ),
+ listItems: [
+ s__(
+ 'PipelineEditorTutorial|Commit the file to your repository. The pipeline then runs automatically.',
+ ),
+ s__('PipelineEditorTutorial|The pipeline status is at the top of the page.'),
+ s__(
+ 'PipelineEditorTutorial|Select the pipeline ID to view the full details about your first pipeline run.',
+ ),
+ ],
+ note: s__(
+ 'PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}',
+ ),
+ },
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ mixins: [Tracking.mixin()],
+ methods: {
+ trackHelpPageClick() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.helpDrawerLinks.runners, { label });
+ },
+ },
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
+};
+</script>
+<template>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <ol class="gl-mb-3">
+ <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li>
+ </ol>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.note">
+ <template #link="{ content }">
+ <gl-link :href="$options.RUNNER_HELP_URL" target="_blank" @click="trackHelpPageClick()">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue
new file mode 100644
index 00000000000..d2682cf6326
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|Get started with GitLab CI/CD'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application.',
+ ),
+ secondParagraph: s__(
+ 'PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor.',
+ ),
+ },
+ components: {
+ GlSprintf,
+ },
+};
+</script>
+<template>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.secondParagraph">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
new file mode 100644
index 00000000000..bc9203b9c5b
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ CI_EXAMPLES_LINK,
+ CI_HELP_LINK,
+ CI_NEEDS_LINK,
+ CI_YAML_LINK,
+ pipelineEditorTrackingOptions,
+} from '../../../constants';
+
+export default {
+ CI_EXAMPLES_LINK,
+ CI_HELP_LINK,
+ CI_NEEDS_LINK,
+ CI_YAML_LINK,
+ i18n: {
+ title: s__('PipelineEditorTutorial|⚙️ Pipeline configuration reference'),
+ firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'),
+ browseExamples: s__(
+ 'PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}',
+ ),
+ viewSyntaxRef: s__(
+ 'PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}',
+ ),
+ learnMore: s__(
+ 'PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}',
+ ),
+ needs: s__(
+ 'PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}',
+ ),
+ },
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'],
+ methods: {
+ trackHelpPageClick(key) {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.helpDrawerLinks[key], { label });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <ul>
+ <li>
+ <gl-sprintf :message="$options.i18n.browseExamples">
+ <template #link="{ content }">
+ <gl-link
+ :href="ciExamplesHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_EXAMPLES_LINK)"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.viewSyntaxRef">
+ <template #link="{ content }">
+ <gl-link
+ :href="ymlHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_YAML_LINK)"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.learnMore">
+ <template #link="{ content }">
+ <gl-link
+ :href="ciHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_HELP_LINK)"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.needs">
+ <template #link="{ content }">
+ <gl-link
+ :href="needsHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_NEEDS_LINK)"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
new file mode 100644
index 00000000000..aeeb52319d2
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
@@ -0,0 +1,18 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|💡 Tip: Visualize and validate your pipeline'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.',
+ ),
+ },
+};
+</script>
+<template>
+ <div>
+ <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3>
+ <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
new file mode 100644
index 00000000000..375db7f3054
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlDrawer } from '@gitlab/ui';
+import { __ } from '~/locale';
+import FirstPipelineCard from './cards/first_pipeline_card.vue';
+import GettingStartedCard from './cards/getting_started_card.vue';
+import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue';
+import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue';
+
+const DRAWER_CARD_STYLES = ['gl-border-bottom-0', 'gl-pt-6!', 'gl-pb-0!', 'gl-line-height-20'];
+
+export default {
+ DRAWER_CARD_STYLES,
+ i18n: {
+ title: __('Help'),
+ },
+ components: {
+ FirstPipelineCard,
+ GettingStartedCard,
+ GlDrawer,
+ PipelineConfigReferenceCard,
+ VisualizeAndLintCard,
+ },
+ props: {
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ drawerCardStyles() {
+ return '';
+ },
+ drawerHeightOffset() {
+ const wrapperEl = document.querySelector('.content-wrapper');
+ return wrapperEl ? `${wrapperEl.offsetTop}px` : '';
+ },
+ },
+ methods: {
+ closeDrawer() {
+ this.$emit('close-drawer');
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ :header-height="drawerHeightOffset"
+ :open="isVisible"
+ :z-index="200"
+ @close="closeDrawer"
+ >
+ <template #title>
+ <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.title }}</h2>
+ </template>
+ <getting-started-card :class="$options.DRAWER_CARD_STYLES" />
+ <first-pipeline-card :class="$options.DRAWER_CARD_STYLES" />
+ <visualize-and-lint-card :class="$options.DRAWER_CARD_STYLES" />
+ <pipeline-config-reference-card :class="$options.DRAWER_CARD_STYLES" />
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue
new file mode 100644
index 00000000000..049504181c4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ props: {
+ jobName: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-w-13 gl-h-6 gl-font-sm gl-bg-white gl-inset-border-1-blue-500 gl-text-center gl-text-truncate gl-rounded-pill gl-px-4 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
+ >
+ {{ jobName }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
new file mode 100644
index 00000000000..42e2d34fa3a
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
+
+export default {
+ i18n: {
+ viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
+ },
+ components: {
+ SourceEditor,
+ GlIcon,
+ },
+ inject: ['ciConfigPath'],
+ props: {
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ failureType: null,
+ };
+ },
+ computed: {
+ fileGlobalId() {
+ return `${this.ciConfigPath}-${uniqueId()}`;
+ },
+ mergedYaml() {
+ return this.ciConfigData.mergedYaml;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ {{ $options.i18n.viewOnlyMessage }}
+ </div>
+ <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <source-editor
+ ref="editor"
+ :value="mergedYaml"
+ :file-name="ciConfigPath"
+ :file-global-id="fileGlobalId"
+ :editor-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
+ readOnly: true,
+ } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ v-on="$listeners"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
new file mode 100644
index 00000000000..201fba837e2
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants';
+
+export default {
+ i18n: {
+ browseTemplates: __('Browse templates'),
+ help: __('Help'),
+ },
+ TEMPLATE_REPOSITORY_URL,
+ components: {
+ GlButton,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ showDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ toggleDrawer() {
+ if (this.showDrawer) {
+ this.$emit('close-drawer');
+ } else {
+ this.$emit('open-drawer');
+ this.trackHelpDrawerClick();
+ }
+ },
+ trackHelpDrawerClick() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.openHelpDrawer, { label });
+ },
+ trackTemplateBrowsing() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+
+ this.track(actions.browseTemplates, { label });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <gl-button
+ :href="$options.TEMPLATE_REPOSITORY_URL"
+ size="small"
+ icon="external-link"
+ target="_blank"
+ data-testid="template-repo-link"
+ data-qa-selector="template_repo_link"
+ @click="trackTemplateBrowsing"
+ >
+ {{ $options.i18n.browseTemplates }}
+ </gl-button>
+ <gl-button
+ icon="information-o"
+ size="small"
+ data-testid="drawer-toggle"
+ data-qa-selector="drawer_toggle"
+ @click="toggleDrawer"
+ >
+ {{ $options.i18n.help }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
new file mode 100644
index 00000000000..255e3cb31f1
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
@@ -0,0 +1,48 @@
+<script>
+import { EDITOR_READY_EVENT } from '~/editor/constants';
+import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
+
+export default {
+ editorOptions: {
+ // Quick suggestions is so that monaco can provide
+ // autocomplete for keywords
+ quickSuggestions: true,
+ },
+ debounceValue: SOURCE_EDITOR_DEBOUNCE,
+ components: {
+ SourceEditor,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: ['ciConfigPath'],
+ inheritAttrs: false,
+ methods: {
+ onCiConfigUpdate(content) {
+ this.$emit('updateCiConfig', content);
+ },
+ registerCiSchema({ detail: { instance } }) {
+ if (this.glFeatures.schemaLinting) {
+ instance.use({ definition: CiSchemaExtension });
+ instance.registerCiSchema();
+ }
+ },
+ },
+ readyEvent: EDITOR_READY_EVENT,
+};
+</script>
+<template>
+ <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
+ <source-editor
+ ref="editor"
+ :debounce-value="$options.debounceValue"
+ :editor-options="$options.editorOptions"
+ :file-name="ciConfigPath"
+ v-bind="$attrs"
+ @[$options.readyEvent]="registerCiSchema($event)"
+ @input="onCiConfigUpdate"
+ v-on="$listeners"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue
new file mode 100644
index 00000000000..ef9acc1f8f1
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -0,0 +1,254 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlInfiniteScroll,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { produce } from 'immer';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import {
+ BRANCH_PAGINATION_LIMIT,
+ BRANCH_SEARCH_DEBOUNCE,
+ DEFAULT_FAILURE,
+} from '~/ci/pipeline_editor/constants';
+import updateCurrentBranchMutation from '~/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql';
+import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql';
+import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql';
+
+export default {
+ i18n: {
+ dropdownHeader: __('Switch branch'),
+ title: __('Branches'),
+ fetchError: __('Unable to fetch branch list for this project.'),
+ },
+ inputDebounce: BRANCH_SEARCH_DEBOUNCE,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlInfiniteScroll,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['projectFullPath', 'totalBranches'],
+ props: {
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ paginationLimit: {
+ type: Number,
+ required: false,
+ default: BRANCH_PAGINATION_LIMIT,
+ },
+ shouldLoadNewBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ availableBranches: [],
+ branchSelected: null,
+ pageLimit: this.paginationLimit,
+ pageCounter: 0,
+ searchTerm: '',
+ lastCommitBranch: '',
+ };
+ },
+ apollo: {
+ availableBranches: {
+ query: getAvailableBranchesQuery,
+ variables() {
+ return {
+ offset: 0,
+ projectFullPath: this.projectFullPath,
+ ...this.availableBranchesVariables,
+ };
+ },
+ update(data) {
+ return data.project?.repository?.branchNames || [];
+ },
+ result() {
+ this.pageCounter += 1;
+ },
+ error() {
+ this.showFetchError();
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches.current.name;
+ },
+ },
+ lastCommitBranch: {
+ query: getLastCommitBranch,
+ update(data) {
+ return data.workBranches.lastCommit.name;
+ },
+ result({ data }) {
+ if (data) {
+ const { name: lastCommitBranch } = data.workBranches.lastCommit;
+ if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) {
+ return;
+ }
+
+ this.availableBranches.unshift(lastCommitBranch);
+ }
+ },
+ },
+ },
+ computed: {
+ availableBranchesVariables() {
+ if (this.searchTerm.length > 0) {
+ return {
+ limit: this.totalBranches,
+ searchPattern: `*${this.searchTerm}*`,
+ };
+ }
+
+ return {
+ limit: this.paginationLimit,
+ searchPattern: '*',
+ };
+ },
+ enableBranchSwitcher() {
+ return this.availableBranches.length > 0 || this.searchTerm.length > 0;
+ },
+ isBranchesLoading() {
+ return this.$apollo.queries.availableBranches.loading;
+ },
+ },
+ watch: {
+ shouldLoadNewBranch(flag) {
+ if (flag) {
+ this.changeBranch(this.branchSelected);
+ }
+ },
+ },
+ methods: {
+ // if there is no searchPattern, paginate by {paginationLimit} branches
+ fetchNextBranches() {
+ if (
+ this.isBranchesLoading ||
+ this.searchTerm.length > 0 ||
+ this.availableBranches.length >= this.totalBranches
+ ) {
+ return;
+ }
+
+ this.$apollo.queries.availableBranches
+ .fetchMore({
+ variables: {
+ offset: this.pageCounter * this.paginationLimit,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ const previousBranches = previousResult.project.repository.branchNames;
+ const newBranches = fetchMoreResult.project.repository.branchNames;
+
+ return produce(fetchMoreResult, (draftData) => {
+ draftData.project.repository.branchNames = previousBranches.concat(newBranches);
+ });
+ },
+ })
+ .catch(this.showFetchError);
+ },
+ async changeBranch(newBranch) {
+ this.updateCurrentBranch(newBranch);
+ const updatedPath = setUrlParams({ branch_name: newBranch });
+ historyPushState(updatedPath);
+
+ // refetching the content will cause a lot of components to re-render,
+ // including the text editor which uses the commit sha to register the CI schema
+ // so we need to make sure the currentBranch (and consequently, the commitSha) are updated first
+ await this.$nextTick();
+ this.$emit('refetchContent');
+ },
+ selectBranch(newBranch) {
+ if (newBranch !== this.currentBranch) {
+ // If there are unsaved changes, we want to show the user
+ // a modal to confirm what to do with these before changing
+ // branches.
+ if (this.hasUnsavedChanges) {
+ this.branchSelected = newBranch;
+ this.$emit('select-branch', newBranch);
+ } else {
+ this.changeBranch(newBranch);
+ }
+ }
+ },
+ async setSearchTerm(newSearchTerm) {
+ this.pageCounter = 0;
+ this.searchTerm = newSearchTerm.trim();
+ },
+ showFetchError() {
+ this.$emit('showError', {
+ type: DEFAULT_FAILURE,
+ reasons: [this.$options.i18n.fetchError],
+ });
+ },
+ updateCurrentBranch(currentBranch) {
+ this.$apollo.mutate({
+ mutation: updateCurrentBranchMutation,
+ variables: { currentBranch },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ v-gl-tooltip.hover
+ :title="$options.i18n.dropdownHeader"
+ :header-text="$options.i18n.dropdownHeader"
+ :text="currentBranch"
+ :disabled="!enableBranchSwitcher"
+ icon="branch"
+ data-qa-selector="branch_selector_button"
+ data-testid="branch-selector"
+ >
+ <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" />
+ <gl-dropdown-section-header>
+ {{ $options.i18n.title }}
+ </gl-dropdown-section-header>
+
+ <gl-infinite-scroll
+ :fetched-items="availableBranches.length"
+ :max-list-height="250"
+ data-qa-selector="branch_menu_container"
+ @bottomReached="fetchNextBranches"
+ >
+ <template #items>
+ <gl-dropdown-item
+ v-for="branch in availableBranches"
+ :key="branch"
+ :is-checked="currentBranch === branch"
+ is-check-item
+ data-qa-selector="branch_menu_item_button"
+ @click="selectBranch(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ </template>
+ <template #default>
+ <gl-dropdown-item v-if="isBranchesLoading" key="loading">
+ <gl-loading-icon size="lg" />
+ </gl-dropdown-item>
+ </template>
+ </gl-infinite-scroll>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
new file mode 100644
index 00000000000..84c29e48114
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants';
+import FileTreePopover from '../popovers/file_tree_popover.vue';
+import BranchSwitcher from './branch_switcher.vue';
+
+export default {
+ components: {
+ BranchSwitcher,
+ FileTreePopover,
+ GlButton,
+ },
+ props: {
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldLoadNewBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
+ },
+ },
+ computed: {
+ isAppLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
+ },
+ showFileTreeToggle() {
+ return !this.isNewCiConfigFile && this.appStatus !== EDITOR_APP_STATUS_EMPTY;
+ },
+ },
+ methods: {
+ onFileTreeBtnClick() {
+ this.$emit('toggle-file-tree');
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-4">
+ <gl-button
+ v-if="showFileTreeToggle"
+ id="file-tree-toggle"
+ icon="file-tree"
+ data-testid="file-tree-toggle"
+ :aria-label="__('File Tree')"
+ :loading="isAppLoading"
+ @click="onFileTreeBtnClick"
+ />
+ <file-tree-popover v-if="showFileTreeToggle" />
+ <branch-switcher
+ :has-unsaved-changes="hasUnsavedChanges"
+ :should-load-new-branch="shouldLoadNewBranch"
+ v-on="$listeners"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue
new file mode 100644
index 00000000000..280cd729a43
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlAlert, GlTooltipDirective } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import { FILE_TREE_TIP_DISMISSED_KEY } from '../../constants';
+import FileItem from './file_item.vue';
+
+const i18n = {
+ tipBtn: __('Learn more'),
+ tipDescription: s__(
+ 'PipelineEditorFileTree|When you use the include keyword to add pipeline configuration from files in the project, those files will be listed here.',
+ ),
+ tipTitle: s__('PipelineEditorFileTree|Configuration files added with the include keyword'),
+};
+
+export default {
+ i18n,
+ name: 'PipelineEditorFileTreeContainer',
+ components: {
+ FileIcon,
+ FileItem,
+ GlAlert,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['ciConfigPath', 'includesHelpPagePath'],
+ props: {
+ includes: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ canShowTip: localStorage.getItem(FILE_TREE_TIP_DISMISSED_KEY) !== 'true',
+ };
+ },
+ computed: {
+ showTip() {
+ return this.includes.length === 0 && this.canShowTip;
+ },
+ },
+ methods: {
+ dismissTip() {
+ this.canShowTip = false;
+ localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true');
+ },
+ },
+};
+</script>
+<template>
+ <aside class="file-tree-container gl-mr-5 gl-mb-5">
+ <div
+ v-gl-tooltip
+ :title="ciConfigPath"
+ class="gl-bg-gray-50 gl-py-2 gl-px-3 gl-mb-3 gl-rounded-base"
+ >
+ <span class="file-row-name gl-str-truncated" :title="ciConfigPath">
+ <file-icon class="file-row-icon" :file-name="ciConfigPath" />
+ <span data-testid="current-config-filename">{{ ciConfigPath }}</span>
+ </span>
+ </div>
+ <gl-alert
+ v-if="showTip"
+ variant="tip"
+ :title="$options.i18n.tipTitle"
+ :secondary-button-text="$options.i18n.tipBtn"
+ :secondary-button-link="includesHelpPagePath"
+ @dismiss="dismissTip"
+ >
+ {{ $options.i18n.tipDescription }}
+ </gl-alert>
+ <div class="gl-overflow-y-auto">
+ <file-item v-for="file in includes" :key="file.location" :file="file" />
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue
new file mode 100644
index 00000000000..786d483b5b9
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+
+export default {
+ name: 'PipelineEditorFileItem',
+ components: {
+ FileIcon,
+ GlIcon,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ fileName() {
+ return this.file.location;
+ },
+ filePath() {
+ return this.file.blob || this.file.raw;
+ },
+ },
+};
+</script>
+<template>
+ <gl-link
+ v-gl-tooltip
+ :href="filePath"
+ :title="fileName"
+ target="_blank"
+ class="file-tree-includes-link gl-display-flex gl-justify-content-space-between gl-hover-bg-gray-50 gl-text-body gl-hover-text-gray-900 gl-hover-text-decoration-none gl-py-2 gl-px-3 gl-rounded-base"
+ >
+ <span class="file-row-name gl-str-truncated" :title="fileName">
+ <file-icon class="file-row-icon" :file-name="fileName" />
+ <span>{{ fileName }}</span>
+ </span>
+ <gl-icon class="gl-display-none gl-relative gl-text-gray-500" name="external-link" />
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue
new file mode 100644
index 00000000000..ec6ee52b6b2
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -0,0 +1,70 @@
+<script>
+import PipelineStatus from './pipeline_status.vue';
+import ValidationSegment from './validation_segment.vue';
+
+const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100'];
+
+const pipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-border-b-0!',
+ 'gl-rounded-top-base',
+];
+
+const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base'];
+
+const validationSegmentWithPipelineStatusClasses = [
+ ...baseClasses,
+ 'gl-border-1',
+ 'gl-rounded-bottom-left-base',
+ 'gl-rounded-bottom-right-base',
+];
+
+export default {
+ pipelineStatusClasses,
+ validationSegmentClasses,
+ validationSegmentWithPipelineStatusClasses,
+ components: {
+ PipelineStatus,
+ ValidationSegment,
+ },
+ props: {
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ showPipelineStatus() {
+ return !this.isNewCiConfigFile;
+ },
+ // make sure corners are rounded correctly depending on if
+ // pipeline status is rendered
+ validationStyling() {
+ return this.showPipelineStatus
+ ? this.$options.validationSegmentWithPipelineStatusClasses
+ : this.$options.validationSegmentClasses;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-5">
+ <pipeline-status
+ v-if="showPipelineStatus"
+ :commit-sha="commitSha"
+ :class="$options.pipelineStatusClasses"
+ v-on="$listeners"
+ />
+ <validation-segment :class="validationStyling" :ci-config="ciConfigData" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
new file mode 100644
index 00000000000..feadc60a22a
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -0,0 +1,92 @@
+<script>
+import { __ } from '~/locale';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import { PIPELINE_FAILURE } from '../../constants';
+
+export default {
+ i18n: {
+ linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'),
+ },
+ components: {
+ PipelineMiniGraph,
+ },
+ inject: ['projectFullPath'],
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ apollo: {
+ linkedPipelines: {
+ query: getLinkedPipelinesQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ iid: this.pipeline.iid,
+ };
+ },
+ skip() {
+ return !this.pipeline.iid;
+ },
+ update({ project }) {
+ return project?.pipeline;
+ },
+ error() {
+ this.$emit('showError', {
+ type: PIPELINE_FAILURE,
+ reasons: [this.$options.i18n.linkedPipelinesFetchError],
+ });
+ },
+ },
+ },
+ computed: {
+ downstreamPipelines() {
+ return this.linkedPipelines?.downstream?.nodes || [];
+ },
+ hasPipelineStages() {
+ return this.pipelineStages.length > 0;
+ },
+ pipelinePath() {
+ return this.pipeline.detailedStatus?.detailsPath || '';
+ },
+ pipelineStages() {
+ const stages = this.pipeline.stages?.edges;
+ if (!stages) {
+ return [];
+ }
+
+ return stages.map(({ node }) => {
+ const { name, detailedStatus } = node;
+ return {
+ // TODO: fetch dropdown_path from graphql when available
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585
+ dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
+ name,
+ path: `${this.pipelinePath}#${name}`,
+ status: {
+ details_path: `${this.pipelinePath}#${name}`,
+ has_details: detailedStatus.hasDetails,
+ ...detailedStatus,
+ },
+ title: `${name}: ${detailedStatus.text}`,
+ };
+ });
+ },
+ upstreamPipeline() {
+ return this.linkedPipelines?.upstream;
+ },
+ },
+};
+</script>
+
+<template>
+ <pipeline-mini-graph
+ v-if="hasPipelineStages"
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="pipelineStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
new file mode 100644
index 00000000000..372f04075ab
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
@@ -0,0 +1,188 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { truncateSha } from '~/lib/utils/text_utility';
+import { s__ } from '~/locale';
+import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
+
+const POLL_INTERVAL = 10000;
+export const i18n = {
+ fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'),
+ fetchLoading: s__('Pipeline|Checking pipeline status'),
+ pipelineInfo: s__(
+ `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
+ ),
+ viewBtn: s__('Pipeline|View pipeline'),
+ viewCommit: s__('Pipeline|View commit'),
+};
+
+export default {
+ i18n,
+ components: {
+ CiIcon,
+ GlButton,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ PipelineEditorMiniGraph,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['projectFullPath'],
+ props: {
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ apollo: {
+ pipelineEtag: {
+ query: getPipelineEtag,
+ update(data) {
+ return data.etags?.pipeline;
+ },
+ },
+ pipeline: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ sha: this.commitSha,
+ };
+ },
+ update(data) {
+ const { id, iid, commit = {}, detailedStatus = {}, stages, status } =
+ data.project?.pipeline || {};
+
+ return {
+ id,
+ iid,
+ commit,
+ detailedStatus,
+ stages,
+ status,
+ };
+ },
+ result(res) {
+ if (res.data?.project?.pipeline) {
+ this.hasError = false;
+ }
+ },
+ error() {
+ this.hasError = true;
+ },
+ pollInterval: POLL_INTERVAL,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ };
+ },
+ computed: {
+ commitText() {
+ const shortSha = truncateSha(this.commitSha);
+ const commitTitle = this.pipeline.commit.title || '';
+
+ if (commitTitle.length > 0) {
+ return `${shortSha}: ${commitTitle}`;
+ }
+
+ return shortSha;
+ },
+ hasPipelineData() {
+ return Boolean(this.pipeline?.id);
+ },
+ pipelineId() {
+ return getIdFromGraphQLId(this.pipeline.id);
+ },
+ showLoadingState() {
+ // the query is set to poll regularly, so if there is no pipeline data
+ // (e.g. pipeline is null during fetch when the pipeline hasn't been
+ // triggered yet), we can just show the loading state until the pipeline
+ // details are ready to be fetched
+ return (
+ this.$apollo.queries.pipeline.loading ||
+ this.commitSha.length === 0 ||
+ (!this.hasPipelineData && !this.hasError)
+ );
+ },
+ shortSha() {
+ return truncateSha(this.commitSha);
+ },
+ status() {
+ return this.pipeline.detailedStatus;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL);
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap">
+ <template v-if="showLoadingState">
+ <div>
+ <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
+ <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
+ </div>
+ </template>
+ <template v-else-if="hasError">
+ <gl-icon class="gl-mr-auto" name="warning-solid" />
+ <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
+ </template>
+ <template v-else>
+ <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1">
+ <a :href="status.detailsPath" class="gl-mr-auto">
+ <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" />
+ </a>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="$options.i18n.pipelineInfo">
+ <template #id="{ content }">
+ <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content">
+ {{ content }}{{ pipelineId }}
+ </span>
+ </template>
+ <template #status>{{ status.text }}</template>
+ <template #commit>
+ <gl-link
+ v-gl-tooltip.hover
+ :href="pipeline.commit.webPath"
+ :title="$options.i18n.viewCommit"
+ data-testid="pipeline-commit"
+ >
+ {{ commitText }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ <div class="gl-display-flex gl-flex-wrap">
+ <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" />
+ <gl-button
+ class="gl-ml-3"
+ category="secondary"
+ variant="confirm"
+ :href="status.detailsPath"
+ data-testid="pipeline-view-btn"
+ >
+ {{ $options.i18n.viewBtn }}
+ </gl-button>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
new file mode 100644
index 00000000000..84c0eef441f
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import {
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+} from '../../constants';
+
+export const i18n = {
+ empty: __(
+ "We'll continuously validate your pipeline configuration. The validation results will appear here.",
+ ),
+ learnMore: __('Learn more'),
+ loading: s__('Pipelines|Validating GitLab CI configuration…'),
+ invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
+ invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
+ unavailableValidation: s__('Pipelines|Configuration validation currently not available.'),
+ valid: s__('Pipelines|Pipeline syntax is correct.'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ TooltipOnTruncate,
+ },
+ inject: {
+ lintUnavailableHelpPagePath: {
+ default: '',
+ },
+ ymlHelpPagePath: {
+ default: '',
+ },
+ },
+ props: {
+ ciConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
+ },
+ },
+ computed: {
+ helpPath() {
+ return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath;
+ },
+ isEmpty() {
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isLintUnavailable() {
+ return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
+ },
+ isValid() {
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
+ },
+ icon() {
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return 'check';
+ case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
+ return 'time-out';
+ case EDITOR_APP_STATUS_VALID:
+ return 'check';
+ default:
+ return 'warning-solid';
+ }
+ },
+ message() {
+ const [reason] = this.ciConfig?.errors || [];
+
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return this.$options.i18n.empty;
+ case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
+ return this.$options.i18n.unavailableValidation;
+ case EDITOR_APP_STATUS_VALID:
+ return this.$options.i18n.valid;
+ default:
+ // Only display first error as a reason
+ return this.ciConfig?.errors?.length > 0
+ ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
+ : this.$options.i18n.invalid;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="isLoading">
+ <gl-loading-icon size="sm" inline />
+ {{ $options.i18n.loading }}
+ </template>
+
+ <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full">
+ <tooltip-on-truncate :title="message" class="gl-text-truncate">
+ <gl-icon :name="icon" />
+ <span data-qa-selector="validation_message_content" data-testid="validationMsg">
+ {{ message }}
+ </span>
+ </tooltip-on-truncate>
+ <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
+ <gl-link data-testid="learnMoreLink" :href="helpPath">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue
new file mode 100644
index 00000000000..0f19b9386e6
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue
@@ -0,0 +1,154 @@
+<script>
+import { GlAlert, GlLink, GlSprintf, GlTableLite } from '@gitlab/ui';
+import { __ } from '~/locale';
+import CiLintResultsParam from './ci_lint_results_param.vue';
+import CiLintResultsValue from './ci_lint_results_value.vue';
+import CiLintWarnings from './ci_lint_warnings.vue';
+
+const thBorderColor = 'gl-border-gray-100!';
+
+export default {
+ correct: {
+ variant: 'success',
+ text: __('Syntax is correct.'),
+ },
+ incorrect: {
+ variant: 'danger',
+ text: __('Syntax is incorrect.'),
+ },
+ includesText: __(
+ 'CI configuration validated, including all configuration added with the %{codeStart}include%{codeEnd} keyword. %{link}',
+ ),
+ warningTitle: __('The form contains the following warning:'),
+ fields: [
+ {
+ key: 'parameter',
+ label: __('Parameter'),
+ thClass: thBorderColor,
+ },
+ {
+ key: 'value',
+ label: __('Value'),
+ thClass: thBorderColor,
+ },
+ ],
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ GlTableLite,
+ CiLintWarnings,
+ CiLintResultsValue,
+ CiLintResultsParam,
+ },
+ props: {
+ errors: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ dryRun: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hideAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isValid: {
+ type: Boolean,
+ required: true,
+ },
+ jobs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ lintHelpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ warnings: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ isWarningDismissed: false,
+ };
+ },
+ computed: {
+ status() {
+ return this.isValid ? this.$options.correct : this.$options.incorrect;
+ },
+ shouldShowTable() {
+ return this.errors.length === 0;
+ },
+ shouldShowError() {
+ return this.errors.length > 0;
+ },
+ shouldShowWarning() {
+ return this.warnings.length > 0 && !this.isWarningDismissed;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert
+ v-if="!hideAlert"
+ class="gl-mb-5"
+ :variant="status.variant"
+ :title="__('Status:')"
+ :dismissible="false"
+ data-testid="ci-lint-status"
+ >{{ status.text }}
+ <gl-sprintf :message="$options.includesText">
+ <template #code="{ content }">
+ <code>
+ {{ content }}
+ </code>
+ </template>
+ <template #link>
+ <gl-link :href="lintHelpPagePath" target="_blank">
+ {{ __('More information') }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <pre
+ v-if="shouldShowError"
+ class="gl-mb-5"
+ data-testid="ci-lint-errors"
+ ><div v-for="error in errors" :key="error">{{ error }}</div></pre>
+
+ <ci-lint-warnings
+ v-if="shouldShowWarning"
+ :warnings="warnings"
+ data-testid="ci-lint-warnings"
+ @dismiss="isWarningDismissed = true"
+ />
+
+ <gl-table-lite
+ v-if="shouldShowTable"
+ :items="jobs"
+ :fields="$options.fields"
+ bordered
+ data-testid="ci-lint-table"
+ >
+ <template #cell(parameter)="{ item }">
+ <ci-lint-results-param :stage="item.stage" :job-name="item.name" />
+ </template>
+ <template #cell(value)="{ item }">
+ <ci-lint-results-value :item="item" :dry-run="dryRun" />
+ </template>
+ </gl-table-lite>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue
new file mode 100644
index 00000000000..49225a7cac7
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue
@@ -0,0 +1,26 @@
+<script>
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
+
+export default {
+ props: {
+ stage: {
+ type: String,
+ required: true,
+ },
+ jobName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formatParameter() {
+ return __(`${capitalizeFirstCharacter(this.stage)} Job - ${this.jobName}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <span data-testid="ci-lint-parameter">{{ formatParameter }}</span>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue
new file mode 100644
index 00000000000..ef2be2a5fba
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue
@@ -0,0 +1,89 @@
+<script>
+import { isEmpty } from 'lodash';
+
+export default {
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ dryRun: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ tagList() {
+ return this.item.tags?.join(', ');
+ },
+ onlyPolicy() {
+ return this.item.only ? this.item.only.refs.join(', ') : this.item.only;
+ },
+ exceptPolicy() {
+ return this.item.except ? this.item.except.refs.join(', ') : this.item.except;
+ },
+ scripts() {
+ return {
+ beforeScript: {
+ show: !isEmpty(this.item.beforeScript),
+ content: this.item.beforeScript?.join('\n'),
+ },
+ script: {
+ show: !isEmpty(this.item.script),
+ content: this.item.script?.join('\n'),
+ },
+ afterScript: {
+ show: !isEmpty(this.item.afterScript),
+ content: this.item.afterScript?.join('\n'),
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="ci-lint-value">
+ <pre
+ v-if="scripts.beforeScript.show"
+ class="gl-white-space-pre-wrap"
+ data-testid="ci-lint-before-script"
+ >{{ scripts.beforeScript.content }}</pre
+ >
+ <pre v-if="scripts.script.show" class="gl-white-space-pre-wrap" data-testid="ci-lint-script">{{
+ scripts.script.content
+ }}</pre>
+ <pre
+ v-if="scripts.afterScript.show"
+ class="gl-white-space-pre-wrap"
+ data-testid="ci-lint-after-script"
+ >{{ scripts.afterScript.content }}</pre
+ >
+
+ <ul class="gl-list-style-none gl-pl-0 gl-mb-0">
+ <li v-if="tagList">
+ <b>{{ __('Tag list:') }}</b>
+ {{ tagList }}
+ </li>
+ <div v-if="!dryRun" data-testid="ci-lint-only-except">
+ <li v-if="onlyPolicy">
+ <b>{{ __('Only policy:') }}</b>
+ {{ onlyPolicy }}
+ </li>
+ <li v-if="exceptPolicy">
+ <b>{{ __('Except policy:') }}</b>
+ {{ exceptPolicy }}
+ </li>
+ </div>
+ <li v-if="item.environment">
+ <b>{{ __('Environment:') }}</b>
+ {{ item.environment }}
+ </li>
+ <li v-if="item.when">
+ <b>{{ __('When:') }}</b>
+ {{ item.when }}
+ <b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue
new file mode 100644
index 00000000000..ac0332cb0bd
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
+
+export default {
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ components: {
+ GlAlert,
+ GlSprintf,
+ },
+ props: {
+ warnings: {
+ type: Array,
+ required: true,
+ },
+ maxWarnings: {
+ type: Number,
+ required: false,
+ default: 25,
+ },
+ title: {
+ type: String,
+ required: false,
+ default: __('The form contains the following warning:'),
+ },
+ },
+ computed: {
+ totalWarnings() {
+ return this.warnings.length;
+ },
+ overMaxWarningsLimit() {
+ return this.totalWarnings > this.maxWarnings;
+ },
+ warningsSummary() {
+ return n__('%d warning found:', '%d warnings found:', this.totalWarnings);
+ },
+ summaryMessage() {
+ return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary;
+ },
+ limitWarnings() {
+ return this.warnings.slice(0, this.maxWarnings);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert class="gl-mb-4" :title="title" variant="warning" @dismiss="$emit('dismiss')">
+ <details>
+ <summary>
+ <gl-sprintf :message="summaryMessage">
+ <template #total>
+ {{ totalWarnings }}
+ </template>
+ <template #warningsDisplayed>
+ {{ maxWarnings }}
+ </template>
+ </gl-sprintf>
+ </summary>
+ <p
+ v-for="(warning, index) in limitWarnings"
+ :key="`warning-${index}`"
+ data-testid="ci-lint-warning"
+ >
+ {{ warning }}
+ </p>
+ </details>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
new file mode 100644
index 00000000000..ed5466ff99c
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -0,0 +1,235 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import {
+ CREATE_TAB,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+ EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ MERGED_TAB,
+ TAB_QUERY_PARAM,
+ TABS_INDEX,
+ VALIDATE_TAB,
+ VALIDATE_TAB_BADGE_DISMISSED_KEY,
+ VISUALIZE_TAB,
+} from '../constants';
+import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
+import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
+import CiEditorHeader from './editor/ci_editor_header.vue';
+import CiValidate from './validate/ci_validate.vue';
+import TextEditor from './editor/text_editor.vue';
+import EditorTab from './ui/editor_tab.vue';
+import WalkthroughPopover from './popovers/walkthrough_popover.vue';
+
+export default {
+ i18n: {
+ new: __('NEW'),
+ tabEdit: s__('Pipelines|Edit'),
+ tabGraph: s__('Pipelines|Visualize'),
+ tabLint: s__('Pipelines|Lint'),
+ tabMergedYaml: s__('Pipelines|View merged YAML'),
+ tabValidate: s__('Pipelines|Validate'),
+ empty: {
+ visualization: s__(
+ 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ lint: s__(
+ 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
+ ),
+ merge: s__(
+ 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ },
+ },
+ errorTexts: {
+ loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
+ },
+ query: {
+ TAB_QUERY_PARAM,
+ },
+ tabConstants: {
+ CREATE_TAB,
+ MERGED_TAB,
+ VALIDATE_TAB,
+ VISUALIZE_TAB,
+ },
+ components: {
+ CiConfigMergedPreview,
+ CiEditorHeader,
+ CiValidate,
+ EditorTab,
+ GlAlert,
+ GlLoadingIcon,
+ GlTabs,
+ PipelineGraph,
+ TextEditor,
+ WalkthroughPopover,
+ },
+ props: {
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ currentTab: {
+ type: String,
+ required: true,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
+ showDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
+ },
+ },
+ data() {
+ return {
+ showValidateNewBadge: false,
+ };
+ },
+ computed: {
+ isMergedYamlAvailable() {
+ return this.ciConfigData?.mergedYaml;
+ },
+ isEmpty() {
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isInvalid() {
+ return this.appStatus === EDITOR_APP_STATUS_INVALID;
+ },
+ isLintUnavailable() {
+ return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE;
+ },
+ isValid() {
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
+ },
+ validateTabBadgeTitle() {
+ if (this.showValidateNewBadge) {
+ return this.$options.i18n.new;
+ }
+
+ return '';
+ },
+ },
+ mounted() {
+ this.showValidateNewBadge = !JSON.parse(localStorage.getItem(VALIDATE_TAB_BADGE_DISMISSED_KEY));
+ },
+ created() {
+ const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM);
+ const tabName = Object.keys(TABS_INDEX)[tabQueryParam];
+
+ if (tabName) {
+ this.setDefaultTab(tabName);
+ }
+ },
+ methods: {
+ setCurrentTab(tabName) {
+ if (this.currentTab === VALIDATE_TAB) {
+ localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true');
+ this.showValidateNewBadge = false;
+ }
+
+ this.$emit('set-current-tab', tabName);
+ },
+ setDefaultTab(tabName) {
+ // We associate tab name with the index so that we can use tab name
+ // in other part of the app and load the corresponding tab closer to the
+ // actual component using a hash that binds the name to the indexes.
+ // This also means that if we ever changed tab order, we would justs need to
+ // update `TABS_INDEX` hash instead of all the instances in the app
+ // where we used the individual indexes
+ const newUrl = setUrlParams({ [TAB_QUERY_PARAM]: TABS_INDEX[tabName] });
+
+ this.setCurrentTab(tabName);
+ updateHistory({ url: newUrl, title: document.title, replace: true });
+ },
+ },
+};
+</script>
+<template>
+ <gl-tabs
+ class="file-editor gl-mb-3"
+ data-qa-selector="file_editor_container"
+ :query-param-name="$options.query.TAB_QUERY_PARAM"
+ sync-active-tab-with-query-params
+ >
+ <editor-tab
+ class="gl-mb-3"
+ title-link-class="js-walkthrough-popover-target"
+ :title="$options.i18n.tabEdit"
+ lazy
+ data-testid="editor-tab"
+ @click="setCurrentTab($options.tabConstants.CREATE_TAB)"
+ >
+ <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
+ <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" />
+ <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
+ </editor-tab>
+ <editor-tab
+ class="gl-mb-3"
+ :empty-message="$options.i18n.empty.visualization"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
+ :is-unavailable="isLintUnavailable"
+ :keep-component-mounted="false"
+ :title="$options.i18n.tabGraph"
+ lazy
+ data-testid="visualization-tab"
+ @click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)"
+ >
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <pipeline-graph v-else :pipeline-data="ciConfigData" />
+ </editor-tab>
+ <editor-tab
+ class="gl-mb-3"
+ data-testid="validate-tab"
+ :badge-title="validateTabBadgeTitle"
+ :title="$options.i18n.tabValidate"
+ @click="setCurrentTab($options.tabConstants.VALIDATE_TAB)"
+ >
+ <ci-validate :ci-file-content="ciFileContent" />
+ </editor-tab>
+ <editor-tab
+ class="gl-mb-3"
+ :empty-message="$options.i18n.empty.merge"
+ :keep-component-mounted="false"
+ :is-empty="isEmpty"
+ :is-unavailable="isLintUnavailable"
+ :title="$options.i18n.tabMergedYaml"
+ lazy
+ data-testid="merged-tab"
+ @click="setCurrentTab($options.tabConstants.MERGED_TAB)"
+ >
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <gl-alert v-else-if="!isMergedYamlAvailable" variant="danger" :dismissible="false">
+ {{ $options.errorTexts.loadMergedYaml }}
+ </gl-alert>
+ <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
+ </editor-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue
new file mode 100644
index 00000000000..efa6a54c638
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { FILE_TREE_POPOVER_DISMISSED_KEY } from '../../constants';
+
+export default {
+ name: 'PipelineEditorFileTreePopover',
+ directives: { Outside },
+ i18n: {
+ description: s__(
+ 'pipelineEditorWalkthrough|You can use the file tree to view your pipeline configuration files. %{linkStart}Learn more%{linkEnd}',
+ ),
+ },
+ components: {
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ inject: ['includesHelpPagePath'],
+ data() {
+ return {
+ showPopover: false,
+ };
+ },
+ mounted() {
+ this.showPopover = localStorage.getItem(FILE_TREE_POPOVER_DISMISSED_KEY) !== 'true';
+ },
+ methods: {
+ dismissPermanently() {
+ this.showPopover = false;
+ localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover
+ v-if="showPopover"
+ show
+ show-close-button
+ target="file-tree-toggle"
+ triggers="manual"
+ placement="right"
+ data-qa-selector="file_tree_popover"
+ @close-button-clicked="dismissPermanently"
+ >
+ <div v-outside="dismissPermanently" class="gl-font-base gl-mb-3">
+ <gl-sprintf :message="$options.i18n.description">
+ <template #link="{ content }">
+ <gl-link :href="includesHelpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue
new file mode 100644
index 00000000000..4730a521227
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { VALIDATE_TAB_FEEDBACK_URL } from '../../constants';
+
+export const i18n = {
+ feedbackLink: __('Provide Feedback'),
+ popoverContent: s__(
+ 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}',
+ ),
+ title: s__('PipelineEditor|Validate pipeline under simulated conditions'),
+};
+
+export default {
+ name: 'ValidatePipelinePopover',
+ directives: { Outside },
+ components: {
+ GlLink,
+ GlPopover,
+ GlSprintf,
+ },
+ inject: ['simulatePipelineHelpPagePath'],
+ data() {
+ return {
+ showPopover: false,
+ };
+ },
+ methods: {
+ dismiss() {
+ this.showPopover = false;
+ },
+ },
+ i18n,
+ VALIDATE_TAB_FEEDBACK_URL,
+};
+</script>
+
+<template>
+ <gl-popover
+ :show.sync="showPopover"
+ target="validate-pipeline-help"
+ triggers="hover focus"
+ placement="top"
+ >
+ <p class="gl-my-3 gl-font-weight-bold">{{ $options.i18n.title }}</p>
+ <p>
+ <gl-sprintf :message="$options.i18n.popoverContent">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="simulatePipelineHelpPagePath"
+ data-testid="help-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-right gl-mb-3">
+ <gl-link
+ class="gl-font-sm"
+ target="_blank"
+ :href="$options.VALIDATE_TAB_FEEDBACK_URL"
+ data-testid="feedback-link"
+ >{{ $options.i18n.feedbackLink }}</gl-link
+ >
+ </p>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue
new file mode 100644
index 00000000000..c636d8b8e34
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ directives: { Outside },
+ i18n: {
+ title: s__('pipelineEditorWalkthrough|See how GitLab pipelines work'),
+ description: s__(
+ 'pipelineEditorWalkthrough|This %{codeStart}.gitlab-ci.yml%{codeEnd} file creates a simple test pipeline.',
+ ),
+ instruction: s__(
+ 'pipelineEditorWalkthrough|Use the %{boldStart}commit changes%{boldEnd} button at the bottom of the page to run the pipeline.',
+ ),
+ ctaText: s__("pipelineEditorWalkthrough|Let's do this!"),
+ },
+ components: {
+ GlButton,
+ GlPopover,
+ GlSprintf,
+ },
+ data() {
+ return {
+ show: true,
+ };
+ },
+ computed: {
+ targetElement() {
+ return document.querySelector('.js-walkthrough-popover-target');
+ },
+ },
+ methods: {
+ close() {
+ this.show = false;
+ },
+ handleClickCta() {
+ this.close();
+ this.$emit('walkthrough-popover-cta-clicked');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover
+ :show.sync="show"
+ :title="$options.i18n.title"
+ :target="targetElement"
+ placement="right"
+ triggers="focus"
+ >
+ <div v-outside="close" class="gl-display-flex gl-flex-direction-column">
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.instruction">
+ <template #bold="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-button
+ class="gl-align-self-end"
+ category="tertiary"
+ data-testid="ctaBtn"
+ variant="confirm"
+ @click="handleClickCta"
+ >
+ <gl-emoji data-name="rocket" />
+ {{ $options.i18n.ctaText }}
+ </gl-button>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue
new file mode 100644
index 00000000000..bc076fbe349
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue
@@ -0,0 +1,26 @@
+<script>
+export default {
+ props: {
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ created() {
+ window.addEventListener('beforeunload', this.confirmChanges);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.confirmChanges);
+ },
+ methods: {
+ confirmChanges(e = {}) {
+ if (this.hasUnsavedChanges) {
+ e.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ e.returnValue = ''; // Chrome requires returnValue to be set
+ }
+ },
+ },
+ render: () => null,
+};
+</script>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue
new file mode 100644
index 00000000000..22b82f2e96f
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlAlert, GlBadge, GlTab } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+/**
+ * Wrapper of <gl-tab> to optionally lazily render this tab's content
+ * when its shown **without dismounting after its hidden**.
+ *
+ * Usage:
+ *
+ * API is the same as <gl-tab>, for example:
+ *
+ * <gl-tabs>
+ * <editor-tab title="Tab 1" lazy>
+ * lazily mounted content (gets mounted if this is first tab)
+ * </editor-tab>
+ * <editor-tab title="Tab 2" lazy>
+ * lazily mounted content
+ * </editor-tab>
+ * <editor-tab title="Tab 3">
+ * eagerly mounted content
+ * </editor-tab>
+ * </gl-tabs>
+ *
+ * Once the tab is selected it is permanently set as "not-lazy"
+ * so it's contents are not dismounted.
+ *
+ * lazy is "false" by default, as in <gl-tab>.
+ *
+ * It is also possible to pass the `isEmpty` and or `isInvalid` to let
+ * the tab component handle that state on its own. For example:
+ *
+ * * <gl-tabs>
+ * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid">
+ * ...
+ * </editor-tab-with-status>
+ * Will be the same as normal, except it will only render the slot component
+ * if the status is not empty and not invalid. In any of these 2 cases, it will render
+ * a generic component and avoid mounting whatever it received in the slot.
+ * </gl-tabs>
+ */
+
+export default {
+ i18n: {
+ invalid: __(
+ 'Your CI/CD configuration syntax is invalid. Select the Validate tab for more details.',
+ ),
+ unavailable: __(
+ "We're experiencing difficulties and this tab content is currently unavailable.",
+ ),
+ },
+ components: {
+ GlAlert,
+ GlBadge,
+ GlTab,
+ // Use a small renderless component to know when the tab content mounts because:
+ // - gl-tab always gets mounted, even if lazy is `true`. See:
+ // https://github.com/bootstrap-vue/bootstrap-vue/blob/dev/src/components/tabs/tab.js#L180
+ // - we cannot listen to events on <slot />
+ MountSpy: {
+ render: () => null,
+ },
+ },
+ inheritAttrs: false,
+ props: {
+ badgeTitle: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ badgeVariant: {
+ type: String,
+ required: false,
+ default: 'info',
+ },
+ emptyMessage: {
+ type: String,
+ required: false,
+ default: s__(
+ 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
+ ),
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isInvalid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isUnavailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ keepComponentMounted: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLazy: this.lazy,
+ };
+ },
+ computed: {
+ hasBadgeTitle() {
+ return this.badgeTitle.length > 0;
+ },
+ slots() {
+ // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots
+ return Object.keys(this.$slots);
+ },
+ },
+ methods: {
+ onContentMounted() {
+ // When a child is first mounted make the entire tab
+ // permanently mounted by setting 'lazy' to false unless
+ // explicitly opted out.
+ if (this.keepComponentMounted) {
+ this.isLazy = false;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
+ <template #title>
+ <span>{{ title }}</span>
+ <gl-badge v-if="hasBadgeTitle" class="gl-ml-2" size="sm" :variant="badgeVariant">{{
+ badgeTitle
+ }}</gl-badge>
+ </template>
+ <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
+ <gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false">
+ {{ $options.i18n.unavailable }}</gl-alert
+ >
+ <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert>
+ <template v-else>
+ <slot v-for="slot in slots" :name="slot"></slot>
+ <mount-spy @hook:mounted="onContentMounted" />
+ </template>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
new file mode 100644
index 00000000000..d7b8e7151d9
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlSprintf,
+ PipelineEditorFileNav,
+ },
+ i18n: {
+ title: __('Optimize your workflow with CI/CD Pipelines'),
+ body: __(
+ 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.',
+ ),
+ btnText: __('Configure pipeline'),
+ externalCiNote: __("This project's pipeline configuration is located outside this repository"),
+ externalCiInstructions: __(
+ 'To edit the pipeline configuration, you must go to the project or external site that hosts the file.',
+ ),
+ },
+ inject: {
+ emptyStateIllustrationPath: {
+ default: '',
+ },
+ usesExternalConfig: {
+ default: false,
+ type: Boolean,
+ required: false,
+ },
+ },
+ methods: {
+ createEmptyConfigFile() {
+ this.$emit('createEmptyConfigFile');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <pipeline-editor-file-nav v-on="$listeners" />
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="emptyStateIllustrationPath" />
+ <div
+ v-if="usesExternalConfig"
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center"
+ >
+ <h1 class="gl-font-size-h1">{{ $options.i18n.externalCiNote }}</h1>
+ <p class="gl-mt-3">{{ $options.i18n.externalCiInstructions }}</p>
+ </div>
+ <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-mt-3">
+ <gl-sprintf :message="$options.i18n.body">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-button
+ variant="confirm"
+ class="gl-mt-3"
+ data-qa-selector="create_new_ci_button"
+ @click="createEmptyConfigFile"
+ >
+ {{ $options.i18n.btnText }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue
new file mode 100644
index 00000000000..c72cff4c6f8
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import {
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+ DEFAULT_FAILURE,
+ DEFAULT_SUCCESS,
+ LOAD_FAILURE_UNKNOWN,
+ PIPELINE_FAILURE,
+} from '../../constants';
+import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue';
+import {
+ CODE_SNIPPET_SOURCE_URL_PARAM,
+ CODE_SNIPPET_SOURCES,
+} from '../code_snippet_alert/constants';
+
+export default {
+ components: {
+ GlAlert,
+ CodeSnippetAlert,
+ },
+
+ errors: {
+ [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
+ [DEFAULT_FAILURE]: __('Something went wrong on our end.'),
+ [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
+ [PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'),
+ },
+ success: {
+ [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
+ [COMMIT_SUCCESS_WITH_REDIRECT]: s__(
+ 'Pipelines|Your changes have been successfully committed. Now redirecting to the new merge request page.',
+ ),
+ [DEFAULT_SUCCESS]: __('Your action succeeded.'),
+ },
+ props: {
+ failureType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ failureReasons: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ showFailure: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showSuccess: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ successType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ codeSnippetCopiedFrom: '',
+ };
+ },
+ computed: {
+ failure() {
+ const { errors } = this.$options;
+
+ return {
+ text: errors[this.failureType] ?? errors[DEFAULT_FAILURE],
+ variant: 'danger',
+ };
+ },
+ success() {
+ const { success } = this.$options;
+
+ return {
+ text: success[this.successType] ?? success[DEFAULT_SUCCESS],
+ variant: 'info',
+ };
+ },
+ },
+ created() {
+ this.parseCodeSnippetSourceParam();
+ },
+ methods: {
+ dismissCodeSnippetAlert() {
+ this.codeSnippetCopiedFrom = '';
+ },
+ dismissFailure() {
+ this.$emit('hide-failure');
+ },
+ dismissSuccess() {
+ this.$emit('hide-success');
+ },
+ parseCodeSnippetSourceParam() {
+ const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
+ if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
+ this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
+ window.history.replaceState(
+ {},
+ document.title,
+ removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
+ );
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <code-snippet-alert
+ v-if="codeSnippetCopiedFrom"
+ :source="codeSnippetCopiedFrom"
+ class="gl-mb-5"
+ @dismiss="dismissCodeSnippetAlert"
+ />
+ <gl-alert
+ v-if="showSuccess"
+ :variant="success.variant"
+ class="gl-mb-5"
+ @dismiss="dismissSuccess"
+ >
+ {{ success.text }}
+ </gl-alert>
+ <gl-alert
+ v-if="showFailure"
+ :variant="failure.variant"
+ class="gl-mb-5"
+ @dismiss="dismissFailure"
+ >
+ {{ failure.text }}
+ <ul v-if="failureReasons.length" class="gl-mb-0">
+ <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
+ </ul>
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
new file mode 100644
index 00000000000..83fcab4b343
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
@@ -0,0 +1,301 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ GlTooltip,
+ GlTooltipDirective,
+ GlSprintf,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions } from '../../constants';
+import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
+import CiLintResults from '../lint/ci_lint_results.vue';
+import getBlobContent from '../../graphql/queries/blob_content.query.graphql';
+import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql';
+import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql';
+
+export const i18n = {
+ alertDesc: s__(
+ 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}',
+ ),
+ cancelBtn: __('Cancel'),
+ contentChange: s__(
+ 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.',
+ ),
+ cta: s__('PipelineEditor|Validate pipeline'),
+ ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'),
+ errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'),
+ help: __('Help'),
+ loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'),
+ pipelineSource: s__('PipelineEditor|Pipeline Source'),
+ pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'),
+ pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'),
+ title: s__('PipelineEditor|Validate pipeline under selected conditions'),
+ contentNote: s__(
+ 'PipelineEditor|Current content in the Edit tab will be used for the simulation.',
+ ),
+ simulationNote: s__(
+ 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.',
+ ),
+ successAlertTitle: s__('PipelineEditor|Simulation completed successfully'),
+};
+
+export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT';
+export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS';
+export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING';
+const BASE_CLASSES = [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-align-items-center',
+ 'gl-mt-11',
+];
+
+export default {
+ name: 'CiValidateTab',
+ components: {
+ CiLintResults,
+ GlAlert,
+ GlButton,
+ GlDropdown,
+ GlIcon,
+ GlLoadingIcon,
+ GlLink,
+ GlSprintf,
+ GlTooltip,
+ ValidatePipelinePopover,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'],
+ props: {
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ initialBlobContent: {
+ query: getBlobContent,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ path: this.ciConfigPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ return data?.project?.repository?.blobs?.nodes[0]?.rawBlob;
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches?.current?.name;
+ },
+ },
+ },
+ data() {
+ return {
+ yaml: this.ciFileContent,
+ state: VALIDATE_TAB_INIT,
+ errors: [],
+ hasCiContentChanged: false,
+ isValid: false,
+ jobs: [],
+ warnings: [],
+ };
+ },
+ computed: {
+ canResimulatePipeline() {
+ return this.hasSimulationResults && this.hasCiContentChanged;
+ },
+ isInitialCiContentLoading() {
+ return this.$apollo.queries.initialBlobContent.loading;
+ },
+ isInitState() {
+ return this.state === VALIDATE_TAB_INIT;
+ },
+ isSimulationLoading() {
+ return this.state === VALIDATE_TAB_LOADING;
+ },
+ hasSimulationResults() {
+ return this.state === VALIDATE_TAB_RESULTS;
+ },
+ resultStatus() {
+ return {
+ title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle,
+ variant: this.isValid ? 'success' : 'danger',
+ };
+ },
+ trackingAction() {
+ const { actions } = pipelineEditorTrackingOptions;
+ return this.canResimulatePipeline ? actions.resimulatePipeline : actions.simulatePipeline;
+ },
+ },
+ watch: {
+ ciFileContent(value) {
+ this.yaml = value;
+ this.hasCiContentChanged = true;
+ },
+ },
+ methods: {
+ cancelSimulation() {
+ this.state = VALIDATE_TAB_INIT;
+ },
+ trackSimulation() {
+ const { label } = pipelineEditorTrackingOptions;
+ this.track(this.trackingAction, { label });
+ },
+ async validateYaml() {
+ this.trackSimulation();
+ this.state = VALIDATE_TAB_LOADING;
+
+ try {
+ const {
+ data: {
+ lintCI: { errors, jobs, valid, warnings },
+ },
+ } = await this.$apollo.mutate({
+ mutation: lintCiMutation,
+ variables: {
+ dry: true,
+ content: this.yaml,
+ endpoint: this.ciLintPath,
+ },
+ });
+
+ // only save the result if the user did not cancel the simulation
+ if (this.state === VALIDATE_TAB_LOADING) {
+ this.errors = errors;
+ this.jobs = jobs;
+ this.warnings = warnings;
+ this.isValid = valid;
+ this.state = VALIDATE_TAB_RESULTS;
+ this.hasCiContentChanged = false;
+ }
+ } catch (error) {
+ this.cancelSimulation();
+ }
+ },
+ },
+ i18n,
+ BASE_CLASSES,
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-3">
+ <div>
+ <label>{{ $options.i18n.pipelineSource }}</label>
+ <gl-dropdown
+ v-gl-tooltip.hover
+ class="gl-ml-3"
+ :title="$options.i18n.pipelineSourceTooltip"
+ :text="$options.i18n.pipelineSourceDefault"
+ disabled
+ data-testid="pipeline-source"
+ />
+ <validate-pipeline-popover />
+ <gl-icon
+ id="validate-pipeline-help"
+ name="question-o"
+ class="gl-ml-1 gl-fill-blue-500"
+ category="secondary"
+ variant="confirm"
+ :aria-label="$options.i18n.help"
+ />
+ </div>
+ <div v-if="canResimulatePipeline">
+ <span class="gl-text-gray-400" data-testid="content-status">
+ {{ $options.i18n.contentChange }}
+ </span>
+ <gl-button
+ variant="confirm"
+ class="gl-ml-2 gl-mb-2"
+ data-testid="resimulate-pipeline-button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-if="isInitState" :class="$options.BASE_CLASSES">
+ <img :src="validateTabIllustrationPath" />
+ <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1>
+ <ul>
+ <li class="gl-mb-3">{{ $options.i18n.contentNote }}</li>
+ <li class="gl-mb-3">
+ <gl-sprintf :message="$options.i18n.simulationNote">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ <div ref="simulatePipelineButton">
+ <gl-button
+ ref="simulatePipelineButton"
+ variant="confirm"
+ class="gl-mt-3"
+ :disabled="isInitialCiContentLoading"
+ data-testid="simulate-pipeline-button"
+ data-qa-selector="simulate_pipeline_button"
+ @click="validateYaml"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ <gl-tooltip
+ v-if="isInitialCiContentLoading"
+ :target="() => $refs.simulatePipelineButton"
+ :title="$options.i18n.ctaDisabledTooltip"
+ data-testid="cta-tooltip"
+ />
+ </div>
+ <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES">
+ <gl-loading-icon size="lg" class="gl-m-3" />
+ <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1>
+ <div>
+ <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation">
+ {{ $options.i18n.cancelBtn }}
+ </gl-button>
+ <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button">
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-else-if="hasSimulationResults" class="gl-mt-5">
+ <gl-alert
+ class="gl-mb-5"
+ :dismissible="false"
+ :title="resultStatus.title"
+ :variant="resultStatus.variant"
+ >
+ <gl-sprintf :message="$options.i18n.alertDesc">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #link="{ content }">
+ <gl-link target="_blank" href="#">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <ci-lint-results
+ dry-run
+ hide-alert
+ :is-valid="isValid"
+ :jobs="jobs"
+ :errors="errors"
+ :warnings="warnings"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
new file mode 100644
index 00000000000..dd25c4d433b
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -0,0 +1,127 @@
+import { s__ } from '~/locale';
+
+// Values for CI_CONFIG_STATUS_* comes from lint graphQL
+export const CI_CONFIG_STATUS_INVALID = 'INVALID';
+export const CI_CONFIG_STATUS_VALID = 'VALID';
+
+// Values for EDITOR_APP_STATUS_* are frontend specifics and
+// represent the global state of the pipeline editor app.
+export const EDITOR_APP_STATUS_EMPTY = 'EMPTY';
+export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID;
+export const EDITOR_APP_STATUS_LINT_UNAVAILABLE = 'LINT_DOWN';
+export const EDITOR_APP_STATUS_LOADING = 'LOADING';
+export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID;
+
+export const EDITOR_APP_VALID_STATUSES = [
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+];
+
+export const COMMIT_FAILURE = 'COMMIT_FAILURE';
+export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
+export const COMMIT_SUCCESS_WITH_REDIRECT = 'COMMIT_SUCCESS_WITH_REDIRECT';
+
+export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
+export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
+export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
+export const PIPELINE_FAILURE = 'PIPELINE_FAILURE';
+
+export const CREATE_TAB = 'CREATE_TAB';
+export const MERGED_TAB = 'MERGED_TAB';
+export const VALIDATE_TAB = 'VALIDATE_TAB';
+export const VISUALIZE_TAB = 'VISUALIZE_TAB';
+
+export const TABS_INDEX = {
+ [CREATE_TAB]: '0',
+ [VISUALIZE_TAB]: '1',
+ [VALIDATE_TAB]: '2',
+ [MERGED_TAB]: '3',
+};
+export const TAB_QUERY_PARAM = 'tab';
+
+export const COMMIT_ACTION_CREATE = 'CREATE';
+export const COMMIT_ACTION_UPDATE = 'UPDATE';
+
+export const BRANCH_PAGINATION_LIMIT = 20;
+export const BRANCH_SEARCH_DEBOUNCE = '500';
+export const SOURCE_EDITOR_DEBOUNCE = 500;
+
+export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display';
+export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed';
+export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed';
+export const VALIDATE_TAB_BADGE_DISMISSED_KEY = 'pipeline_editor_validate_tab_badge_dismissed';
+
+export const STARTER_TEMPLATE_NAME = 'Getting-Started';
+
+export const CI_EXAMPLES_LINK = 'CI_EXAMPLES_LINK';
+export const CI_HELP_LINK = 'CI_HELP_LINK';
+export const CI_NEEDS_LINK = 'CI_NEEDS_LINK';
+export const CI_RUNNERS_LINK = 'CI_RUNNERS_LINK';
+export const CI_YAML_LINK = 'CI_YAML_LINK';
+
+export const pipelineEditorTrackingOptions = {
+ label: 'pipeline_editor',
+ actions: {
+ browseTemplates: 'browse_templates',
+ closeHelpDrawer: 'close_help_drawer',
+ helpDrawerLinks: {
+ [CI_EXAMPLES_LINK]: 'visit_help_drawer_link_ci_examples',
+ [CI_HELP_LINK]: 'visit_help_drawer_link_ci_help',
+ [CI_NEEDS_LINK]: 'visit_help_drawer_link_needs',
+ [CI_RUNNERS_LINK]: 'visit_help_drawer_link_runners',
+ [CI_YAML_LINK]: 'visit_help_drawer_link_yaml',
+ },
+ openHelpDrawer: 'open_help_drawer',
+ resimulatePipeline: 'resimulate_pipeline',
+ simulatePipeline: 'simulate_pipeline',
+ },
+};
+
+export const TEMPLATE_REPOSITORY_URL =
+ 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
+export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687';
+
+export const COMMIT_SHA_POLL_INTERVAL = 1000;
+
+export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
+export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
+export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
+export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
+export const I18N = {
+ title: s__('Pipelines|Get started with GitLab CI/CD'),
+ runners: {
+ title: s__('Pipelines|Runners are available to run your jobs now'),
+ subtitle: s__(
+ 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
+ ),
+ },
+ noRunners: {
+ title: s__('Pipelines|No runners detected'),
+ subtitle: s__(
+ 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
+ ),
+ cta: s__('Pipelines|Install GitLab Runner'),
+ },
+ learnBasics: {
+ title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ gettingStarted: {
+ title: s__('Pipelines|"Hello world" with GitLab CI'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.',
+ ),
+ cta: s__('Pipelines|Try test template'),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Ready to set up CI/CD for your project?'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ },
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
new file mode 100644
index 00000000000..2d42ebb6ac3
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
@@ -0,0 +1,21 @@
+mutation lintCI($endpoint: String, $content: String, $dry: Boolean) {
+ lintCI(endpoint: $endpoint, content: $content, dry_run: $dry) @client {
+ valid
+ errors
+ warnings
+ jobs {
+ afterScript
+ allowFailure
+ beforeScript
+ environment
+ except
+ name
+ only {
+ refs
+ }
+ stage
+ tags
+ when
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql
new file mode 100644
index 00000000000..7487e328668
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateAppStatus($appStatus: String) {
+ updateAppStatus(appStatus: $appStatus) @client
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql
new file mode 100644
index 00000000000..b722c147f5f
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateCurrentBranch($currentBranch: String) {
+ updateCurrentBranch(currentBranch: $currentBranch) @client
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql
new file mode 100644
index 00000000000..9561312f2b6
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateLastCommitBranch($lastCommitBranch: String) {
+ updateLastCommitBranch(lastCommitBranch: $lastCommitBranch) @client
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql
new file mode 100644
index 00000000000..9025f00b343
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updatePipelineEtag($pipelineEtag: String) {
+ updatePipelineEtag(pipelineEtag: $pipelineEtag) @client
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
new file mode 100644
index 00000000000..3495ca51283
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -0,0 +1,29 @@
+mutation commitCIFile(
+ $action: CommitActionMode!
+ $projectPath: ID!
+ $branch: String!
+ $startBranch: String
+ $message: String!
+ $filePath: String!
+ $lastCommitId: String!
+ $content: String
+) {
+ commitCreate(
+ input: {
+ projectPath: $projectPath
+ branch: $branch
+ startBranch: $startBranch
+ message: $message
+ actions: [
+ { action: $action, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
+ ]
+ }
+ ) {
+ commit {
+ id
+ sha
+ }
+ commitPipelinePath
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql
new file mode 100644
index 00000000000..359b4a846c7
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql
@@ -0,0 +1,13 @@
+query getAvailableBranches(
+ $limit: Int!
+ $offset: Int!
+ $projectFullPath: ID!
+ $searchPattern: String!
+) {
+ project(fullPath: $projectFullPath) {
+ id
+ repository {
+ branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern)
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql
new file mode 100644
index 00000000000..5928d90f7c4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql
@@ -0,0 +1,13 @@
+query getBlobContent($projectPath: ID!, $path: String!, $ref: String) {
+ project(fullPath: $projectPath) {
+ id
+ repository {
+ blobs(paths: [$path], ref: $ref) {
+ nodes {
+ id
+ rawBlob
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql
new file mode 100644
index 00000000000..5354ed7c2d5
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql
@@ -0,0 +1,18 @@
+#import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql"
+
+query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) {
+ ciConfig(projectPath: $projectPath, sha: $sha, content: $content) {
+ errors
+ includes {
+ location
+ type
+ blob
+ raw
+ }
+ mergedYaml
+ status
+ stages {
+ ...PipelineStagesConnection
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql
new file mode 100644
index 00000000000..0df8cafa3cb
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql
@@ -0,0 +1,5 @@
+query getAppStatus {
+ app @client {
+ status
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql
new file mode 100644
index 00000000000..1f4f9d26f24
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql
@@ -0,0 +1,7 @@
+query getCurrentBranch {
+ workBranches @client {
+ current {
+ name
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql
new file mode 100644
index 00000000000..a83129759de
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql
@@ -0,0 +1,7 @@
+query getLastCommitBranchQuery {
+ workBranches @client {
+ lastCommit {
+ name
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql
new file mode 100644
index 00000000000..8df6e74a5d9
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql
@@ -0,0 +1,5 @@
+query getPipelineEtag {
+ etags @client {
+ pipeline
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql
new file mode 100644
index 00000000000..a34c8f365f4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql
@@ -0,0 +1,8 @@
+query getTemplate($projectPath: ID!, $templateName: String!) {
+ project(fullPath: $projectPath) {
+ id
+ ciTemplate(name: $templateName) {
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
new file mode 100644
index 00000000000..d62fda40237
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
@@ -0,0 +1,13 @@
+query getLatestCommitSha($projectPath: ID!, $ref: String) {
+ project(fullPath: $projectPath) {
+ id
+ repository {
+ tree(ref: $ref) {
+ lastCommit {
+ id
+ sha
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql
new file mode 100644
index 00000000000..021b858d72e
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql
@@ -0,0 +1,41 @@
+query getPipeline($fullPath: ID!, $sha: String!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(sha: $sha) {
+ id
+ iid
+ status
+ commit {
+ id
+ title
+ webPath
+ }
+ detailedStatus {
+ id
+ detailsPath
+ icon
+ group
+ text
+ }
+ stages {
+ edges {
+ node {
+ id
+ name
+ status
+ detailedStatus {
+ detailsPath
+ group
+ hasDetails
+ icon
+ id
+ label
+ text
+ tooltip
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js
new file mode 100644
index 00000000000..fa1c70c1994
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js
@@ -0,0 +1,86 @@
+import axios from '~/lib/utils/axios_utils';
+import getAppStatus from './queries/client/app_status.query.graphql';
+import getCurrentBranch from './queries/client/current_branch.query.graphql';
+import getLastCommitBranch from './queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './queries/client/pipeline_etag.query.graphql';
+
+export const resolvers = {
+ Mutation: {
+ lintCI: (_, { endpoint, content, dry_run }) => {
+ return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
+ valid: data.valid,
+ errors: data.errors,
+ warnings: data.warnings,
+ jobs: data.jobs.map((job) => {
+ const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null;
+
+ return {
+ name: job.name,
+ stage: job.stage,
+ beforeScript: job.before_script,
+ script: job.script,
+ afterScript: job.after_script,
+ tags: job.tag_list,
+ environment: job.environment,
+ when: job.when,
+ allowFailure: job.allow_failure,
+ only,
+ except: job.except,
+ __typename: 'CiLintJob',
+ };
+ }),
+ __typename: 'CiLintContent',
+ }));
+ },
+ updateAppStatus: (_, { appStatus }, { cache }) => {
+ cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: appStatus,
+ },
+ },
+ });
+ },
+ updateCurrentBranch: (_, { currentBranch }, { cache }) => {
+ cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: currentBranch,
+ },
+ },
+ },
+ });
+ },
+ updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => {
+ cache.writeQuery({
+ query: getLastCommitBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: lastCommitBranch,
+ },
+ },
+ },
+ });
+ },
+ updatePipelineEtag: (_, { pipelineEtag }, { cache }) => {
+ cache.writeQuery({
+ query: getPipelineEtag,
+ data: {
+ etags: {
+ __typename: 'EtagValues',
+ pipeline: pipelineEtag,
+ },
+ },
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql
new file mode 100644
index 00000000000..508ff22c46e
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql
@@ -0,0 +1,23 @@
+type PipelineEditorApp {
+ status: String!
+}
+
+type BranchList {
+ current: WorkBranch!
+ lastCommit: WorkBranch!
+}
+
+type EtagValues {
+ pipeline: String!
+}
+
+type WorkBranch {
+ name: String!
+ commit: String
+}
+
+extend type Query {
+ app: PipelineEditorApp
+ etags: EtagValues
+ workBranches: BranchList
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
new file mode 100644
index 00000000000..6d91c339833
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -0,0 +1,146 @@
+import Vue from 'vue';
+
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { EDITOR_APP_STATUS_LOADING } from './constants';
+import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
+import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
+import getAppStatus from './graphql/queries/client/app_status.query.graphql';
+import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql';
+import { resolvers } from './graphql/resolvers';
+import typeDefs from './graphql/typedefs.graphql';
+import PipelineEditorApp from './pipeline_editor_app.vue';
+
+export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ // Add to apollo cache as it can be updated by future queries
+ initialBranchName,
+ pipelineEtag,
+ // Add to provide/inject API for static values
+ ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
+ ciLintPath,
+ defaultBranch,
+ emptyStateIllustrationPath,
+ helpPaths,
+ includesHelpPagePath,
+ lintHelpPagePath,
+ lintUnavailableHelpPagePath,
+ needsHelpPagePath,
+ newMergeRequestPath,
+ pipelinePagePath,
+ projectFullPath,
+ projectPath,
+ projectNamespace,
+ simulatePipelineHelpPagePath,
+ totalBranches,
+ usesExternalConfig,
+ validateTabIllustrationPath,
+ ymlHelpPagePath,
+ } = el.dataset;
+
+ const configurationPaths = Object.fromEntries(
+ Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
+ source,
+ el.dataset[datasetKey],
+ ]),
+ );
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers, {
+ typeDefs,
+ useGet: true,
+ }),
+ });
+ const { cache } = apolloProvider.clients.defaultClient;
+
+ cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: EDITOR_APP_STATUS_LOADING,
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: initialBranchName || defaultBranch,
+ },
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getLastCommitBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: '',
+ },
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getPipelineEtag,
+ data: {
+ etags: {
+ __typename: 'EtagValues',
+ pipeline: pipelineEtag,
+ },
+ },
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
+ ciLintPath,
+ configurationPaths,
+ dataMethod: 'graphql',
+ defaultBranch,
+ emptyStateIllustrationPath,
+ helpPaths,
+ includesHelpPagePath,
+ lintHelpPagePath,
+ lintUnavailableHelpPagePath,
+ needsHelpPagePath,
+ newMergeRequestPath,
+ pipelinePagePath,
+ projectFullPath,
+ projectPath,
+ projectNamespace,
+ simulatePipelineHelpPagePath,
+ totalBranches: parseInt(totalBranches, 10),
+ usesExternalConfig: parseBoolean(usesExternalConfig),
+ validateTabIllustrationPath,
+ ymlHelpPagePath,
+ },
+ render(h) {
+ return h(PipelineEditorApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
new file mode 100644
index 00000000000..ff848a973e3
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -0,0 +1,440 @@
+<script>
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { fetchPolicies } from '~/lib/graphql';
+import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+
+import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+
+import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
+import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
+import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
+import {
+ COMMIT_SHA_POLL_INTERVAL,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ EDITOR_APP_VALID_STATUSES,
+ LOAD_FAILURE_UNKNOWN,
+ STARTER_TEMPLATE_NAME,
+} from './constants';
+import updateAppStatus from './graphql/mutations/client/update_app_status.mutation.graphql';
+import getBlobContent from './graphql/queries/blob_content.query.graphql';
+import getCiConfigData from './graphql/queries/ci_config.query.graphql';
+import getAppStatus from './graphql/queries/client/app_status.query.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
+import getTemplate from './graphql/queries/get_starter_template.query.graphql';
+import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
+import PipelineEditorHome from './pipeline_editor_home.vue';
+
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
+export default {
+ components: {
+ ConfirmUnsavedChangesDialog,
+ GlLoadingIcon,
+ GlModal,
+ PipelineEditorEmptyState,
+ PipelineEditorHome,
+ PipelineEditorMessages,
+ },
+ inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath', 'usesExternalConfig'],
+ data() {
+ return {
+ ciConfigData: {},
+ currentCiFileContent: '',
+ failureType: null,
+ failureReasons: [],
+ hasBranchLoaded: false,
+ initialCiFileContent: '',
+ isFetchingCommitSha: false,
+ isLintUnavailable: false,
+ isNewCiConfigFile: false,
+ lastCommittedContent: '',
+ shouldSkipStartScreen: false,
+ showFailure: false,
+ showResetConfirmationModal: false,
+ showStartScreen: false,
+ showSuccess: false,
+ starterTemplate: '',
+ starterTemplateName: STARTER_TEMPLATE_NAME,
+ successType: null,
+ };
+ },
+ apollo: {
+ initialCiFileContent: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: getBlobContent,
+ // If it's a brand new file, we don't want to fetch the content.
+ // Then when the user commits the first time, the query would run
+ // to get the initial file content, but we already have it in `lastCommitedContent`
+ // so we skip the loading altogether. We also wait for the currentBranch
+ // to have been fetched
+ skip() {
+ return this.shouldSkipBlobContentQuery;
+ },
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ path: this.ciConfigPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ return data?.project?.repository?.blobs?.nodes[0]?.rawBlob;
+ },
+ result({ data }) {
+ const nodes = data?.project?.repository?.blobs?.nodes;
+ if (!nodes) {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ } else {
+ const rawBlob = nodes[0]?.rawBlob;
+ const fileContent = rawBlob ?? '';
+
+ this.lastCommittedContent = fileContent;
+ this.currentCiFileContent = fileContent;
+
+ // If rawBlob is defined and returns a string, it means that there is
+ // a CI config file with empty content. If `rawBlob` is not defined
+ // at all, it means there was no file found.
+ const hasCIFile = rawBlob === '' || fileContent.length > 0;
+
+ if (!fileContent.length) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
+ }
+
+ this.isNewCiConfigFile = false;
+ if (!hasCIFile) {
+ if (this.shouldSkipStartScreen) {
+ this.setNewEmptyCiConfigFile();
+ } else {
+ this.showStartScreen = true;
+ }
+ } else if (fileContent.length) {
+ // If the file content is > 0, then we make sure to reset the
+ // start screen flag during a refetch
+ // e.g. when switching branches
+ this.showStartScreen = false;
+ }
+ }
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ },
+ watchLoading(isLoading) {
+ if (isLoading) {
+ this.setAppStatus(EDITOR_APP_STATUS_LOADING);
+ }
+ },
+ },
+ ciConfigData: {
+ query: getCiConfigData,
+ skip() {
+ return this.shouldSkipCiConfigQuery;
+ },
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ sha: this.commitSha,
+ content: this.currentCiFileContent,
+ };
+ },
+ update(data) {
+ const { ciConfig } = data || {};
+ const stageNodes = ciConfig?.stages?.nodes || [];
+ const stages = unwrapStagesWithNeeds(JSON.parse(JSON.stringify(stageNodes)));
+
+ return { ...ciConfig, stages };
+ },
+ result({ data }) {
+ if (data?.ciConfig?.status) {
+ this.setAppStatus(data.ciConfig.status);
+ if (this.isLintUnavailable) {
+ this.isLintUnavailable = false;
+ }
+ }
+ },
+ error() {
+ // We are not using `reportFailure` here because we don't
+ // need to bring attention to the linter being down. We let
+ // the user work on their file and if they look at their
+ // lint status, they will notice that the service is down
+ this.isLintUnavailable = true;
+ },
+ watchLoading(isLoading) {
+ if (isLoading) {
+ this.setAppStatus(EDITOR_APP_STATUS_LOADING);
+ }
+ },
+ },
+ appStatus: {
+ query: getAppStatus,
+ update(data) {
+ return data.app.status;
+ },
+ },
+ commitSha: {
+ query: getLatestCommitShaQuery,
+ skip({ currentBranch }) {
+ return !currentBranch;
+ },
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ const latestCommitSha = data?.project?.repository?.tree?.lastCommit?.sha;
+
+ if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) {
+ this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL);
+ return this.commitSha;
+ }
+
+ this.isFetchingCommitSha = false;
+ this.$apollo.queries.commitSha.stopPolling();
+ return latestCommitSha;
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ update(data) {
+ return data.workBranches?.current?.name;
+ },
+ },
+ starterTemplate: {
+ query: getTemplate,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ templateName: this.starterTemplateName,
+ };
+ },
+ skip({ isNewCiConfigFile }) {
+ return !isNewCiConfigFile;
+ },
+ update(data) {
+ return data.project?.ciTemplate?.content || '';
+ },
+ result({ data }) {
+ this.updateCiConfig(data?.project?.ciTemplate?.content || '');
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ },
+ },
+ },
+ computed: {
+ hasUnsavedChanges() {
+ return this.lastCommittedContent !== this.currentCiFileContent;
+ },
+ isBlobContentLoading() {
+ return !this.hasBranchLoaded || this.$apollo.queries.initialCiFileContent.loading;
+ },
+ isCiConfigDataLoading() {
+ return this.$apollo.queries.ciConfigData.loading;
+ },
+ isEmpty() {
+ return this.currentCiFileContent === '';
+ },
+ shouldSkipBlobContentQuery() {
+ return this.isNewCiConfigFile || this.lastCommittedContent || !this.hasBranchLoaded;
+ },
+ shouldSkipCiConfigQuery() {
+ return !this.currentCiFileContent || !this.commitSha;
+ },
+ },
+ i18n: {
+ resetModal: {
+ actionPrimary: {
+ text: __('Reset file'),
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ body: s__(
+ 'Pipeline Editor|Are you sure you want to reset the file to its last committed version?',
+ ),
+ title: __('Discard changes'),
+ },
+ },
+ watch: {
+ currentBranch: {
+ immediate: true,
+ handler(branch) {
+ // currentBranch is a client query so it starts off undefined. In the index.js,
+ // write to the apollo cache. Once that operation is done, we can safely do operations
+ // that require the branch to have loaded.
+ if (branch) {
+ this.hasBranchLoaded = true;
+ }
+ },
+ },
+ isEmpty(flag) {
+ if (flag) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
+ }
+ },
+ isLintUnavailable(flag) {
+ if (flag) {
+ // We cannot set this status directly in the `error`
+ // hook otherwise we get an infinite loop caused by apollo.
+ this.setAppStatus(EDITOR_APP_STATUS_LINT_UNAVAILABLE);
+ }
+ },
+ },
+ mounted() {
+ this.loadTemplateFromURL();
+ this.checkShouldSkipStartScreen();
+ },
+ methods: {
+ checkShouldSkipStartScreen() {
+ const params = queryToObject(window.location.search);
+ this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
+ },
+ confirmReset() {
+ if (this.hasUnsavedChanges) {
+ this.showResetConfirmationModal = true;
+ }
+ },
+ hideFailure() {
+ this.showFailure = false;
+ },
+ hideSuccess() {
+ this.showSuccess = false;
+ },
+ loadTemplateFromURL() {
+ const templateName = queryToObject(window.location.search)?.template;
+
+ if (templateName) {
+ this.starterTemplateName = templateName;
+ this.setNewEmptyCiConfigFile();
+ }
+ },
+ redirectToNewMergeRequest(sourceBranch, targetBranch) {
+ const url = mergeUrlParams(
+ {
+ [MR_SOURCE_BRANCH]: sourceBranch,
+ [MR_TARGET_BRANCH]: targetBranch,
+ },
+ this.newMergeRequestPath,
+ );
+ redirectTo(url);
+ },
+ async refetchContent() {
+ this.$apollo.queries.initialCiFileContent.skip = false;
+ await this.$apollo.queries.initialCiFileContent.refetch();
+ },
+ reportFailure(type, reasons = []) {
+ this.showFailure = true;
+ this.failureType = type;
+ this.failureReasons = reasons;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ },
+ reportSuccess(type) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ this.showSuccess = true;
+ this.successType = type;
+ },
+ resetContent() {
+ this.showResetConfirmationModal = false;
+ this.currentCiFileContent = this.lastCommittedContent;
+ },
+ setAppStatus(appStatus) {
+ if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) {
+ this.$apollo.mutate({
+ mutation: updateAppStatus,
+ variables: { appStatus },
+ });
+ }
+ },
+ setNewEmptyCiConfigFile() {
+ this.isNewCiConfigFile = true;
+ this.showStartScreen = false;
+ },
+ showErrorAlert({ type, reasons = [] }) {
+ this.reportFailure(type, reasons);
+ },
+ updateCiConfig(ciFileContent) {
+ this.currentCiFileContent = ciFileContent;
+ },
+ updateCommitSha() {
+ this.isFetchingCommitSha = true;
+ this.$apollo.queries.commitSha.refetch();
+ },
+ async updateOnCommit({ type, params = {} }) {
+ this.reportSuccess(type);
+
+ if (this.isNewCiConfigFile) {
+ this.isNewCiConfigFile = false;
+ }
+
+ // Keep track of the latest committed content to know
+ // if the user has made changes to the file that are unsaved.
+ this.lastCommittedContent = this.currentCiFileContent;
+
+ if (type === COMMIT_SUCCESS_WITH_REDIRECT) {
+ const { sourceBranch, targetBranch } = params;
+ // This force update does 2 things for us:
+ // 1. It make sure `hasUnsavedChanges` is updated so
+ // we don't show a modal when the user creates an MR
+ // 2. Ensure the commit success banner is visible.
+ await this.$forceUpdate();
+ this.redirectToNewMergeRequest(sourceBranch, targetBranch);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app">
+ <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
+ <pipeline-editor-empty-state
+ v-else-if="showStartScreen || usesExternalConfig"
+ @createEmptyConfigFile="setNewEmptyCiConfigFile"
+ @refetchContent="refetchContent"
+ />
+ <div v-else>
+ <pipeline-editor-messages
+ :failure-type="failureType"
+ :failure-reasons="failureReasons"
+ :show-failure="showFailure"
+ :show-success="showSuccess"
+ :success-type="successType"
+ @hide-success="hideSuccess"
+ @hide-failure="hideFailure"
+ />
+ <pipeline-editor-home
+ :ci-config-data="ciConfigData"
+ :ci-file-content="currentCiFileContent"
+ :commit-sha="commitSha"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ @commit="updateOnCommit"
+ @resetContent="confirmReset"
+ @showError="showErrorAlert"
+ @refetchContent="refetchContent"
+ @updateCiConfig="updateCiConfig"
+ @updateCommitSha="updateCommitSha"
+ />
+ <gl-modal
+ v-model="showResetConfirmationModal"
+ modal-id="reset-content"
+ :title="$options.i18n.resetModal.title"
+ :action-cancel="$options.i18n.resetModal.actionCancel"
+ :action-primary="$options.i18n.resetModal.actionPrimary"
+ @primary="resetContent"
+ >
+ {{ $options.i18n.resetModal.body }}
+ </gl-modal>
+ <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
new file mode 100644
index 00000000000..1972125ed56
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -0,0 +1,181 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+import CommitSection from './components/commit/commit_section.vue';
+import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
+import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
+import PipelineEditorFileTree from './components/file_tree/container.vue';
+import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
+import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
+import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants';
+
+export default {
+ commitSectionRef: 'commitSectionRef',
+ modal: {
+ switchBranch: {
+ title: __('You have unsaved changes'),
+ body: __('Uncommitted changes will be lost if you change branches. Do you want to continue?'),
+ actionPrimary: {
+ text: __('Switch Branches'),
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: { variant: 'default' },
+ },
+ },
+ },
+ components: {
+ CommitSection,
+ GlModal,
+ PipelineEditorDrawer,
+ PipelineEditorFileNav,
+ PipelineEditorFileTree,
+ PipelineEditorHeader,
+ PipelineEditorTabs,
+ },
+ props: {
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isNewCiConfigFile: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentTab: CREATE_TAB,
+ scrollToCommitForm: false,
+ shouldLoadNewBranch: false,
+ showDrawer: false,
+ showFileTree: false,
+ showSwitchBranchModal: false,
+ };
+ },
+ computed: {
+ showCommitForm() {
+ return this.currentTab === CREATE_TAB;
+ },
+ includesFiles() {
+ return this.ciConfigData?.includes || [];
+ },
+ },
+ mounted() {
+ this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false;
+ },
+ methods: {
+ closeBranchModal() {
+ this.showSwitchBranchModal = false;
+ },
+ closeDrawer() {
+ this.showDrawer = false;
+ },
+ handleConfirmSwitchBranch() {
+ this.showSwitchBranchModal = true;
+ },
+ openDrawer() {
+ this.showDrawer = true;
+ },
+ toggleFileTree() {
+ this.showFileTree = !this.showFileTree;
+ localStorage.setItem(FILE_TREE_DISPLAY_KEY, this.showFileTree);
+ },
+ switchBranch() {
+ this.showSwitchBranchModal = false;
+ this.shouldLoadNewBranch = true;
+ },
+ setCurrentTab(tabName) {
+ this.currentTab = tabName;
+ },
+ setScrollToCommitForm(newValue = true) {
+ this.scrollToCommitForm = newValue;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-transition-medium gl-w-full">
+ <gl-modal
+ v-if="showSwitchBranchModal"
+ visible
+ modal-id="switchBranchModal"
+ :title="$options.modal.switchBranch.title"
+ :action-primary="$options.modal.switchBranch.actionPrimary"
+ :action-secondary="$options.modal.switchBranch.actionSecondary"
+ @primary="switchBranch"
+ @secondary="closeBranchModal"
+ @cancel="closeBranchModal"
+ @hide="closeBranchModal"
+ >
+ {{ $options.modal.switchBranch.body }}
+ </gl-modal>
+ <pipeline-editor-file-nav
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ :should-load-new-branch="shouldLoadNewBranch"
+ @select-branch="handleConfirmSwitchBranch"
+ @toggle-file-tree="toggleFileTree"
+ v-on="$listeners"
+ />
+ <div class="gl-display-flex gl-w-full gl-sm-flex-direction-column">
+ <pipeline-editor-file-tree
+ v-if="showFileTree"
+ class="gl-flex-shrink-0"
+ :includes="includesFiles"
+ />
+ <div class="gl-flex-grow-1 gl-min-w-0">
+ <pipeline-editor-header
+ :ci-config-data="ciConfigData"
+ :commit-sha="commitSha"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ v-on="$listeners"
+ />
+ <pipeline-editor-tabs
+ :ci-config-data="ciConfigData"
+ :ci-file-content="ciFileContent"
+ :commit-sha="commitSha"
+ :current-tab="currentTab"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ :show-drawer="showDrawer"
+ v-on="$listeners"
+ @open-drawer="openDrawer"
+ @close-drawer="closeDrawer"
+ @set-current-tab="setCurrentTab"
+ @walkthrough-popover-cta-clicked="setScrollToCommitForm"
+ />
+ </div>
+ </div>
+ <commit-section
+ v-show="showCommitForm"
+ :ref="$options.commitSectionRef"
+ :ci-file-content="ciFileContent"
+ :commit-sha="commitSha"
+ :has-unsaved-changes="hasUnsavedChanges"
+ :is-new-ci-config-file="isNewCiConfigFile"
+ :scroll-to-commit-form="scrollToCommitForm"
+ @scrolled-to-commit-form="setScrollToCommitForm(false)"
+ v-on="$listeners"
+ />
+ <pipeline-editor-drawer
+ :is-visible="showDrawer"
+ v-on="$listeners"
+ @close-drawer="closeDrawer"
+ />
+ </div>
+</template>