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