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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-12-20 17:22:11 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-12-20 17:22:11 +0300
commit0c872e02b2c822e3397515ec324051ff540f0cd5 (patch)
treece2fb6ce7030e4dad0f4118d21ab6453e5938cdd /app/assets/javascripts/ci
parentf7e05a6853b12f02911494c4b3fe53d9540d74fc (diff)
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/ci')
-rw-r--r--app/assets/javascripts/ci/ci_lint/components/ci_lint.vue131
-rw-r--r--app/assets/javascripts/ci/ci_lint/index.js31
-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
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue311
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/constants.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js24
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue76
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/constants.js53
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/actions.js30
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/getters.js63
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/index.js17
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/mutations.js27
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/state.js16
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js29
-rw-r--r--app/assets/javascripts/ci/reports/components/grouped_issues_list.vue106
-rw-r--r--app/assets/javascripts/ci/reports/components/issue_body.js17
-rw-r--r--app/assets/javascripts/ci/reports/components/issue_status_icon.vue54
-rw-r--r--app/assets/javascripts/ci/reports/components/issues_list.vue119
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue67
-rw-r--r--app/assets/javascripts/ci/reports/components/report_link.vue30
-rw-r--r--app/assets/javascripts/ci/reports/components/report_section.vue237
-rw-r--r--app/assets/javascripts/ci/reports/components/summary_row.vue93
-rw-r--r--app/assets/javascripts/ci/reports/constants.js38
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue53
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/index.js2
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue17
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue (renamed from app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue)15
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_detail.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_groups.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue55
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list.vue11
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_type_tabs.vue9
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_count.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_stats.vue41
-rw-r--r--app/assets/javascripts/ci/runner/constants.js11
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue3
-rw-r--r--app/assets/javascripts/ci/runner/runner_search_utils.js1
99 files changed, 6218 insertions, 63 deletions
diff --git a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue
new file mode 100644
index 00000000000..49a314e067c
--- /dev/null
+++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
+import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue';
+import lintCiMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlIcon,
+ GlLink,
+ GlAlert,
+ CiLintResults,
+ SourceEditor,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ lintHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ pipelineSimulationHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ content: '',
+ loading: false,
+ isValid: false,
+ errors: null,
+ warnings: null,
+ jobs: [],
+ dryRun: false,
+ showingResults: false,
+ apiError: null,
+ isErrorDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowError() {
+ return this.apiError && !this.isErrorDismissed;
+ },
+ },
+ methods: {
+ async lint() {
+ this.loading = true;
+ try {
+ const {
+ data: {
+ lintCI: { valid, errors, warnings, jobs },
+ },
+ } = await this.$apollo.mutate({
+ mutation: lintCiMutation,
+ variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun },
+ });
+
+ this.showingResults = true;
+ this.isValid = valid;
+ this.errors = errors;
+ this.warnings = warnings;
+ this.jobs = jobs;
+ } catch (error) {
+ this.apiError = error;
+ this.isErrorDismissed = false;
+ } finally {
+ this.loading = false;
+ }
+ },
+ clear() {
+ this.content = '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row">
+ <div class="col-sm-12">
+ <gl-alert
+ v-if="shouldShowError"
+ class="gl-mb-3"
+ variant="danger"
+ @dismiss="isErrorDismissed = true"
+ >{{ apiError }}</gl-alert
+ >
+ <div class="file-holder gl-mb-3">
+ <div class="js-file-title file-title clearfix">
+ {{ __('Contents of .gitlab-ci.yml') }}
+ </div>
+ <source-editor v-model="content" file-name="*.yml" />
+ </div>
+ </div>
+
+ <div class="col-sm-12 gl-display-flex gl-justify-content-space-between">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button
+ class="gl-mr-4"
+ :loading="loading"
+ category="primary"
+ variant="confirm"
+ data-testid="ci-lint-validate"
+ @click="lint"
+ >{{ __('Validate') }}</gl-button
+ >
+ <gl-form-checkbox v-model="dryRun"
+ >{{ __('Simulate a pipeline created for the default branch') }}
+ <gl-link :href="pipelineSimulationHelpPagePath" target="_blank"
+ ><gl-icon class="gl-text-blue-600" name="question-o" /></gl-link
+ ></gl-form-checkbox>
+ </div>
+ <gl-button data-testid="ci-lint-clear" @click="clear">{{ __('Clear') }}</gl-button>
+ </div>
+
+ <ci-lint-results
+ v-if="showingResults"
+ class="col-sm-12 gl-mt-5"
+ :is-valid="isValid"
+ :jobs="jobs"
+ :errors="errors"
+ :warnings="warnings"
+ :dry-run="dryRun"
+ :lint-help-page-path="lintHelpPagePath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/ci_lint/index.js b/app/assets/javascripts/ci/ci_lint/index.js
new file mode 100644
index 00000000000..382059eb17e
--- /dev/null
+++ b/app/assets/javascripts/ci/ci_lint/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
+
+import CiLint from './components/ci_lint.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers),
+});
+
+export default (containerId = '#js-ci-lint') => {
+ const containerEl = document.querySelector(containerId);
+ const { endpoint, lintHelpPagePath, pipelineSimulationHelpPagePath } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ render(createElement) {
+ return createElement(CiLint, {
+ props: {
+ endpoint,
+ lintHelpPagePath,
+ pipelineSimulationHelpPagePath,
+ },
+ });
+ },
+ });
+};
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>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
index 6e24ac6b8d4..a4ef7827f73 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue
@@ -1,18 +1,321 @@
<script>
-import { GlForm } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import Vue from 'vue';
+import { __, s__ } from '~/locale';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
+import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue';
+import { VARIABLE_TYPE, FILE_TYPE } from '../constants';
export default {
components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlForm,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+ RefSelector,
+ TimezoneDropdown,
+ IntervalPatternInput,
},
- inject: {
- fullPath: {
+ inject: [
+ 'fullPath',
+ 'projectId',
+ 'defaultBranch',
+ 'cron',
+ 'cronTimezone',
+ 'dailyLimit',
+ 'settingsLink',
+ ],
+ props: {
+ timezoneData: {
+ type: Array,
+ required: true,
+ },
+ refParam: {
+ type: String,
+ required: false,
default: '',
},
},
+ data() {
+ return {
+ refValue: {
+ shortName: this.refParam,
+ // this is needed until we add support for ref type in url query strings
+ // ensure default branch is called with full ref on load
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined,
+ },
+ description: '',
+ scheduleRef: this.defaultBranch,
+ activated: true,
+ timezone: this.cronTimezone,
+ formCiVariables: {},
+ // TODO: Add the GraphQL query to help populate the predefined variables
+ // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131
+ predefinedValueOptions: {},
+ };
+ },
+ i18n: {
+ activated: __('Activated'),
+ cronTimezone: s__('PipelineSchedules|Cron timezone'),
+ description: s__('PipelineSchedules|Description'),
+ shortDescriptionPipeline: s__(
+ 'PipelineSchedules|Provide a short description for this pipeline',
+ ),
+ savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'),
+ cancel: __('Cancel'),
+ targetBranchTag: __('Select target branch or tag'),
+ intervalPattern: s__('PipelineSchedules|Interval Pattern'),
+ variablesDescription: s__(
+ 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ ),
+ removeVariableLabel: s__('CiVariables|Remove variable'),
+ variables: s__('Pipeline|Variables'),
+ },
+ typeOptions: {
+ [VARIABLE_TYPE]: __('Variable'),
+ [FILE_TYPE]: __('File'),
+ },
+ formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
+ computed: {
+ dropdownTranslations() {
+ return {
+ dropdownHeader: this.$options.i18n.targetBranchTag,
+ };
+ },
+ refFullName() {
+ return this.refValue.fullName;
+ },
+ variables() {
+ return this.formCiVariables[this.refFullName]?.variables ?? [];
+ },
+ descriptions() {
+ return this.formCiVariables[this.refFullName]?.descriptions ?? {};
+ },
+ typeOptionsListbox() {
+ return [
+ {
+ text: __('Variable'),
+ value: VARIABLE_TYPE,
+ },
+ {
+ text: __('File'),
+ value: FILE_TYPE,
+ },
+ ];
+ },
+ getEnabledRefTypes() {
+ return [REF_TYPE_BRANCHES, REF_TYPE_TAGS];
+ },
+ },
+ created() {
+ Vue.set(this.formCiVariables, this.refFullName, {
+ variables: [],
+ descriptions: {},
+ });
+
+ this.addEmptyVariable(this.refFullName);
+ },
+ methods: {
+ addEmptyVariable(refValue) {
+ const { variables } = this.formCiVariables[refValue];
+
+ const lastVar = variables[variables.length - 1];
+ if (lastVar?.key === '' && lastVar?.value === '') {
+ return;
+ }
+
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
+ variable_type: VARIABLE_TYPE,
+ key: '',
+ value: '',
+ });
+ },
+ setVariableAttribute(key, attribute, value) {
+ const { variables } = this.formCiVariables[this.refFullName];
+ const variable = variables.find((v) => v.key === key);
+ variable[attribute] = value;
+ },
+ shouldShowValuesDropdown(key) {
+ return this.predefinedValueOptions[key]?.length > 1;
+ },
+ removeVariable(index) {
+ this.variables.splice(index, 1);
+ },
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
+ },
};
</script>
<template>
- <gl-form />
+ <div class="col-lg-8">
+ <gl-form>
+ <!--Description-->
+ <gl-form-group :label="$options.i18n.description" label-for="schedule-description">
+ <gl-form-input
+ id="schedule-description"
+ v-model="description"
+ type="text"
+ :placeholder="$options.i18n.shortDescriptionPipeline"
+ data-testid="schedule-description"
+ />
+ </gl-form-group>
+ <!--Interval Pattern-->
+ <gl-form-group :label="$options.i18n.intervalPattern" label-for="schedule-interval">
+ <interval-pattern-input
+ id="schedule-interval"
+ :initial-cron-interval="cron"
+ :daily-limit="dailyLimit"
+ :send-native-errors="false"
+ />
+ </gl-form-group>
+ <!--Timezone-->
+ <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone">
+ <timezone-dropdown
+ id="schedule-timezone"
+ :value="timezone"
+ :timezone-data="timezoneData"
+ name="schedule-timezone"
+ />
+ </gl-form-group>
+ <!--Branch/Tag Selector-->
+ <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag">
+ <ref-selector
+ id="schedule-target-branch-tag"
+ :enabled-ref-types="getEnabledRefTypes"
+ :project-id="projectId"
+ :value="scheduleRef"
+ :use-symbolic-ref-names="true"
+ :translations="dropdownTranslations"
+ class="gl-w-full"
+ />
+ </gl-form-group>
+ <!--Variable List-->
+ <gl-form-group :label="$options.i18n.variables">
+ <div
+ v-for="(variable, index) in variables"
+ :key="variable.uniqueId"
+ class="gl-mb-3 gl-pb-2"
+ data-testid="ci-variable-row"
+ data-qa-selector="ci_variable_row_container"
+ >
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
+ >
+ <gl-dropdown
+ :text="$options.typeOptions[variable.variable_type]"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-type"
+ >
+ <gl-dropdown-item
+ v-for="type in Object.keys($options.typeOptions)"
+ :key="type"
+ @click="setVariableAttribute(variable.key, 'variable_type', type)"
+ >
+ {{ $options.typeOptions[type] }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-form-input
+ v-model="variable.key"
+ :placeholder="s__('CiVariables|Input variable key')"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-key"
+ data-qa-selector="ci_variable_key_field"
+ @change="addEmptyVariable(refFullName)"
+ />
+ <gl-dropdown
+ v-if="shouldShowValuesDropdown(variable.key)"
+ :text="variable.value"
+ :class="$options.formElementClasses"
+ class="gl-flex-grow-1 gl-mr-0!"
+ data-testid="pipeline-form-ci-variable-value-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="value in predefinedValueOptions[variable.key]"
+ :key="value"
+ data-testid="pipeline-form-ci-variable-value-dropdown-items"
+ @click="setVariableAttribute(variable.key, 'value', value)"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-form-textarea
+ v-else
+ v-model="variable.value"
+ :placeholder="s__('CiVariables|Input variable value')"
+ class="gl-mb-3 gl-h-7!"
+ :style="$options.textAreaStyle"
+ :no-resize="false"
+ data-testid="pipeline-form-ci-variable-value"
+ data-qa-selector="ci_variable_value_field"
+ />
+
+ <template v-if="variables.length > 1">
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-md-ml-3 gl-mb-3"
+ data-testid="remove-ci-variable-row"
+ variant="danger"
+ category="secondary"
+ icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
+ @click="removeVariable(index)"
+ />
+ <gl-button
+ v-else
+ class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden"
+ icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
+ />
+ </template>
+ </div>
+ <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3">
+ {{ descriptions[variable.key] }}
+ </div>
+ </div>
+
+ <template #description
+ ><gl-sprintf :message="$options.i18n.variablesDescription">
+ <template #link="{ content }">
+ <gl-link :href="settingsLink">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf></template
+ >
+ </gl-form-group>
+ <!--Activated-->
+ <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{
+ $options.i18n.activated
+ }}</gl-form-checkbox>
+
+ <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{
+ $options.i18n.savePipelineSchedule
+ }}</gl-button>
+ <gl-button type="reset" data-testid="schedule-cancel-button">{{
+ $options.i18n.cancel
+ }}</gl-button>
+ </gl-form>
+ </div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js
new file mode 100644
index 00000000000..b4ab1143f60
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js
@@ -0,0 +1,2 @@
+export const VARIABLE_TYPE = 'env_var';
+export const FILE_TYPE = 'file';
diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
index d83417ab84a..445161f99cb 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
+++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js
@@ -16,7 +16,16 @@ export default (selector) => {
return false;
}
- const { fullPath } = containerEl.dataset;
+ const {
+ fullPath,
+ cron,
+ dailyLimit,
+ timezoneData,
+ cronTimezone,
+ projectId,
+ defaultBranch,
+ settingsLink,
+ } = containerEl.dataset;
return new Vue({
el: containerEl,
@@ -24,9 +33,20 @@ export default (selector) => {
apolloProvider,
provide: {
fullPath,
+ projectId,
+ defaultBranch,
+ dailyLimit: dailyLimit ?? '',
+ cronTimezone: cronTimezone ?? '',
+ cron: cron ?? '',
+ settingsLink,
},
render(createElement) {
- return createElement(PipelineSchedulesForm);
+ return createElement(PipelineSchedulesForm, {
+ props: {
+ timezoneData: JSON.parse(timezoneData),
+ refParam: defaultBranch,
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue
new file mode 100644
index 00000000000..5a7ee9c9b28
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue
@@ -0,0 +1,76 @@
+<script>
+/**
+ * Renders Code quality body text
+ * Fixed: [name] in [link]:[line]
+ */
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ReportLink from '~/ci/reports/components/report_link.vue';
+import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/ci/reports/constants';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants';
+
+export default {
+ name: 'CodequalityIssueBody',
+ components: {
+ GlIcon,
+ ReportLink,
+ },
+ directives: {
+ tooltip: GlTooltipDirective,
+ },
+ props: {
+ status: {
+ type: String,
+ required: false,
+ default: STATUS_NEUTRAL,
+ },
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ issueName() {
+ return `${this.severityLabel} - ${this.issue.name}`;
+ },
+ issueSeverity() {
+ return this.issue.severity?.toLowerCase();
+ },
+ isStatusSuccess() {
+ return this.status === STATUS_SUCCESS;
+ },
+ severityClass() {
+ return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon() {
+ return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown;
+ },
+ severityLabel() {
+ return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown;
+ },
+ },
+ severityText: {
+ info: s__('severity|Info'),
+ minor: s__('severity|Minor'),
+ major: s__('severity|Major'),
+ critical: s__('severity|Critical'),
+ blocker: s__('severity|Blocker'),
+ unknown: s__('severity|Unknown'),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-mt-2 gl-mb-2 gl-w-full">
+ <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon">
+ <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" />
+ </span>
+ <div class="gl-flex-grow-1">
+ <div>
+ <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong>
+ {{ issueName }}
+ </div>
+
+ <report-link v-if="issue.path" :issue="issue" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js
new file mode 100644
index 00000000000..5e81245037f
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js
@@ -0,0 +1,53 @@
+export const SEVERITY_CLASSES = {
+ info: 'text-primary-400',
+ minor: 'text-warning-200',
+ major: 'text-warning-400',
+ critical: 'text-danger-600',
+ blocker: 'text-danger-800',
+ unknown: 'text-secondary-400',
+};
+
+export const SEVERITY_ICONS = {
+ info: 'severity-info',
+ minor: 'severity-low',
+ major: 'severity-medium',
+ critical: 'severity-high',
+ blocker: 'severity-critical',
+ unknown: 'severity-unknown',
+};
+
+export const SEVERITY_ICONS_MR_WIDGET = {
+ info: 'severityInfo',
+ minor: 'severityLow',
+ major: 'severityMedium',
+ critical: 'severityHigh',
+ blocker: 'severityCritical',
+ unknown: 'severityUnknown',
+};
+
+export const SEVERITIES = {
+ info: {
+ class: SEVERITY_CLASSES.info,
+ name: SEVERITY_ICONS.info,
+ },
+ minor: {
+ class: SEVERITY_CLASSES.minor,
+ name: SEVERITY_ICONS.minor,
+ },
+ major: {
+ class: SEVERITY_CLASSES.major,
+ name: SEVERITY_ICONS.major,
+ },
+ critical: {
+ class: SEVERITY_CLASSES.critical,
+ name: SEVERITY_ICONS.critical,
+ },
+ blocker: {
+ class: SEVERITY_CLASSES.blocker,
+ name: SEVERITY_ICONS.blocker,
+ },
+ unknown: {
+ class: SEVERITY_CLASSES.unknown,
+ name: SEVERITY_ICONS.unknown,
+ },
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js
new file mode 100644
index 00000000000..04aca11b945
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js
@@ -0,0 +1,30 @@
+import pollUntilComplete from '~/lib/utils/poll_until_complete';
+import { STATUS_NOT_FOUND } from '../../constants';
+import * as types from './mutation_types';
+import { parseCodeclimateMetrics } from './utils/codequality_parser';
+
+export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
+
+export const fetchReports = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORTS);
+
+ return pollUntilComplete(state.reportsPath)
+ .then(({ data }) => {
+ if (data.status === STATUS_NOT_FOUND) {
+ return dispatch('receiveReportsError', data);
+ }
+ return dispatch('receiveReportsSuccess', {
+ newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath),
+ resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath),
+ });
+ })
+ .catch((error) => dispatch('receiveReportsError', error));
+};
+
+export const receiveReportsSuccess = ({ commit }, data) => {
+ commit(types.RECEIVE_REPORTS_SUCCESS, data);
+};
+
+export const receiveReportsError = ({ commit }, error) => {
+ commit(types.RECEIVE_REPORTS_ERROR, error);
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js
new file mode 100644
index 00000000000..70d11e96a54
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js
@@ -0,0 +1,63 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+import { sprintf, s__, n__ } from '~/locale';
+import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants';
+
+export const hasCodequalityIssues = (state) =>
+ Boolean(state.newIssues?.length || state.resolvedIssues?.length);
+
+export const codequalityStatus = (state) => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+ if (state.hasError) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const codequalityText = (state) => {
+ const { newIssues, resolvedIssues } = state;
+ let text;
+ if (!newIssues.length && !resolvedIssues.length) {
+ text = s__('ciReport|No changes to code quality');
+ } else if (newIssues.length && resolvedIssues.length) {
+ text = sprintf(
+ s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`),
+ {
+ issueCount: newIssues.length + resolvedIssues.length,
+ },
+ );
+ } else if (resolvedIssues.length) {
+ text = n__(
+ `ciReport|Code quality improved due to 1 resolved issue`,
+ `ciReport|Code quality improved due to %d resolved issues`,
+ resolvedIssues.length,
+ );
+ } else if (newIssues.length) {
+ text = n__(
+ `ciReport|Code quality degraded due to 1 new issue`,
+ `ciReport|Code quality degraded due to %d new issues`,
+ newIssues.length,
+ );
+ }
+
+ return text;
+};
+
+export const codequalityPopover = (state) => {
+ if (state.status === STATUS_NOT_FOUND) {
+ return {
+ title: s__('ciReport|Base pipeline codequality artifact not found'),
+ content: sprintf(
+ s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'),
+ {
+ linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`,
+ linkEndTag: `${spriteIcon('external-link', 's16')}</a>`,
+ },
+ false,
+ ),
+ };
+ }
+ return {};
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
new file mode 100644
index 00000000000..5bfcd69edec
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = (initialState) => ({
+ actions,
+ getters,
+ mutations,
+ state: state(initialState),
+});
+
+export default (initialState) => new Vuex.Store(getStoreConfig(initialState));
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js
new file mode 100644
index 00000000000..c362c973ae1
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_PATHS = 'SET_PATHS';
+
+export const REQUEST_REPORTS = 'REQUEST_REPORTS';
+export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
+export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js
new file mode 100644
index 00000000000..249c2f35c0b
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js
@@ -0,0 +1,27 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_PATHS](state, paths) {
+ state.baseBlobPath = paths.baseBlobPath;
+ state.headBlobPath = paths.headBlobPath;
+ state.reportsPath = paths.reportsPath;
+ state.helpPath = paths.helpPath;
+ },
+ [types.REQUEST_REPORTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORTS_SUCCESS](state, data) {
+ state.hasError = false;
+ state.status = '';
+ state.statusReason = '';
+ state.isLoading = false;
+ state.newIssues = data.newIssues;
+ state.resolvedIssues = data.resolvedIssues;
+ },
+ [types.RECEIVE_REPORTS_ERROR](state, error) {
+ state.isLoading = false;
+ state.hasError = true;
+ state.status = error?.status || '';
+ state.statusReason = error?.response?.data?.status_reason;
+ },
+};
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js
new file mode 100644
index 00000000000..f68dbc2a5fa
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/state.js
@@ -0,0 +1,16 @@
+export default () => ({
+ reportsPath: null,
+
+ baseBlobPath: null,
+ headBlobPath: null,
+
+ isLoading: false,
+ hasError: false,
+ status: '',
+ statusReason: '',
+
+ newIssues: [],
+ resolvedIssues: [],
+
+ helpPath: null,
+});
diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js
new file mode 100644
index 00000000000..417297df43c
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js
@@ -0,0 +1,29 @@
+export const parseCodeclimateMetrics = (issues = [], blobPath = '') => {
+ return issues.map((issue) => {
+ // the `file_path` attribute from the artifact is returned as `file` by GraphQL
+ const issuePath = issue.file_path || issue.path;
+ const parsedIssue = {
+ name: issue.description,
+ path: issuePath,
+ urlPath: `${blobPath}/${issuePath}#L${issue.line}`,
+ ...issue,
+ };
+
+ if (issue?.location?.path) {
+ let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`;
+ parsedIssue.path = issue.location.path;
+
+ if (issue?.location?.lines?.begin) {
+ parsedIssue.line = issue.location.lines.begin;
+ parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
+ } else if (issue?.location?.positions?.begin?.line) {
+ parsedIssue.line = issue.location.positions.begin.line;
+ parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`;
+ }
+
+ parsedIssue.urlPath = parseCodeQualityUrl;
+ }
+
+ return parsedIssue;
+ });
+};
diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
new file mode 100644
index 00000000000..b21a486e259
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue
@@ -0,0 +1,106 @@
+<script>
+import { s__ } from '~/locale';
+import ReportItem from '~/ci/reports/components/report_item.vue';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+export default {
+ components: {
+ ReportItem,
+ SmartVirtualList,
+ },
+ props: {
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|Fixed'),
+ },
+ unresolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|New'),
+ },
+ },
+ groups: ['unresolved', 'resolved'],
+ typicalReportItemHeight: 32,
+ maxShownReportItems: 20,
+ computed: {
+ groups() {
+ return this.$options.groups
+ .map((group) => ({
+ name: group,
+ issues: this[`${group}Issues`],
+ heading: this[`${group}Heading`],
+ }))
+ .filter(({ issues }) => issues.length > 0);
+ },
+ listLength() {
+ // every group has a header which is rendered as a list item
+ const groupsCount = this.groups.length;
+ const issuesCount = this.groups.reduce(
+ (totalIssues, { issues }) => totalIssues + issues.length,
+ 0,
+ );
+
+ return groupsCount + issuesCount;
+ },
+ listClasses() {
+ return {
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <smart-virtual-list
+ :length="listLength"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ :class="listClasses"
+ class="report-block-container"
+ wtag="ul"
+ wclass="report-block-list"
+ >
+ <template v-for="(group, groupIndex) in groups">
+ <h2
+ :key="group.name"
+ :data-testid="`${group.name}Heading`"
+ :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']"
+ class="h5 mb-1"
+ >
+ {{ group.heading }}
+ </h2>
+ <report-item
+ v-for="(issue, issueIndex) in group.issues"
+ :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`"
+ :issue="issue"
+ :show-report-section-status-icon="false"
+ :component="component"
+ status="none"
+ />
+ </template>
+ </smart-virtual-list>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/issue_body.js b/app/assets/javascripts/ci/reports/components/issue_body.js
new file mode 100644
index 00000000000..daff1be30ff
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issue_body.js
@@ -0,0 +1,17 @@
+import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue';
+
+export const components = {
+ CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'),
+};
+
+export const componentNames = {
+ CodequalityIssueBody: 'CodequalityIssueBody',
+};
+
+export const iconComponents = {
+ IssueStatusIcon,
+};
+
+export const iconComponentNames = {
+ IssueStatusIcon: IssueStatusIcon.name,
+};
diff --git a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue
new file mode 100644
index 00000000000..bd41b8d23f1
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '../constants';
+
+export default {
+ name: 'IssueStatusIcon',
+ components: {
+ GlIcon,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 24,
+ },
+ },
+ computed: {
+ iconName() {
+ if (this.isStatusFailed) {
+ return 'status_failed_borderless';
+ } else if (this.isStatusSuccess) {
+ return 'status_success_borderless';
+ }
+
+ return 'dash';
+ },
+ isStatusFailed() {
+ return this.status === STATUS_FAILED;
+ },
+ isStatusSuccess() {
+ return this.status === STATUS_SUCCESS;
+ },
+ isStatusNeutral() {
+ return this.status === STATUS_NEUTRAL;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :class="{
+ failed: isStatusFailed,
+ success: isStatusSuccess,
+ neutral: isStatusNeutral,
+ }"
+ class="report-block-list-icon"
+ >
+ <gl-icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/issues_list.vue b/app/assets/javascripts/ci/reports/components/issues_list.vue
new file mode 100644
index 00000000000..ababd4b5e49
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/issues_list.vue
@@ -0,0 +1,119 @@
+<script>
+import ReportItem from '~/ci/reports/components/report_item.vue';
+import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+
+const wrapIssueWithState = (status, isNew = false) => (issue) => ({
+ status: issue.status || status,
+ isNew,
+ issue,
+});
+
+/**
+ * Renders block of issues
+ */
+export default {
+ components: {
+ SmartVirtualList,
+ ReportItem,
+ },
+ // Typical height of a report item in px
+ typicalReportItemHeight: 32,
+ /*
+ The maximum amount of shown issues. This is calculated by
+ ( max-height of report-block-list / typicalReportItemHeight ) + some safety margin
+ We will use VirtualList if we have more items than this number.
+ For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly.
+ */
+ maxShownReportItems: 20,
+ props: {
+ newIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ neutralIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ nestedLevel: {
+ type: Number,
+ required: false,
+ default: 0,
+ validator: (value) => [0, 1, 2].includes(value),
+ },
+ },
+ computed: {
+ issuesWithState() {
+ return [
+ ...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)),
+ ...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)),
+ ...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)),
+ ...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)),
+ ];
+ },
+ wclass() {
+ return `report-block-list ${this.issuesUlElementClass}`;
+ },
+ listClasses() {
+ return {
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
+ };
+ },
+ },
+};
+</script>
+<template>
+ <smart-virtual-list
+ :length="issuesWithState.length"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ class="report-block-container"
+ :class="listClasses"
+ wtag="ul"
+ :wclass="wclass"
+ >
+ <report-item
+ v-for="(wrapped, index) in issuesWithState"
+ :key="index"
+ :issue="wrapped.issue"
+ :status="wrapped.status"
+ :component="component"
+ :is-new="wrapped.isNew"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :class="issueItemClass"
+ />
+ </smart-virtual-list>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
new file mode 100644
index 00000000000..97d4ac7bf6f
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -0,0 +1,67 @@
+<script>
+import {
+ components,
+ componentNames,
+ iconComponents,
+ iconComponentNames,
+} from 'ee_else_ce/ci/reports/components/issue_body';
+
+export default {
+ name: 'ReportItem',
+ components: {
+ ...components,
+ ...iconComponents,
+ },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ validator: (value) => value === '' || Object.values(componentNames).includes(value),
+ },
+ iconComponent: {
+ type: String,
+ required: false,
+ default: iconComponentNames.IssueStatusIcon,
+ validator: (value) => Object.values(iconComponentNames).includes(value),
+ },
+ // failed || success
+ status: {
+ type: String,
+ required: true,
+ },
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 24,
+ },
+ isNew: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+};
+</script>
+<template>
+ <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
+ <component
+ :is="iconComponent"
+ v-if="showReportSectionStatusIcon"
+ :status="status"
+ :status-icon-size="statusIconSize"
+ class="gl-mr-2"
+ />
+
+ <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
+ </li>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_link.vue b/app/assets/javascripts/ci/reports/components/report_link.vue
new file mode 100644
index 00000000000..1f68f79e487
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_link.vue
@@ -0,0 +1,30 @@
+<script>
+/* eslint-disable @gitlab/vue-require-i18n-strings */
+export default {
+ name: 'ReportIssueLink',
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="report-block-list-issue-description-link">
+ in
+
+ <a
+ v-if="issue.urlPath"
+ :href="issue.urlPath"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="break-link"
+ >
+ {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
+ </a>
+ <template v-else>
+ {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue
new file mode 100644
index 00000000000..468c8916b8d
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/report_section.vue
@@ -0,0 +1,237 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import api from '~/api';
+import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
+import IssuesList from './issues_list.vue';
+
+export default {
+ name: 'ReportSection',
+ components: {
+ GlButton,
+ IssuesList,
+ HelpPopover,
+ StatusIcon,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ alwaysOpen: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ status: {
+ type: String,
+ required: true,
+ },
+ loadingText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ errorText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ successText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ neutralIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ infoText: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ },
+ hasIssues: {
+ type: Boolean,
+ required: true,
+ },
+ popoverOptions: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issuesListContainerClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ shouldEmitToggleEvent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ trackAction: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+
+ computed: {
+ isLoading() {
+ return this.status === status.LOADING;
+ },
+ loadingFailed() {
+ return this.status === status.ERROR;
+ },
+ isSuccess() {
+ return this.status === status.SUCCESS;
+ },
+ isCollapsible() {
+ return !this.alwaysOpen && this.hasIssues;
+ },
+ isExpanded() {
+ return this.alwaysOpen || !this.isCollapsed;
+ },
+ statusIconName() {
+ if (this.isLoading) {
+ return 'loading';
+ }
+ if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
+ return 'warning';
+ }
+ return 'success';
+ },
+ headerText() {
+ if (this.isLoading) {
+ return this.loadingText;
+ }
+
+ if (this.isSuccess) {
+ return this.successText;
+ }
+
+ if (this.loadingFailed) {
+ return this.errorText;
+ }
+
+ return '';
+ },
+ hasPopover() {
+ return Object.keys(this.popoverOptions).length > 0;
+ },
+ slotName() {
+ if (this.isSuccess) {
+ return SLOT_SUCCESS;
+ } else if (this.isLoading) {
+ return SLOT_LOADING;
+ }
+
+ return SLOT_ERROR;
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ if (this.trackAction) {
+ api.trackRedisHllUserEvent(this.trackAction);
+ }
+
+ if (this.shouldEmitToggleEvent) {
+ this.$emit('toggleEvent');
+ }
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+};
+</script>
+<template>
+ <section class="media-section">
+ <div class="media">
+ <status-icon :status="statusIconName" :size="24" class="align-self-center" />
+ <div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!">
+ <div
+ data-testid="report-section-code-text"
+ class="js-code-text code-text gl-align-self-center gl-flex-grow-1"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
+ <slot :name="slotName"></slot>
+ <help-popover
+ v-if="hasPopover"
+ :options="popoverOptions"
+ class="gl-ml-2 gl-display-inline-flex"
+ />
+ </div>
+ <slot name="sub-heading"></slot>
+ </div>
+
+ <slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
+
+ <div
+ v-if="isCollapsible"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3"
+ >
+ <gl-button
+ data-testid="report-section-expand-button"
+ data-qa-selector="expand_report_button"
+ category="tertiary"
+ size="small"
+ :icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'"
+ @click="toggleCollapsed"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div v-if="hasIssues" v-show="isExpanded" class="js-report-section-container">
+ <slot name="body">
+ <issues-list
+ :unresolved-issues="unresolvedIssues"
+ :resolved-issues="resolvedIssues"
+ :neutral-issues="neutralIssues"
+ :component="component"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :issues-ul-element-class="issuesUlElementClass"
+ :class="issuesListContainerClass"
+ :issue-item-class="issueItemClass"
+ />
+ </slot>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue
new file mode 100644
index 00000000000..ee55368c829
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/components/summary_row.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { ICON_WARNING } from '../constants';
+
+/**
+ * Renders the summary row for each report
+ *
+ * Used both in MR widget and Pipeline's view for:
+ * - Unit tests reports
+ * - Security reports
+ */
+
+export default {
+ name: 'ReportSummaryRow',
+ components: {
+ CiIcon,
+ HelpPopover,
+ GlLoadingIcon,
+ },
+ props: {
+ nestedSummary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ summary: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ statusIcon: {
+ type: String,
+ required: true,
+ },
+ popoverOptions: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ iconStatus() {
+ return {
+ group: this.statusIcon,
+ icon: `status_${this.statusIcon}`,
+ };
+ },
+ rowClasses() {
+ if (!this.nestedSummary) {
+ return ['gl-px-5'];
+ }
+ return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ },
+ statusIconSize() {
+ if (!this.nestedSummary) {
+ return 24;
+ }
+ return 16;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center"
+ :class="rowClasses"
+ >
+ <div class="gl-mr-3">
+ <gl-loading-icon
+ v-if="statusIcon === 'loading'"
+ css-class="report-block-list-loading-icon"
+ size="lg"
+ />
+ <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
+ </div>
+ <div class="report-block-list-issue-description">
+ <div class="report-block-list-issue-description-text" data-testid="summary-row-description">
+ <slot name="summary">{{ summary }}</slot
+ ><span v-if="popoverOptions" class="text-nowrap"
+ >&nbsp;<help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" />
+ </span>
+ </div>
+ </div>
+ <div
+ v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */"
+ class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row"
+ >
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js
new file mode 100644
index 00000000000..bad6fa1e7b9
--- /dev/null
+++ b/app/assets/javascripts/ci/reports/constants.js
@@ -0,0 +1,38 @@
+export const fieldTypes = {
+ codeBlock: 'codeBlock',
+ link: 'link',
+ seconds: 'seconds',
+ text: 'text',
+};
+
+export const LOADING = 'LOADING';
+export const ERROR = 'ERROR';
+export const SUCCESS = 'SUCCESS';
+
+export const STATUS_FAILED = 'failed';
+export const STATUS_SUCCESS = 'success';
+export const STATUS_NEUTRAL = 'neutral';
+export const STATUS_NOT_FOUND = 'not_found';
+
+export const ICON_WARNING = 'warning';
+export const ICON_SUCCESS = 'success';
+export const ICON_NOTFOUND = 'notfound';
+export const ICON_PENDING = 'pending';
+export const ICON_FAILED = 'failed';
+
+export const status = {
+ LOADING,
+ ERROR,
+ SUCCESS,
+};
+
+export const ACCESSIBILITY_ISSUE_ERROR = 'error';
+export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
+
+/**
+ * Slot names for the ReportSection component, corresponding to the success,
+ * loading and error statuses.
+ */
+export const SLOT_SUCCESS = 'success';
+export const SLOT_LOADING = 'loading';
+export const SLOT_ERROR = 'error';
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 9fa4b521ebc..66d790acb00 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,5 +1,6 @@
<script>
-import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
+import VueRouter from 'vue-router';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -11,11 +12,28 @@ import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import RunnerJobs from '../components/runner_jobs.vue';
-import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants';
+import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+const ROUTE_DETAILS = 'details';
+const ROUTE_JOBS = 'jobs';
+
+const routes = [
+ {
+ path: '/',
+ name: ROUTE_DETAILS,
+ component: RunnerDetails,
+ },
+ {
+ path: '/jobs',
+ name: ROUTE_JOBS,
+ component: RunnerJobs,
+ },
+ { path: '*', redirect: { name: ROUTE_DETAILS } },
+];
+
export default {
name: 'AdminRunnerShowApp',
components: {
@@ -26,12 +44,10 @@ export default {
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
- RunnerDetails,
- RunnerJobs,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
},
+ router: new VueRouter({
+ routes,
+ }),
props: {
runnerId: {
type: String,
@@ -72,11 +88,17 @@ export default {
jobCount() {
return formatJobCount(this.runner?.jobCount);
},
+ tabIndex() {
+ return routes.findIndex(({ name }) => name === this.$route.name);
+ },
},
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
+ goTo(name) {
+ this.$router.push({ name });
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -85,7 +107,10 @@ export default {
redirectTo(this.runnersPath);
},
},
+ ROUTE_DETAILS,
+ ROUTE_JOBS,
I18N_DETAILS,
+ I18N_JOBS,
};
</script>
<template>
@@ -98,15 +123,13 @@ export default {
</template>
</runner-header>
- <gl-tabs>
- <gl-tab>
+ <gl-tabs :value="tabIndex">
+ <gl-tab @click="goTo($options.ROUTE_DETAILS)">
<template #title>{{ $options.I18N_DETAILS }}</template>
-
- <runner-details v-if="runner" :runner="runner" />
</gl-tab>
- <gl-tab>
+ <gl-tab @click="goTo($options.ROUTE_JOBS)">
<template #title>
- {{ s__('Runners|Jobs') }}
+ {{ $options.I18N_JOBS }}
<gl-badge
v-if="jobCount"
data-testid="job-count-badge"
@@ -116,9 +139,9 @@ export default {
{{ jobCount }}
</gl-badge>
</template>
-
- <runner-jobs v-if="runner" :runner="runner" />
</gl-tab>
+
+ <router-view v-if="runner" :runner="runner" />
</gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js
index ea455416648..cbd25819303 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js
@@ -1,10 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import AdminRunnerShowApp from './admin_runner_show_app.vue';
Vue.use(VueApollo);
+Vue.use(VueRouter);
export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => {
showAlertFromLocalStorage();
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index 2915e460085..3bd20dff9cc 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -23,6 +23,7 @@ import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
+import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
@@ -48,6 +49,7 @@ export default {
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
+ RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
@@ -69,6 +71,9 @@ export default {
apollo: {
runners: {
query: allRunnersQuery,
+ context: {
+ isSingleRequest: true,
+ },
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
@@ -134,6 +139,12 @@ export default {
this.reportToSentry(error);
},
methods: {
+ jobsUrl(runner) {
+ const url = new URL(runner.adminUrl);
+ url.hash = '#/jobs';
+
+ return url.href;
+ },
onToggledPaused() {
// When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
@@ -208,6 +219,12 @@ export default {
<runner-name :runner="runner" />
</gl-link>
</template>
+ <template #runner-job-status-badge="{ runner }">
+ <runner-job-status-badge
+ :href="jobsUrl(runner)"
+ :job-status="runner.jobExecutionStatus"
+ />
+ </template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell
:runner="runner"
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
index 67b9b0a266f..cfbe37f5ba2 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
@@ -7,8 +7,6 @@ import RunnerPausedBadge from '../runner_paused_badge.vue';
export default {
components: {
RunnerStatusBadge,
- RunnerUpgradeStatusBadge: () =>
- import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'),
RunnerPausedBadge,
},
directives: {
@@ -34,10 +32,6 @@ export default {
:runner="runner"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
- <runner-upgrade-status-badge
- :runner="runner"
- class="gl-display-inline-block gl-max-w-full gl-text-truncate"
- />
<runner-paused-badge
v-if="paused"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index 1e44d5fccc2..4a72023b6a0 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -6,9 +6,11 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
import RunnerTags from '../runner_tags.vue';
import RunnerTypeBadge from '../runner_type_badge.vue';
+import RunnerJobStatusBadge from '../runner_job_status_badge.vue';
import { formatJobCount } from '../../utils';
import {
+ I18N_NO_DESCRIPTION,
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
@@ -25,6 +27,7 @@ export default {
RunnerName,
RunnerTags,
RunnerTypeBadge,
+ RunnerJobStatusBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
TooltipOnTruncate,
@@ -44,6 +47,7 @@ export default {
},
},
i18n: {
+ I18N_NO_DESCRIPTION,
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
@@ -75,12 +79,21 @@ export default {
</gl-sprintf>
</div>
<div class="gl-text-secondary gl-mx-2" aria-hidden="true">ยท</div>
- <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description">
+ <tooltip-on-truncate
+ v-if="runner.description"
+ class="gl-text-truncate gl-display-block"
+ :title="runner.description"
+ >
{{ runner.description }}
</tooltip-on-truncate>
+ <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span>
</div>
<div>
+ <slot :runner="runner" name="runner-job-status-badge">
+ <runner-job-status-badge :job-status="runner.jobExecutionStatus" />
+ </slot>
+
<runner-summary-field icon="clock">
<gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
<template #timeAgo>
diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue
index c260670b517..9e8055a8432 100644
--- a/app/assets/javascripts/ci/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue
@@ -49,7 +49,7 @@ export default {
<template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
</template>
- <span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
+ <span v-else class="gl-text-secondary">{{ emptyValue }}</span>
</dd>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue
index c3b35bd52a9..8501d165157 100644
--- a/app/assets/javascripts/ci/runner/components/runner_groups.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue
@@ -32,6 +32,6 @@ export default {
:avatar-url="group.avatarUrl"
/>
</template>
- <span v-else class="gl-text-gray-500">{{ __('None') }}</span>
+ <span v-else class="gl-text-secondary">{{ __('None') }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
new file mode 100644
index 00000000000..1e52acecfb8
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import {
+ I18N_JOB_STATUS_RUNNING,
+ I18N_JOB_STATUS_IDLE,
+ JOB_STATUS_RUNNING,
+ JOB_STATUS_IDLE,
+} from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ jobStatus: {
+ required: false,
+ default: null,
+ type: String,
+ },
+ },
+ computed: {
+ badge() {
+ switch (this.jobStatus) {
+ case JOB_STATUS_RUNNING:
+ return {
+ classes: 'gl-text-blue-600! gl-border gl-border-blue-600!',
+ label: I18N_JOB_STATUS_RUNNING,
+ };
+ case JOB_STATUS_IDLE:
+ return {
+ classes: 'gl-text-gray-700! gl-border gl-border-gray-500!',
+ label: I18N_JOB_STATUS_IDLE,
+ };
+ default:
+ return null;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-if="badge"
+ v-bind="$attrs"
+ size="sm"
+ class="gl-mr-3 gl-bg-transparent!"
+ variant="muted"
+ :class="badge.classes"
+ >
+ {{ badge.label }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue
index e895537dcdc..b2aad0aac4f 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list.vue
@@ -7,7 +7,7 @@ import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.grap
import { formatJobCount, tableField } from '../utils';
import RunnerBulkDelete from './runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue';
-import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
+import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerOwnerCell from './cells/runner_owner_cell.vue';
@@ -28,7 +28,7 @@ export default {
RunnerBulkDelete,
RunnerBulkDeleteCheckbox,
RunnerStatusPopover,
- RunnerStackedSummaryCell,
+ RunnerSummaryCell,
RunnerStatusCell,
RunnerOwnerCell,
},
@@ -154,11 +154,14 @@ export default {
</template>
<template #cell(summary)="{ item, index }">
- <runner-stacked-summary-cell :runner="item">
+ <runner-summary-cell :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
- </runner-stacked-summary-cell>
+ <template #runner-job-status-badge="{ runner }">
+ <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot>
+ </template>
+ </runner-summary-cell>
</template>
<template #head(owner)="{ label }">
diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue
index 84008e8eee8..4a6e90b44a9 100644
--- a/app/assets/javascripts/ci/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue
@@ -133,7 +133,7 @@ export default {
:is-owner="isOwner(project.id)"
/>
</template>
- <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div>
+ <div v-else class="gl-py-5 gl-text-secondary">{{ $options.I18N_NO_PROJECTS_FOUND }}</div>
<runner-pagination
:disabled="loading"
diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue
index 584236168ac..70226074993 100644
--- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue
@@ -59,21 +59,20 @@ export default {
return [
{
title: I18N_ALL_TYPES,
- runnerType: null,
},
...tabs,
];
},
},
methods: {
- onTabSelected({ runnerType }) {
+ onTabSelected(runnerType) {
this.$emit('input', {
...this.value,
runnerType,
pagination: { page: 1 },
});
},
- isTabActive({ runnerType }) {
+ isTabActive(runnerType = null) {
return runnerType === this.value.runnerType;
},
tabBadgeCountVariables(runnerType) {
@@ -102,8 +101,8 @@ export default {
<gl-tab
v-for="tab in tabs"
:key="`${tab.runnerType}`"
- :active="isTabActive(tab)"
- @click="onTabSelected(tab)"
+ :active="isTabActive(tab.runnerType)"
+ @click="onTabSelected(tab.runnerType)"
>
<template #title>
{{ tab.title }}
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
index 97ee8ec3eef..71a145dd4a3 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js
@@ -1,5 +1,5 @@
import { __ } from '~/locale';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants';
@@ -24,5 +24,5 @@ export const pausedTokenConfig = {
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
title: title.replace(/\s/g, '\u00a0'),
})),
- operators: OPERATOR_IS_ONLY,
+ operators: OPERATORS_IS,
};
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
index 117a630719e..4bc32909777 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js
@@ -1,5 +1,5 @@
import {
- OPERATOR_IS_ONLY,
+ OPERATORS_IS,
TOKEN_TITLE_STATUS,
} from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
@@ -38,5 +38,5 @@ export const statusTokenConfig = {
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
title: title.replace(/\s/g, '\u00a0'),
})),
- operators: OPERATOR_IS_ONLY,
+ operators: OPERATORS_IS,
};
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js
index fdeba714385..369b214f952 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js
@@ -1,5 +1,5 @@
import { s__ } from '~/locale';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import { PARAM_KEY_TAG } from '../../constants';
import TagToken from './tag_token.vue';
@@ -8,5 +8,5 @@ export const tagTokenConfig = {
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
- operators: OPERATOR_IS_ONLY,
+ operators: OPERATORS_IS,
};
diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue
index 4ad9259f59d..c33c42f3afe 100644
--- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue
@@ -16,13 +16,13 @@ import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
* <strong/> tag.
*
* ```vue
- * <runner-count-stat
+ * <runner-count
* #default="{ count }"
* :scope="INSTANCE_TYPE"
* :variables="{ status: 'ONLINE' }"
* >
* <strong>{{ count }}</strong>
- * </runner-count-stat>
+ * </runner-count>
* ```
*
* Use `:skip="true"` to prevent data from being fetched and
diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
index 3965e5551f1..2e50dc13d2d 100644
--- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue
@@ -1,5 +1,4 @@
<script>
-import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue';
import {
I18N_STATUS_ONLINE,
I18N_STATUS_OFFLINE,
@@ -8,9 +7,19 @@ import {
STATUS_OFFLINE,
STATUS_STALE,
} from '../../constants';
+import RunnerSingleStat from './runner_single_stat.vue';
+import RunnerCount from './runner_count.vue';
+
+/**
+ * Shows general stats about the runners.
+ *
+ * First it checks if there are any runners in this context, and if so,
+ * shows more details for different status.
+ */
export default {
components: {
+ RunnerCount,
RunnerSingleStat,
RunnerUpgradeStatusStats: () =>
import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'),
@@ -71,19 +80,21 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-py-6">
- <runner-single-stat
- v-for="stat in stats"
- :key="stat.key"
- :scope="scope"
- v-bind="stat.props"
- class="gl-px-5"
- />
+ <runner-count #default="{ count }" :scope="scope" :variables="variables">
+ <div v-if="count" class="gl-display-flex gl-flex-wrap gl-py-6">
+ <runner-single-stat
+ v-for="stat in stats"
+ :key="stat.key"
+ :scope="scope"
+ v-bind="stat.props"
+ class="gl-px-5"
+ />
- <runner-upgrade-status-stats
- class="gl-display-contents"
- :scope="scope"
- :variables="variables"
- />
- </div>
+ <runner-upgrade-status-stats
+ class="gl-display-contents"
+ :scope="scope"
+ :variables="variables"
+ />
+ </div>
+ </runner-count>
</template>
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index dfc5f0c4152..31900a1fe89 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -32,6 +32,10 @@ export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted');
export const I18N_STATUS_OFFLINE = s__('Runners|Offline');
export const I18N_STATUS_STALE = s__('Runners|Stale');
+// Executor Status
+export const I18N_JOB_STATUS_RUNNING = s__('Runners|Running');
+export const I18N_JOB_STATUS_IDLE = s__('Runners|Idle');
+
// Status help popover
export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses');
@@ -82,6 +86,7 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
// List
+export const I18N_NO_DESCRIPTION = s__('Runners|No description');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
);
@@ -94,6 +99,7 @@ export const I18N_ADMIN = s__('Runners|Administrator');
// Runner details
export const I18N_DETAILS = s__('Runners|Details');
+export const I18N_JOBS = s__('Runners|Jobs');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects');
export const I18N_CLEAR_FILTER_PROJECTS = __('Clear');
@@ -134,6 +140,11 @@ export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_STALE = 'STALE';
+// CiRunnerJobExecutionStatus
+
+export const JOB_STATUS_RUNNING = 'RUNNING';
+export const JOB_STATUS_IDLE = 'IDLE';
+
// CiRunnerAccessLevel
export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED';
diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index 0dff011daaa..6f72509f599 100644
--- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
@@ -12,6 +12,7 @@ fragment ListItemShared on CiRunner {
createdAt
contactedAt
status(legacyMode: null)
+ jobExecutionStatus
userPermissions {
updateRunner
deleteRunner
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 91c22923075..57ceaa24b6e 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -82,6 +82,9 @@ export default {
apollo: {
runners: {
query: groupRunnersQuery,
+ context: {
+ isSingleRequest: true,
+ },
fetchPolicy: fetchPolicies.NETWORK_ONLY,
variables() {
return this.variables;
diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js
index adc832b0600..3dc99baa329 100644
--- a/app/assets/javascripts/ci/runner/runner_search_utils.js
+++ b/app/assets/javascripts/ci/runner/runner_search_utils.js
@@ -176,6 +176,7 @@ export const fromSearchToUrl = (
[PARAM_KEY_RUNNER_TYPE]: [],
[PARAM_KEY_MEMBERSHIP]: [],
[PARAM_KEY_TAG]: [],
+ [PARAM_KEY_PAUSED]: [],
// Current filters
...filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,