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 | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/ci')
99 files changed, 6218 insertions, 63 deletions
diff --git a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue new file mode 100644 index 00000000000..49a314e067c --- /dev/null +++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue @@ -0,0 +1,131 @@ +<script> +import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCiMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; + +export default { + components: { + GlButton, + GlFormCheckbox, + GlIcon, + GlLink, + GlAlert, + CiLintResults, + SourceEditor, + }, + props: { + endpoint: { + type: String, + required: true, + }, + lintHelpPagePath: { + type: String, + required: true, + }, + pipelineSimulationHelpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + content: '', + loading: false, + isValid: false, + errors: null, + warnings: null, + jobs: [], + dryRun: false, + showingResults: false, + apiError: null, + isErrorDismissed: false, + }; + }, + computed: { + shouldShowError() { + return this.apiError && !this.isErrorDismissed; + }, + }, + methods: { + async lint() { + this.loading = true; + try { + const { + data: { + lintCI: { valid, errors, warnings, jobs }, + }, + } = await this.$apollo.mutate({ + mutation: lintCiMutation, + variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun }, + }); + + this.showingResults = true; + this.isValid = valid; + this.errors = errors; + this.warnings = warnings; + this.jobs = jobs; + } catch (error) { + this.apiError = error; + this.isErrorDismissed = false; + } finally { + this.loading = false; + } + }, + clear() { + this.content = ''; + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-sm-12"> + <gl-alert + v-if="shouldShowError" + class="gl-mb-3" + variant="danger" + @dismiss="isErrorDismissed = true" + >{{ apiError }}</gl-alert + > + <div class="file-holder gl-mb-3"> + <div class="js-file-title file-title clearfix"> + {{ __('Contents of .gitlab-ci.yml') }} + </div> + <source-editor v-model="content" file-name="*.yml" /> + </div> + </div> + + <div class="col-sm-12 gl-display-flex gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-center"> + <gl-button + class="gl-mr-4" + :loading="loading" + category="primary" + variant="confirm" + data-testid="ci-lint-validate" + @click="lint" + >{{ __('Validate') }}</gl-button + > + <gl-form-checkbox v-model="dryRun" + >{{ __('Simulate a pipeline created for the default branch') }} + <gl-link :href="pipelineSimulationHelpPagePath" target="_blank" + ><gl-icon class="gl-text-blue-600" name="question-o" /></gl-link + ></gl-form-checkbox> + </div> + <gl-button data-testid="ci-lint-clear" @click="clear">{{ __('Clear') }}</gl-button> + </div> + + <ci-lint-results + v-if="showingResults" + class="col-sm-12 gl-mt-5" + :is-valid="isValid" + :jobs="jobs" + :errors="errors" + :warnings="warnings" + :dry-run="dryRun" + :lint-help-page-path="lintHelpPagePath" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/ci_lint/index.js b/app/assets/javascripts/ci/ci_lint/index.js new file mode 100644 index 00000000000..382059eb17e --- /dev/null +++ b/app/assets/javascripts/ci/ci_lint/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; + +import CiLint from './components/ci_lint.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), +}); + +export default (containerId = '#js-ci-lint') => { + const containerEl = document.querySelector(containerId); + const { endpoint, lintHelpPagePath, pipelineSimulationHelpPagePath } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + render(createElement) { + return createElement(CiLint, { + props: { + endpoint, + lintHelpPagePath, + pipelineSimulationHelpPagePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue new file mode 100644 index 00000000000..7b33d98bca0 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue @@ -0,0 +1,42 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants'; + +export default { + name: 'CodeSnippetAlert', + components: { + GlAlert, + }, + inject: ['configurationPaths'], + props: { + source: { + type: String, + required: true, + validator: (source) => CODE_SNIPPET_SOURCES.includes(source), + }, + }, + computed: { + settings() { + return CODE_SNIPPET_SOURCE_SETTINGS[this.source]; + }, + configurationPath() { + return this.configurationPaths[this.source]; + }, + }, +}; +</script> + +<template> + <gl-alert + variant="tip" + :title="__('Code snippet copied. Insert it in the correct location in the YAML file.')" + :dismiss-label="__('Dismiss')" + :primary-button-link="settings.docsPath" + :primary-button-text="__('Read documentation')" + :secondary-button-link="configurationPath" + :secondary-button-text="__('Go back to configuration')" + v-on="$listeners" + > + {{ __('Before inserting code, be sure to read the comment that separated each code group.') }} + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js new file mode 100644 index 00000000000..e4fd423249b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js @@ -0,0 +1,17 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from'; +export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing'; +export const CODE_SNIPPET_SOURCE_DAST = 'dast'; + +export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING, CODE_SNIPPET_SOURCE_DAST]; +export const CODE_SNIPPET_SOURCE_SETTINGS = { + [CODE_SNIPPET_SOURCE_API_FUZZING]: { + datasetKey: 'apiFuzzingConfigurationPath', + docsPath: helpPagePath('user/application_security/api_fuzzing/index'), + }, + [CODE_SNIPPET_SOURCE_DAST]: { + datasetKey: 'dastConfigurationPath', + docsPath: helpPagePath('user/application_security/dast/index'), + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue new file mode 100644 index 00000000000..4775836fcc6 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue @@ -0,0 +1,167 @@ +<script> +import { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, + }, + props: { + currentBranch: { + type: String, + required: false, + default: '', + }, + defaultMessage: { + type: String, + required: false, + default: '', + }, + hasUnsavedChanges: { + type: Boolean, + required: true, + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + message: this.defaultMessage, + openMergeRequest: false, + sourceBranch: this.currentBranch, + }; + }, + computed: { + isCommitFormFilledOut() { + return this.message && this.sourceBranch; + }, + isCurrentBranchSourceBranch() { + return this.sourceBranch === this.currentBranch; + }, + isSubmitDisabled() { + return !this.isCommitFormFilledOut || (!this.hasUnsavedChanges && !this.isNewCiConfigFile); + }, + }, + watch: { + scrollToCommitForm(flag) { + if (flag) { + this.scrollIntoView(); + } + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { + message: this.message, + sourceBranch: this.sourceBranch, + openMergeRequest: this.openMergeRequest, + }); + }, + onReset() { + this.$emit('resetContent'); + }, + scrollIntoView() { + this.$el.scrollIntoView({ behavior: 'smooth' }); + this.$emit('scrolled-to-commit-form'); + }, + }, + i18n: { + commitMessage: __('Commit message'), + sourceBranch: __('Branch'), + startMergeRequest: __('Start a %{new_merge_request} with these changes'), + newMergeRequest: __('new merge request'), + commitChanges: __('Commit changes'), + resetContent: __('Reset'), + }, +}; +</script> + +<template> + <div> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <gl-form-group + id="commit-group" + :label="$options.i18n.commitMessage" + label-cols-sm="2" + label-for="commit-message" + > + <gl-form-textarea + id="commit-message" + v-model="message" + class="gl-font-monospace!" + required + :placeholder="defaultMessage" + /> + </gl-form-group> + <gl-form-group + id="source-branch-group" + :label="$options.i18n.sourceBranch" + label-cols-sm="2" + label-for="source-branch-field" + > + <gl-form-input + id="source-branch-field" + v-model="sourceBranch" + class="gl-font-monospace!" + required + data-qa-selector="source_branch_field" + /> + <gl-form-checkbox + v-if="!isCurrentBranchSourceBranch" + v-model="openMergeRequest" + data-testid="new-mr-checkbox" + data-qa-selector="new_mr_checkbox" + class="gl-mt-3" + > + <gl-sprintf :message="$options.i18n.startMergeRequest"> + <template #new_merge_request> + <strong>{{ $options.i18n.newMergeRequest }}</strong> + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> + <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"> + <gl-button + type="submit" + class="js-no-auto-disable gl-mr-3" + category="primary" + variant="confirm" + data-qa-selector="commit_changes_button" + :disabled="isSubmitDisabled" + :loading="isSaving" + > + {{ $options.i18n.commitChanges }} + </gl-button> + <gl-button type="reset" category="secondary" class="gl-mr-3"> + {{ $options.i18n.resetContent }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue new file mode 100644 index 00000000000..9cbf60b1c8f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue @@ -0,0 +1,164 @@ +<script> +import { __, s__, sprintf } from '~/locale'; +import { + COMMIT_ACTION_CREATE, + COMMIT_ACTION_UPDATE, + COMMIT_FAILURE, + COMMIT_SUCCESS, + COMMIT_SUCCESS_WITH_REDIRECT, +} from '../../constants'; +import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; +import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql'; +import updateLastCommitBranchMutation from '../../graphql/mutations/client/update_last_commit_branch.mutation.graphql'; +import updatePipelineEtag from '../../graphql/mutations/client/update_pipeline_etag.mutation.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; + +import CommitForm from './commit_form.vue'; + +export default { + alertTexts: { + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + }, + i18n: { + defaultCommitMessage: __('Update %{sourcePath} file'), + }, + components: { + CommitForm, + }, + inject: ['projectFullPath', 'ciConfigPath'], + props: { + ciFileContent: { + type: String, + required: true, + }, + commitSha: { + type: String, + required: false, + default: '', + }, + hasUnsavedChanges: { + type: Boolean, + required: true, + }, + isNewCiConfigFile: { + type: Boolean, + required: false, + default: false, + }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + commit: {}, + isSaving: false, + }; + }, + apollo: { + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, + }, + }, + computed: { + action() { + return this.isNewCiConfigFile ? COMMIT_ACTION_CREATE : COMMIT_ACTION_UPDATE; + }, + defaultCommitMessage() { + return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); + }, + }, + methods: { + async onCommitSubmit({ message, sourceBranch, openMergeRequest }) { + this.isSaving = true; + + try { + const { + data: { + commitCreate: { errors, commitPipelinePath: pipelineEtag }, + }, + } = await this.$apollo.mutate({ + mutation: commitCIFile, + variables: { + action: this.action, + projectPath: this.projectFullPath, + branch: sourceBranch, + startBranch: this.currentBranch, + message, + filePath: this.ciConfigPath, + content: this.ciFileContent, + lastCommitId: this.commitSha, + }, + }); + + if (pipelineEtag) { + this.updatePipelineEtag(pipelineEtag); + } + + if (errors?.length) { + this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); + } else { + const params = openMergeRequest + ? { + type: COMMIT_SUCCESS_WITH_REDIRECT, + params: { + sourceBranch, + targetBranch: this.currentBranch, + }, + } + : { type: COMMIT_SUCCESS }; + + this.$emit('commit', { + ...params, + }); + + this.updateLastCommitBranch(sourceBranch); + this.updateCurrentBranch(sourceBranch); + + if (this.currentBranch === sourceBranch) { + this.$emit('updateCommitSha'); + } + } + } catch (error) { + this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] }); + } finally { + this.isSaving = false; + } + }, + updateCurrentBranch(currentBranch) { + this.$apollo.mutate({ + mutation: updateCurrentBranchMutation, + variables: { currentBranch }, + }); + }, + updateLastCommitBranch(lastCommitBranch) { + this.$apollo.mutate({ + mutation: updateLastCommitBranchMutation, + variables: { lastCommitBranch }, + }); + }, + updatePipelineEtag(pipelineEtag) { + this.$apollo.mutate({ mutation: updatePipelineEtag, variables: { pipelineEtag } }); + }, + }, +}; +</script> + +<template> + <commit-form + :current-branch="currentBranch" + :default-message="defaultCommitMessage" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" + :is-saving="isSaving" + :scroll-to-commit-form="scrollToCommitForm" + v-on="$listeners" + @submit="onCommitSubmit" + /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue new file mode 100644 index 00000000000..0b57433e894 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -0,0 +1,57 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { pipelineEditorTrackingOptions } from '../../../constants'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|๐ Run your first pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|This template creates a simple test pipeline. To use it:', + ), + listItems: [ + s__( + 'PipelineEditorTutorial|Commit the file to your repository. The pipeline then runs automatically.', + ), + s__('PipelineEditorTutorial|The pipeline status is at the top of the page.'), + s__( + 'PipelineEditorTutorial|Select the pipeline ID to view the full details about your first pipeline run.', + ), + ], + note: s__( + 'PipelineEditorTutorial|If youโre using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}', + ), + }, + components: { + GlLink, + GlSprintf, + }, + mixins: [Tracking.mixin()], + methods: { + trackHelpPageClick() { + const { label, actions } = pipelineEditorTrackingOptions; + this.track(actions.helpDrawerLinks.runners, { label }); + }, + }, + RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', +}; +</script> +<template> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ol class="gl-mb-3"> + <li v-for="(item, i) in $options.i18n.listItems" :key="`li-${i}`">{{ item }}</li> + </ol> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.note"> + <template #link="{ content }"> + <gl-link :href="$options.RUNNER_HELP_URL" target="_blank" @click="trackHelpPageClick()"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue new file mode 100644 index 00000000000..d2682cf6326 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue @@ -0,0 +1,32 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|Get started with GitLab CI/CD'), + firstParagraph: s__( + 'PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application.', + ), + secondParagraph: s__( + 'PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor.', + ), + }, + components: { + GlSprintf, + }, +}; +</script> +<template> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue new file mode 100644 index 00000000000..bc9203b9c5b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue @@ -0,0 +1,107 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + CI_EXAMPLES_LINK, + CI_HELP_LINK, + CI_NEEDS_LINK, + CI_YAML_LINK, + pipelineEditorTrackingOptions, +} from '../../../constants'; + +export default { + CI_EXAMPLES_LINK, + CI_HELP_LINK, + CI_NEEDS_LINK, + CI_YAML_LINK, + i18n: { + title: s__('PipelineEditorTutorial|โ๏ธ Pipeline configuration reference'), + firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'), + browseExamples: s__( + 'PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}', + ), + viewSyntaxRef: s__( + 'PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}', + ), + learnMore: s__( + 'PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}', + ), + needs: s__( + 'PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}', + ), + }, + components: { + GlLink, + GlSprintf, + }, + mixins: [Tracking.mixin()], + inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'], + methods: { + trackHelpPageClick(key) { + const { label, actions } = pipelineEditorTrackingOptions; + this.track(actions.helpDrawerLinks[key], { label }); + }, + }, +}; +</script> +<template> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ul> + <li> + <gl-sprintf :message="$options.i18n.browseExamples"> + <template #link="{ content }"> + <gl-link + :href="ciExamplesHelpPagePath" + target="_blank" + @click="trackHelpPageClick($options.CI_EXAMPLES_LINK)" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.viewSyntaxRef"> + <template #link="{ content }"> + <gl-link + :href="ymlHelpPagePath" + target="_blank" + @click="trackHelpPageClick($options.CI_YAML_LINK)" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link + :href="ciHelpPagePath" + target="_blank" + @click="trackHelpPageClick($options.CI_HELP_LINK)" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.needs"> + <template #link="{ content }"> + <gl-link + :href="needsHelpPagePath" + target="_blank" + @click="trackHelpPageClick($options.CI_NEEDS_LINK)" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue new file mode 100644 index 00000000000..aeeb52319d2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue @@ -0,0 +1,18 @@ +<script> +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|๐ก Tip: Visualize and validate your pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.', + ), + }, +}; +</script> +<template> + <div> + <h3 class="gl-font-lg gl-mt-0 gl-mb-5">{{ $options.i18n.title }}</h3> + <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue new file mode 100644 index 00000000000..375db7f3054 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -0,0 +1,61 @@ +<script> +import { GlDrawer } from '@gitlab/ui'; +import { __ } from '~/locale'; +import FirstPipelineCard from './cards/first_pipeline_card.vue'; +import GettingStartedCard from './cards/getting_started_card.vue'; +import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; +import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue'; + +const DRAWER_CARD_STYLES = ['gl-border-bottom-0', 'gl-pt-6!', 'gl-pb-0!', 'gl-line-height-20']; + +export default { + DRAWER_CARD_STYLES, + i18n: { + title: __('Help'), + }, + components: { + FirstPipelineCard, + GettingStartedCard, + GlDrawer, + PipelineConfigReferenceCard, + VisualizeAndLintCard, + }, + props: { + isVisible: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + drawerCardStyles() { + return ''; + }, + drawerHeightOffset() { + const wrapperEl = document.querySelector('.content-wrapper'); + return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; + }, + }, + methods: { + closeDrawer() { + this.$emit('close-drawer'); + }, + }, +}; +</script> +<template> + <gl-drawer + :header-height="drawerHeightOffset" + :open="isVisible" + :z-index="200" + @close="closeDrawer" + > + <template #title> + <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.title }}</h2> + </template> + <getting-started-card :class="$options.DRAWER_CARD_STYLES" /> + <first-pipeline-card :class="$options.DRAWER_CARD_STYLES" /> + <visualize-and-lint-card :class="$options.DRAWER_CARD_STYLES" /> + <pipeline-config-reference-card :class="$options.DRAWER_CARD_STYLES" /> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue new file mode 100644 index 00000000000..049504181c4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue @@ -0,0 +1,17 @@ +<script> +export default { + props: { + jobName: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div + class="gl-w-13 gl-h-6 gl-font-sm gl-bg-white gl-inset-border-1-blue-500 gl-text-center gl-text-truncate gl-rounded-pill gl-px-4 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + > + {{ jobName }} + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue new file mode 100644 index 00000000000..42e2d34fa3a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -0,0 +1,56 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; + +export default { + i18n: { + viewOnlyMessage: s__('Pipelines|Merged YAML is view only'), + }, + components: { + SourceEditor, + GlIcon, + }, + inject: ['ciConfigPath'], + props: { + ciConfigData: { + type: Object, + required: true, + }, + }, + data() { + return { + failureType: null, + }; + }, + computed: { + fileGlobalId() { + return `${this.ciConfigPath}-${uniqueId()}`; + }, + mergedYaml() { + return this.ciConfigData.mergedYaml; + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" /> + {{ $options.i18n.viewOnlyMessage }} + </div> + <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <source-editor + ref="editor" + :value="mergedYaml" + :file-name="ciConfigPath" + :file-global-id="fileGlobalId" + :editor-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + readOnly: true, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + v-on="$listeners" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue new file mode 100644 index 00000000000..201fba837e2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -0,0 +1,68 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants'; + +export default { + i18n: { + browseTemplates: __('Browse templates'), + help: __('Help'), + }, + TEMPLATE_REPOSITORY_URL, + components: { + GlButton, + }, + mixins: [Tracking.mixin()], + props: { + showDrawer: { + type: Boolean, + required: true, + }, + }, + methods: { + toggleDrawer() { + if (this.showDrawer) { + this.$emit('close-drawer'); + } else { + this.$emit('open-drawer'); + this.trackHelpDrawerClick(); + } + }, + trackHelpDrawerClick() { + const { label, actions } = pipelineEditorTrackingOptions; + this.track(actions.openHelpDrawer, { label }); + }, + trackTemplateBrowsing() { + const { label, actions } = pipelineEditorTrackingOptions; + + this.track(actions.browseTemplates, { label }); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <gl-button + :href="$options.TEMPLATE_REPOSITORY_URL" + size="small" + icon="external-link" + target="_blank" + data-testid="template-repo-link" + data-qa-selector="template_repo_link" + @click="trackTemplateBrowsing" + > + {{ $options.i18n.browseTemplates }} + </gl-button> + <gl-button + icon="information-o" + size="small" + data-testid="drawer-toggle" + data-qa-selector="drawer_toggle" + @click="toggleDrawer" + > + {{ $options.i18n.help }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue new file mode 100644 index 00000000000..255e3cb31f1 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue @@ -0,0 +1,48 @@ +<script> +import { EDITOR_READY_EVENT } from '~/editor/constants'; +import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SOURCE_EDITOR_DEBOUNCE } from '../../constants'; + +export default { + editorOptions: { + // Quick suggestions is so that monaco can provide + // autocomplete for keywords + quickSuggestions: true, + }, + debounceValue: SOURCE_EDITOR_DEBOUNCE, + components: { + SourceEditor, + }, + mixins: [glFeatureFlagMixin()], + inject: ['ciConfigPath'], + inheritAttrs: false, + methods: { + onCiConfigUpdate(content) { + this.$emit('updateCiConfig', content); + }, + registerCiSchema({ detail: { instance } }) { + if (this.glFeatures.schemaLinting) { + instance.use({ definition: CiSchemaExtension }); + instance.registerCiSchema(); + } + }, + }, + readyEvent: EDITOR_READY_EVENT, +}; +</script> +<template> + <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!"> + <source-editor + ref="editor" + :debounce-value="$options.debounceValue" + :editor-options="$options.editorOptions" + :file-name="ciConfigPath" + v-bind="$attrs" + @[$options.readyEvent]="registerCiSchema($event)" + @input="onCiConfigUpdate" + v-on="$listeners" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue new file mode 100644 index 00000000000..ef9acc1f8f1 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -0,0 +1,254 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlInfiniteScroll, + GlLoadingIcon, + GlSearchBoxByType, + GlTooltipDirective, +} from '@gitlab/ui'; +import { produce } from 'immer'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { + BRANCH_PAGINATION_LIMIT, + BRANCH_SEARCH_DEBOUNCE, + DEFAULT_FAILURE, +} from '~/ci/pipeline_editor/constants'; +import updateCurrentBranchMutation from '~/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; + +export default { + i18n: { + dropdownHeader: __('Switch branch'), + title: __('Branches'), + fetchError: __('Unable to fetch branch list for this project.'), + }, + inputDebounce: BRANCH_SEARCH_DEBOUNCE, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlInfiniteScroll, + GlLoadingIcon, + GlSearchBoxByType, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectFullPath', 'totalBranches'], + props: { + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, + paginationLimit: { + type: Number, + required: false, + default: BRANCH_PAGINATION_LIMIT, + }, + shouldLoadNewBranch: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + availableBranches: [], + branchSelected: null, + pageLimit: this.paginationLimit, + pageCounter: 0, + searchTerm: '', + lastCommitBranch: '', + }; + }, + apollo: { + availableBranches: { + query: getAvailableBranchesQuery, + variables() { + return { + offset: 0, + projectFullPath: this.projectFullPath, + ...this.availableBranchesVariables, + }; + }, + update(data) { + return data.project?.repository?.branchNames || []; + }, + result() { + this.pageCounter += 1; + }, + error() { + this.showFetchError(); + }, + }, + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, + }, + lastCommitBranch: { + query: getLastCommitBranch, + update(data) { + return data.workBranches.lastCommit.name; + }, + result({ data }) { + if (data) { + const { name: lastCommitBranch } = data.workBranches.lastCommit; + if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { + return; + } + + this.availableBranches.unshift(lastCommitBranch); + } + }, + }, + }, + computed: { + availableBranchesVariables() { + if (this.searchTerm.length > 0) { + return { + limit: this.totalBranches, + searchPattern: `*${this.searchTerm}*`, + }; + } + + return { + limit: this.paginationLimit, + searchPattern: '*', + }; + }, + enableBranchSwitcher() { + return this.availableBranches.length > 0 || this.searchTerm.length > 0; + }, + isBranchesLoading() { + return this.$apollo.queries.availableBranches.loading; + }, + }, + watch: { + shouldLoadNewBranch(flag) { + if (flag) { + this.changeBranch(this.branchSelected); + } + }, + }, + methods: { + // if there is no searchPattern, paginate by {paginationLimit} branches + fetchNextBranches() { + if ( + this.isBranchesLoading || + this.searchTerm.length > 0 || + this.availableBranches.length >= this.totalBranches + ) { + return; + } + + this.$apollo.queries.availableBranches + .fetchMore({ + variables: { + offset: this.pageCounter * this.paginationLimit, + }, + updateQuery(previousResult, { fetchMoreResult }) { + const previousBranches = previousResult.project.repository.branchNames; + const newBranches = fetchMoreResult.project.repository.branchNames; + + return produce(fetchMoreResult, (draftData) => { + draftData.project.repository.branchNames = previousBranches.concat(newBranches); + }); + }, + }) + .catch(this.showFetchError); + }, + async changeBranch(newBranch) { + this.updateCurrentBranch(newBranch); + const updatedPath = setUrlParams({ branch_name: newBranch }); + historyPushState(updatedPath); + + // refetching the content will cause a lot of components to re-render, + // including the text editor which uses the commit sha to register the CI schema + // so we need to make sure the currentBranch (and consequently, the commitSha) are updated first + await this.$nextTick(); + this.$emit('refetchContent'); + }, + selectBranch(newBranch) { + if (newBranch !== this.currentBranch) { + // If there are unsaved changes, we want to show the user + // a modal to confirm what to do with these before changing + // branches. + if (this.hasUnsavedChanges) { + this.branchSelected = newBranch; + this.$emit('select-branch', newBranch); + } else { + this.changeBranch(newBranch); + } + } + }, + async setSearchTerm(newSearchTerm) { + this.pageCounter = 0; + this.searchTerm = newSearchTerm.trim(); + }, + showFetchError() { + this.$emit('showError', { + type: DEFAULT_FAILURE, + reasons: [this.$options.i18n.fetchError], + }); + }, + updateCurrentBranch(currentBranch) { + this.$apollo.mutate({ + mutation: updateCurrentBranchMutation, + variables: { currentBranch }, + }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + v-gl-tooltip.hover + :title="$options.i18n.dropdownHeader" + :header-text="$options.i18n.dropdownHeader" + :text="currentBranch" + :disabled="!enableBranchSwitcher" + icon="branch" + data-qa-selector="branch_selector_button" + data-testid="branch-selector" + > + <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> + <gl-dropdown-section-header> + {{ $options.i18n.title }} + </gl-dropdown-section-header> + + <gl-infinite-scroll + :fetched-items="availableBranches.length" + :max-list-height="250" + data-qa-selector="branch_menu_container" + @bottomReached="fetchNextBranches" + > + <template #items> + <gl-dropdown-item + v-for="branch in availableBranches" + :key="branch" + :is-checked="currentBranch === branch" + is-check-item + data-qa-selector="branch_menu_item_button" + @click="selectBranch(branch)" + > + {{ branch }} + </gl-dropdown-item> + </template> + <template #default> + <gl-dropdown-item v-if="isBranchesLoading" key="loading"> + <gl-loading-icon size="lg" /> + </gl-dropdown-item> + </template> + </gl-infinite-scroll> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue new file mode 100644 index 00000000000..84c29e48114 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -0,0 +1,72 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants'; +import FileTreePopover from '../popovers/file_tree_popover.vue'; +import BranchSwitcher from './branch_switcher.vue'; + +export default { + components: { + BranchSwitcher, + FileTreePopover, + GlButton, + }, + props: { + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, + isNewCiConfigFile: { + type: Boolean, + required: false, + default: false, + }, + shouldLoadNewBranch: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + }, + computed: { + isAppLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; + }, + showFileTreeToggle() { + return !this.isNewCiConfigFile && this.appStatus !== EDITOR_APP_STATUS_EMPTY; + }, + }, + methods: { + onFileTreeBtnClick() { + this.$emit('toggle-file-tree'); + }, + }, +}; +</script> +<template> + <div class="gl-mb-4"> + <gl-button + v-if="showFileTreeToggle" + id="file-tree-toggle" + icon="file-tree" + data-testid="file-tree-toggle" + :aria-label="__('File Tree')" + :loading="isAppLoading" + @click="onFileTreeBtnClick" + /> + <file-tree-popover v-if="showFileTreeToggle" /> + <branch-switcher + :has-unsaved-changes="hasUnsavedChanges" + :should-load-new-branch="shouldLoadNewBranch" + v-on="$listeners" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue new file mode 100644 index 00000000000..280cd729a43 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue @@ -0,0 +1,78 @@ +<script> +import { GlAlert, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { FILE_TREE_TIP_DISMISSED_KEY } from '../../constants'; +import FileItem from './file_item.vue'; + +const i18n = { + tipBtn: __('Learn more'), + tipDescription: s__( + 'PipelineEditorFileTree|When you use the include keyword to add pipeline configuration from files in the project, those files will be listed here.', + ), + tipTitle: s__('PipelineEditorFileTree|Configuration files added with the include keyword'), +}; + +export default { + i18n, + name: 'PipelineEditorFileTreeContainer', + components: { + FileIcon, + FileItem, + GlAlert, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['ciConfigPath', 'includesHelpPagePath'], + props: { + includes: { + type: Array, + required: true, + }, + }, + data() { + return { + canShowTip: localStorage.getItem(FILE_TREE_TIP_DISMISSED_KEY) !== 'true', + }; + }, + computed: { + showTip() { + return this.includes.length === 0 && this.canShowTip; + }, + }, + methods: { + dismissTip() { + this.canShowTip = false; + localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true'); + }, + }, +}; +</script> +<template> + <aside class="file-tree-container gl-mr-5 gl-mb-5"> + <div + v-gl-tooltip + :title="ciConfigPath" + class="gl-bg-gray-50 gl-py-2 gl-px-3 gl-mb-3 gl-rounded-base" + > + <span class="file-row-name gl-str-truncated" :title="ciConfigPath"> + <file-icon class="file-row-icon" :file-name="ciConfigPath" /> + <span data-testid="current-config-filename">{{ ciConfigPath }}</span> + </span> + </div> + <gl-alert + v-if="showTip" + variant="tip" + :title="$options.i18n.tipTitle" + :secondary-button-text="$options.i18n.tipBtn" + :secondary-button-link="includesHelpPagePath" + @dismiss="dismissTip" + > + {{ $options.i18n.tipDescription }} + </gl-alert> + <div class="gl-overflow-y-auto"> + <file-item v-for="file in includes" :key="file.location" :file="file" /> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue new file mode 100644 index 00000000000..786d483b5b9 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue @@ -0,0 +1,45 @@ +<script> +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; + +export default { + name: 'PipelineEditorFileItem', + components: { + FileIcon, + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + fileName() { + return this.file.location; + }, + filePath() { + return this.file.blob || this.file.raw; + }, + }, +}; +</script> +<template> + <gl-link + v-gl-tooltip + :href="filePath" + :title="fileName" + target="_blank" + class="file-tree-includes-link gl-display-flex gl-justify-content-space-between gl-hover-bg-gray-50 gl-text-body gl-hover-text-gray-900 gl-hover-text-decoration-none gl-py-2 gl-px-3 gl-rounded-base" + > + <span class="file-row-name gl-str-truncated" :title="fileName"> + <file-icon class="file-row-icon" :file-name="fileName" /> + <span>{{ fileName }}</span> + </span> + <gl-icon class="gl-display-none gl-relative gl-text-gray-500" name="external-link" /> + </gl-link> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue new file mode 100644 index 00000000000..ec6ee52b6b2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue @@ -0,0 +1,70 @@ +<script> +import PipelineStatus from './pipeline_status.vue'; +import ValidationSegment from './validation_segment.vue'; + +const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; + +const pipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-border-b-0!', + 'gl-rounded-top-base', +]; + +const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; + +const validationSegmentWithPipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-rounded-bottom-left-base', + 'gl-rounded-bottom-right-base', +]; + +export default { + pipelineStatusClasses, + validationSegmentClasses, + validationSegmentWithPipelineStatusClasses, + components: { + PipelineStatus, + ValidationSegment, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + commitSha: { + type: String, + required: false, + default: '', + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, + }, + computed: { + showPipelineStatus() { + return !this.isNewCiConfigFile; + }, + // make sure corners are rounded correctly depending on if + // pipeline status is rendered + validationStyling() { + return this.showPipelineStatus + ? this.$options.validationSegmentWithPipelineStatusClasses + : this.$options.validationSegmentClasses; + }, + }, +}; +</script> +<template> + <div class="gl-mb-5"> + <pipeline-status + v-if="showPipelineStatus" + :commit-sha="commitSha" + :class="$options.pipelineStatusClasses" + v-on="$listeners" + /> + <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue new file mode 100644 index 00000000000..feadc60a22a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -0,0 +1,92 @@ +<script> +import { __ } from '~/locale'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import { PIPELINE_FAILURE } from '../../constants'; + +export default { + i18n: { + linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'), + }, + components: { + PipelineMiniGraph, + }, + inject: ['projectFullPath'], + props: { + pipeline: { + type: Object, + required: true, + }, + }, + apollo: { + linkedPipelines: { + query: getLinkedPipelinesQuery, + variables() { + return { + fullPath: this.projectFullPath, + iid: this.pipeline.iid, + }; + }, + skip() { + return !this.pipeline.iid; + }, + update({ project }) { + return project?.pipeline; + }, + error() { + this.$emit('showError', { + type: PIPELINE_FAILURE, + reasons: [this.$options.i18n.linkedPipelinesFetchError], + }); + }, + }, + }, + computed: { + downstreamPipelines() { + return this.linkedPipelines?.downstream?.nodes || []; + }, + hasPipelineStages() { + return this.pipelineStages.length > 0; + }, + pipelinePath() { + return this.pipeline.detailedStatus?.detailsPath || ''; + }, + pipelineStages() { + const stages = this.pipeline.stages?.edges; + if (!stages) { + return []; + } + + return stages.map(({ node }) => { + const { name, detailedStatus } = node; + return { + // TODO: fetch dropdown_path from graphql when available + // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585 + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus.hasDetails, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus.text}`, + }; + }); + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, + }, +}; +</script> + +<template> + <pipeline-mini-graph + v-if="hasPipelineStages" + :downstream-pipelines="downstreamPipelines" + :pipeline-path="pipelinePath" + :stages="pipelineStages" + :upstream-pipeline="upstreamPipeline" + /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue new file mode 100644 index 00000000000..372f04075ab --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -0,0 +1,188 @@ +<script> +import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; + +const POLL_INTERVAL = 10000; +export const i18n = { + fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'), + fetchLoading: s__('Pipeline|Checking pipeline status'), + pipelineInfo: s__( + `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, + ), + viewBtn: s__('Pipeline|View pipeline'), + viewCommit: s__('Pipeline|View commit'), +}; + +export default { + i18n, + components: { + CiIcon, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + PipelineEditorMiniGraph, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectFullPath'], + props: { + commitSha: { + type: String, + required: false, + default: '', + }, + }, + apollo: { + pipelineEtag: { + query: getPipelineEtag, + update(data) { + return data.etags?.pipeline; + }, + }, + pipeline: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineQuery, + variables() { + return { + fullPath: this.projectFullPath, + sha: this.commitSha, + }; + }, + update(data) { + const { id, iid, commit = {}, detailedStatus = {}, stages, status } = + data.project?.pipeline || {}; + + return { + id, + iid, + commit, + detailedStatus, + stages, + status, + }; + }, + result(res) { + if (res.data?.project?.pipeline) { + this.hasError = false; + } + }, + error() { + this.hasError = true; + }, + pollInterval: POLL_INTERVAL, + }, + }, + data() { + return { + hasError: false, + }; + }, + computed: { + commitText() { + const shortSha = truncateSha(this.commitSha); + const commitTitle = this.pipeline.commit.title || ''; + + if (commitTitle.length > 0) { + return `${shortSha}: ${commitTitle}`; + } + + return shortSha; + }, + hasPipelineData() { + return Boolean(this.pipeline?.id); + }, + pipelineId() { + return getIdFromGraphQLId(this.pipeline.id); + }, + showLoadingState() { + // the query is set to poll regularly, so if there is no pipeline data + // (e.g. pipeline is null during fetch when the pipeline hasn't been + // triggered yet), we can just show the loading state until the pipeline + // details are ready to be fetched + return ( + this.$apollo.queries.pipeline.loading || + this.commitSha.length === 0 || + (!this.hasPipelineData && !this.hasError) + ); + }, + shortSha() { + return truncateSha(this.commitSha); + }, + status() { + return this.pipeline.detailedStatus; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL); + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap"> + <template v-if="showLoadingState"> + <div> + <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> + <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + </div> + </template> + <template v-else-if="hasError"> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </template> + <template v-else> + <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> + <a :href="status.detailsPath" class="gl-mr-auto"> + <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> + </a> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="$options.i18n.pipelineInfo"> + <template #id="{ content }"> + <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content"> + {{ content }}{{ pipelineId }} + </span> + </template> + <template #status>{{ status.text }}</template> + <template #commit> + <gl-link + v-gl-tooltip.hover + :href="pipeline.commit.webPath" + :title="$options.i18n.viewCommit" + data-testid="pipeline-commit" + > + {{ commitText }} + </gl-link> + </template> + </gl-sprintf> + </span> + </div> + <div class="gl-display-flex gl-flex-wrap"> + <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> + <gl-button + class="gl-ml-3" + category="secondary" + variant="confirm" + :href="status.detailsPath" + data-testid="pipeline-view-btn" + > + {{ $options.i18n.viewBtn }} + </gl-button> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue new file mode 100644 index 00000000000..84c0eef441f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -0,0 +1,126 @@ +<script> +import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '../../constants'; + +export const i18n = { + empty: __( + "We'll continuously validate your pipeline configuration. The validation results will appear here.", + ), + learnMore: __('Learn more'), + loading: s__('Pipelines|Validating GitLab CI configurationโฆ'), + invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), + invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'), + unavailableValidation: s__('Pipelines|Configuration validation currently not available.'), + valid: s__('Pipelines|Pipeline syntax is correct.'), +}; + +export default { + i18n, + components: { + GlIcon, + GlLink, + GlLoadingIcon, + TooltipOnTruncate, + }, + inject: { + lintUnavailableHelpPagePath: { + default: '', + }, + ymlHelpPagePath: { + default: '', + }, + }, + props: { + ciConfig: { + type: Object, + required: false, + default: () => ({}), + }, + }, + apollo: { + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + }, + computed: { + helpPath() { + return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath; + }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isLintUnavailable() { + return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; + }, + isValid() { + return this.appStatus === EDITOR_APP_STATUS_VALID; + }, + icon() { + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return 'check'; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return 'time-out'; + case EDITOR_APP_STATUS_VALID: + return 'check'; + default: + return 'warning-solid'; + } + }, + message() { + const [reason] = this.ciConfig?.errors || []; + + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return this.$options.i18n.empty; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return this.$options.i18n.unavailableValidation; + case EDITOR_APP_STATUS_VALID: + return this.$options.i18n.valid; + default: + // Only display first error as a reason + return this.ciConfig?.errors?.length > 0 + ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false) + : this.$options.i18n.invalid; + } + }, + }, +}; +</script> + +<template> + <div> + <template v-if="isLoading"> + <gl-loading-icon size="sm" inline /> + {{ $options.i18n.loading }} + </template> + + <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full"> + <tooltip-on-truncate :title="message" class="gl-text-truncate"> + <gl-icon :name="icon" /> + <span data-qa-selector="validation_message_content" data-testid="validationMsg"> + {{ message }} + </span> + </tooltip-on-truncate> + <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2"> + <gl-link data-testid="learnMoreLink" :href="helpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue new file mode 100644 index 00000000000..0f19b9386e6 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue @@ -0,0 +1,154 @@ +<script> +import { GlAlert, GlLink, GlSprintf, GlTableLite } from '@gitlab/ui'; +import { __ } from '~/locale'; +import CiLintResultsParam from './ci_lint_results_param.vue'; +import CiLintResultsValue from './ci_lint_results_value.vue'; +import CiLintWarnings from './ci_lint_warnings.vue'; + +const thBorderColor = 'gl-border-gray-100!'; + +export default { + correct: { + variant: 'success', + text: __('Syntax is correct.'), + }, + incorrect: { + variant: 'danger', + text: __('Syntax is incorrect.'), + }, + includesText: __( + 'CI configuration validated, including all configuration added with the %{codeStart}include%{codeEnd} keyword. %{link}', + ), + warningTitle: __('The form contains the following warning:'), + fields: [ + { + key: 'parameter', + label: __('Parameter'), + thClass: thBorderColor, + }, + { + key: 'value', + label: __('Value'), + thClass: thBorderColor, + }, + ], + components: { + GlAlert, + GlLink, + GlSprintf, + GlTableLite, + CiLintWarnings, + CiLintResultsValue, + CiLintResultsParam, + }, + props: { + errors: { + type: Array, + required: false, + default: () => [], + }, + dryRun: { + type: Boolean, + required: false, + default: false, + }, + hideAlert: { + type: Boolean, + required: false, + default: false, + }, + isValid: { + type: Boolean, + required: true, + }, + jobs: { + type: Array, + required: false, + default: () => [], + }, + lintHelpPagePath: { + type: String, + required: false, + default: '', + }, + warnings: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + isWarningDismissed: false, + }; + }, + computed: { + status() { + return this.isValid ? this.$options.correct : this.$options.incorrect; + }, + shouldShowTable() { + return this.errors.length === 0; + }, + shouldShowError() { + return this.errors.length > 0; + }, + shouldShowWarning() { + return this.warnings.length > 0 && !this.isWarningDismissed; + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="!hideAlert" + class="gl-mb-5" + :variant="status.variant" + :title="__('Status:')" + :dismissible="false" + data-testid="ci-lint-status" + >{{ status.text }} + <gl-sprintf :message="$options.includesText"> + <template #code="{ content }"> + <code> + {{ content }} + </code> + </template> + <template #link> + <gl-link :href="lintHelpPagePath" target="_blank"> + {{ __('More information') }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <pre + v-if="shouldShowError" + class="gl-mb-5" + data-testid="ci-lint-errors" + ><div v-for="error in errors" :key="error">{{ error }}</div></pre> + + <ci-lint-warnings + v-if="shouldShowWarning" + :warnings="warnings" + data-testid="ci-lint-warnings" + @dismiss="isWarningDismissed = true" + /> + + <gl-table-lite + v-if="shouldShowTable" + :items="jobs" + :fields="$options.fields" + bordered + data-testid="ci-lint-table" + > + <template #cell(parameter)="{ item }"> + <ci-lint-results-param :stage="item.stage" :job-name="item.name" /> + </template> + <template #cell(value)="{ item }"> + <ci-lint-results-value :item="item" :dry-run="dryRun" /> + </template> + </gl-table-lite> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue new file mode 100644 index 00000000000..49225a7cac7 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue @@ -0,0 +1,26 @@ +<script> +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; + +export default { + props: { + stage: { + type: String, + required: true, + }, + jobName: { + type: String, + required: true, + }, + }, + computed: { + formatParameter() { + return __(`${capitalizeFirstCharacter(this.stage)} Job - ${this.jobName}`); + }, + }, +}; +</script> + +<template> + <span data-testid="ci-lint-parameter">{{ formatParameter }}</span> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue new file mode 100644 index 00000000000..ef2be2a5fba --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue @@ -0,0 +1,89 @@ +<script> +import { isEmpty } from 'lodash'; + +export default { + props: { + item: { + type: Object, + required: true, + }, + dryRun: { + type: Boolean, + required: true, + }, + }, + computed: { + tagList() { + return this.item.tags?.join(', '); + }, + onlyPolicy() { + return this.item.only ? this.item.only.refs.join(', ') : this.item.only; + }, + exceptPolicy() { + return this.item.except ? this.item.except.refs.join(', ') : this.item.except; + }, + scripts() { + return { + beforeScript: { + show: !isEmpty(this.item.beforeScript), + content: this.item.beforeScript?.join('\n'), + }, + script: { + show: !isEmpty(this.item.script), + content: this.item.script?.join('\n'), + }, + afterScript: { + show: !isEmpty(this.item.afterScript), + content: this.item.afterScript?.join('\n'), + }, + }; + }, + }, +}; +</script> + +<template> + <div data-testid="ci-lint-value"> + <pre + v-if="scripts.beforeScript.show" + class="gl-white-space-pre-wrap" + data-testid="ci-lint-before-script" + >{{ scripts.beforeScript.content }}</pre + > + <pre v-if="scripts.script.show" class="gl-white-space-pre-wrap" data-testid="ci-lint-script">{{ + scripts.script.content + }}</pre> + <pre + v-if="scripts.afterScript.show" + class="gl-white-space-pre-wrap" + data-testid="ci-lint-after-script" + >{{ scripts.afterScript.content }}</pre + > + + <ul class="gl-list-style-none gl-pl-0 gl-mb-0"> + <li v-if="tagList"> + <b>{{ __('Tag list:') }}</b> + {{ tagList }} + </li> + <div v-if="!dryRun" data-testid="ci-lint-only-except"> + <li v-if="onlyPolicy"> + <b>{{ __('Only policy:') }}</b> + {{ onlyPolicy }} + </li> + <li v-if="exceptPolicy"> + <b>{{ __('Except policy:') }}</b> + {{ exceptPolicy }} + </li> + </div> + <li v-if="item.environment"> + <b>{{ __('Environment:') }}</b> + {{ item.environment }} + </li> + <li v-if="item.when"> + <b>{{ __('When:') }}</b> + {{ item.when }} + <b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue new file mode 100644 index 00000000000..ac0332cb0bd --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue @@ -0,0 +1,69 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; + +export default { + maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), + components: { + GlAlert, + GlSprintf, + }, + props: { + warnings: { + type: Array, + required: true, + }, + maxWarnings: { + type: Number, + required: false, + default: 25, + }, + title: { + type: String, + required: false, + default: __('The form contains the following warning:'), + }, + }, + computed: { + totalWarnings() { + return this.warnings.length; + }, + overMaxWarningsLimit() { + return this.totalWarnings > this.maxWarnings; + }, + warningsSummary() { + return n__('%d warning found:', '%d warnings found:', this.totalWarnings); + }, + summaryMessage() { + return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary; + }, + limitWarnings() { + return this.warnings.slice(0, this.maxWarnings); + }, + }, +}; +</script> + +<template> + <gl-alert class="gl-mb-4" :title="title" variant="warning" @dismiss="$emit('dismiss')"> + <details> + <summary> + <gl-sprintf :message="summaryMessage"> + <template #total> + {{ totalWarnings }} + </template> + <template #warningsDisplayed> + {{ maxWarnings }} + </template> + </gl-sprintf> + </summary> + <p + v-for="(warning, index) in limitWarnings" + :key="`warning-${index}`" + data-testid="ci-lint-warning" + > + {{ warning }} + </p> + </details> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue new file mode 100644 index 00000000000..ed5466ff99c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -0,0 +1,235 @@ +<script> +import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import { + CREATE_TAB, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + MERGED_TAB, + TAB_QUERY_PARAM, + TABS_INDEX, + VALIDATE_TAB, + VALIDATE_TAB_BADGE_DISMISSED_KEY, + VISUALIZE_TAB, +} from '../constants'; +import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; +import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; +import CiEditorHeader from './editor/ci_editor_header.vue'; +import CiValidate from './validate/ci_validate.vue'; +import TextEditor from './editor/text_editor.vue'; +import EditorTab from './ui/editor_tab.vue'; +import WalkthroughPopover from './popovers/walkthrough_popover.vue'; + +export default { + i18n: { + new: __('NEW'), + tabEdit: s__('Pipelines|Edit'), + tabGraph: s__('Pipelines|Visualize'), + tabLint: s__('Pipelines|Lint'), + tabMergedYaml: s__('Pipelines|View merged YAML'), + tabValidate: s__('Pipelines|Validate'), + empty: { + visualization: s__( + 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.', + ), + lint: s__( + 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.', + ), + merge: s__( + 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.', + ), + }, + }, + errorTexts: { + loadMergedYaml: s__('Pipelines|Could not load merged YAML content'), + }, + query: { + TAB_QUERY_PARAM, + }, + tabConstants: { + CREATE_TAB, + MERGED_TAB, + VALIDATE_TAB, + VISUALIZE_TAB, + }, + components: { + CiConfigMergedPreview, + CiEditorHeader, + CiValidate, + EditorTab, + GlAlert, + GlLoadingIcon, + GlTabs, + PipelineGraph, + TextEditor, + WalkthroughPopover, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, + commitSha: { + type: String, + required: false, + default: '', + }, + currentTab: { + type: String, + required: true, + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, + showDrawer: { + type: Boolean, + required: true, + }, + }, + apollo: { + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + }, + data() { + return { + showValidateNewBadge: false, + }; + }, + computed: { + isMergedYamlAvailable() { + return this.ciConfigData?.mergedYaml; + }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isInvalid() { + return this.appStatus === EDITOR_APP_STATUS_INVALID; + }, + isLintUnavailable() { + return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE; + }, + isValid() { + return this.appStatus === EDITOR_APP_STATUS_VALID; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; + }, + validateTabBadgeTitle() { + if (this.showValidateNewBadge) { + return this.$options.i18n.new; + } + + return ''; + }, + }, + mounted() { + this.showValidateNewBadge = !JSON.parse(localStorage.getItem(VALIDATE_TAB_BADGE_DISMISSED_KEY)); + }, + created() { + const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM); + const tabName = Object.keys(TABS_INDEX)[tabQueryParam]; + + if (tabName) { + this.setDefaultTab(tabName); + } + }, + methods: { + setCurrentTab(tabName) { + if (this.currentTab === VALIDATE_TAB) { + localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true'); + this.showValidateNewBadge = false; + } + + this.$emit('set-current-tab', tabName); + }, + setDefaultTab(tabName) { + // We associate tab name with the index so that we can use tab name + // in other part of the app and load the corresponding tab closer to the + // actual component using a hash that binds the name to the indexes. + // This also means that if we ever changed tab order, we would justs need to + // update `TABS_INDEX` hash instead of all the instances in the app + // where we used the individual indexes + const newUrl = setUrlParams({ [TAB_QUERY_PARAM]: TABS_INDEX[tabName] }); + + this.setCurrentTab(tabName); + updateHistory({ url: newUrl, title: document.title, replace: true }); + }, + }, +}; +</script> +<template> + <gl-tabs + class="file-editor gl-mb-3" + data-qa-selector="file_editor_container" + :query-param-name="$options.query.TAB_QUERY_PARAM" + sync-active-tab-with-query-params + > + <editor-tab + class="gl-mb-3" + title-link-class="js-walkthrough-popover-target" + :title="$options.i18n.tabEdit" + lazy + data-testid="editor-tab" + @click="setCurrentTab($options.tabConstants.CREATE_TAB)" + > + <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> + <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" /> + <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> + </editor-tab> + <editor-tab + class="gl-mb-3" + :empty-message="$options.i18n.empty.visualization" + :is-empty="isEmpty" + :is-invalid="isInvalid" + :is-unavailable="isLintUnavailable" + :keep-component-mounted="false" + :title="$options.i18n.tabGraph" + lazy + data-testid="visualization-tab" + @click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)" + > + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <pipeline-graph v-else :pipeline-data="ciConfigData" /> + </editor-tab> + <editor-tab + class="gl-mb-3" + data-testid="validate-tab" + :badge-title="validateTabBadgeTitle" + :title="$options.i18n.tabValidate" + @click="setCurrentTab($options.tabConstants.VALIDATE_TAB)" + > + <ci-validate :ci-file-content="ciFileContent" /> + </editor-tab> + <editor-tab + class="gl-mb-3" + :empty-message="$options.i18n.empty.merge" + :keep-component-mounted="false" + :is-empty="isEmpty" + :is-unavailable="isLintUnavailable" + :title="$options.i18n.tabMergedYaml" + lazy + data-testid="merged-tab" + @click="setCurrentTab($options.tabConstants.MERGED_TAB)" + > + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <gl-alert v-else-if="!isMergedYamlAvailable" variant="danger" :dismissible="false"> + {{ $options.errorTexts.loadMergedYaml }} + </gl-alert> + <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" /> + </editor-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue new file mode 100644 index 00000000000..efa6a54c638 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue @@ -0,0 +1,56 @@ +<script> +import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { FILE_TREE_POPOVER_DISMISSED_KEY } from '../../constants'; + +export default { + name: 'PipelineEditorFileTreePopover', + directives: { Outside }, + i18n: { + description: s__( + 'pipelineEditorWalkthrough|You can use the file tree to view your pipeline configuration files. %{linkStart}Learn more%{linkEnd}', + ), + }, + components: { + GlLink, + GlPopover, + GlSprintf, + }, + inject: ['includesHelpPagePath'], + data() { + return { + showPopover: false, + }; + }, + mounted() { + this.showPopover = localStorage.getItem(FILE_TREE_POPOVER_DISMISSED_KEY) !== 'true'; + }, + methods: { + dismissPermanently() { + this.showPopover = false; + localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true'); + }, + }, +}; +</script> + +<template> + <gl-popover + v-if="showPopover" + show + show-close-button + target="file-tree-toggle" + triggers="manual" + placement="right" + data-qa-selector="file_tree_popover" + @close-button-clicked="dismissPermanently" + > + <div v-outside="dismissPermanently" class="gl-font-base gl-mb-3"> + <gl-sprintf :message="$options.i18n.description"> + <template #link="{ content }"> + <gl-link :href="includesHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue new file mode 100644 index 00000000000..4730a521227 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue @@ -0,0 +1,72 @@ +<script> +import { GlLink, GlPopover, GlOutsideDirective as Outside, GlSprintf } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { VALIDATE_TAB_FEEDBACK_URL } from '../../constants'; + +export const i18n = { + feedbackLink: __('Provide Feedback'), + popoverContent: s__( + 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}', + ), + title: s__('PipelineEditor|Validate pipeline under simulated conditions'), +}; + +export default { + name: 'ValidatePipelinePopover', + directives: { Outside }, + components: { + GlLink, + GlPopover, + GlSprintf, + }, + inject: ['simulatePipelineHelpPagePath'], + data() { + return { + showPopover: false, + }; + }, + methods: { + dismiss() { + this.showPopover = false; + }, + }, + i18n, + VALIDATE_TAB_FEEDBACK_URL, +}; +</script> + +<template> + <gl-popover + :show.sync="showPopover" + target="validate-pipeline-help" + triggers="hover focus" + placement="top" + > + <p class="gl-my-3 gl-font-weight-bold">{{ $options.i18n.title }}</p> + <p> + <gl-sprintf :message="$options.i18n.popoverContent"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link + class="gl-font-sm" + target="_blank" + :href="simulatePipelineHelpPagePath" + data-testid="help-link" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </p> + <p class="gl-text-right gl-mb-3"> + <gl-link + class="gl-font-sm" + target="_blank" + :href="$options.VALIDATE_TAB_FEEDBACK_URL" + data-testid="feedback-link" + >{{ $options.i18n.feedbackLink }}</gl-link + > + </p> + </gl-popover> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue new file mode 100644 index 00000000000..c636d8b8e34 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue @@ -0,0 +1,83 @@ +<script> +import { GlButton, GlPopover, GlSprintf, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + directives: { Outside }, + i18n: { + title: s__('pipelineEditorWalkthrough|See how GitLab pipelines work'), + description: s__( + 'pipelineEditorWalkthrough|This %{codeStart}.gitlab-ci.yml%{codeEnd} file creates a simple test pipeline.', + ), + instruction: s__( + 'pipelineEditorWalkthrough|Use the %{boldStart}commit changes%{boldEnd} button at the bottom of the page to run the pipeline.', + ), + ctaText: s__("pipelineEditorWalkthrough|Let's do this!"), + }, + components: { + GlButton, + GlPopover, + GlSprintf, + }, + data() { + return { + show: true, + }; + }, + computed: { + targetElement() { + return document.querySelector('.js-walkthrough-popover-target'); + }, + }, + methods: { + close() { + this.show = false; + }, + handleClickCta() { + this.close(); + this.$emit('walkthrough-popover-cta-clicked'); + }, + }, +}; +</script> + +<template> + <gl-popover + :show.sync="show" + :title="$options.i18n.title" + :target="targetElement" + placement="right" + triggers="focus" + > + <div v-outside="close" class="gl-display-flex gl-flex-direction-column"> + <p> + <gl-sprintf :message="$options.i18n.description"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <p> + <gl-sprintf :message="$options.i18n.instruction"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </p> + + <gl-button + class="gl-align-self-end" + category="tertiary" + data-testid="ctaBtn" + variant="confirm" + @click="handleClickCta" + > + <gl-emoji data-name="rocket" /> + {{ $options.i18n.ctaText }} + </gl-button> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue new file mode 100644 index 00000000000..bc076fbe349 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue @@ -0,0 +1,26 @@ +<script> +export default { + props: { + hasUnsavedChanges: { + type: Boolean, + required: true, + }, + }, + created() { + window.addEventListener('beforeunload', this.confirmChanges); + }, + destroyed() { + window.removeEventListener('beforeunload', this.confirmChanges); + }, + methods: { + confirmChanges(e = {}) { + if (this.hasUnsavedChanges) { + e.preventDefault(); + // eslint-disable-next-line no-param-reassign + e.returnValue = ''; // Chrome requires returnValue to be set + } + }, + }, + render: () => null, +}; +</script> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue new file mode 100644 index 00000000000..22b82f2e96f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue @@ -0,0 +1,156 @@ +<script> +import { GlAlert, GlBadge, GlTab } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +/** + * Wrapper of <gl-tab> to optionally lazily render this tab's content + * when its shown **without dismounting after its hidden**. + * + * Usage: + * + * API is the same as <gl-tab>, for example: + * + * <gl-tabs> + * <editor-tab title="Tab 1" lazy> + * lazily mounted content (gets mounted if this is first tab) + * </editor-tab> + * <editor-tab title="Tab 2" lazy> + * lazily mounted content + * </editor-tab> + * <editor-tab title="Tab 3"> + * eagerly mounted content + * </editor-tab> + * </gl-tabs> + * + * Once the tab is selected it is permanently set as "not-lazy" + * so it's contents are not dismounted. + * + * lazy is "false" by default, as in <gl-tab>. + * + * It is also possible to pass the `isEmpty` and or `isInvalid` to let + * the tab component handle that state on its own. For example: + * + * * <gl-tabs> + * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid"> + * ... + * </editor-tab-with-status> + * Will be the same as normal, except it will only render the slot component + * if the status is not empty and not invalid. In any of these 2 cases, it will render + * a generic component and avoid mounting whatever it received in the slot. + * </gl-tabs> + */ + +export default { + i18n: { + invalid: __( + 'Your CI/CD configuration syntax is invalid. Select the Validate tab for more details.', + ), + unavailable: __( + "We're experiencing difficulties and this tab content is currently unavailable.", + ), + }, + components: { + GlAlert, + GlBadge, + GlTab, + // Use a small renderless component to know when the tab content mounts because: + // - gl-tab always gets mounted, even if lazy is `true`. See: + // https://github.com/bootstrap-vue/bootstrap-vue/blob/dev/src/components/tabs/tab.js#L180 + // - we cannot listen to events on <slot /> + MountSpy: { + render: () => null, + }, + }, + inheritAttrs: false, + props: { + badgeTitle: { + type: String, + required: false, + default: '', + }, + badgeVariant: { + type: String, + required: false, + default: 'info', + }, + emptyMessage: { + type: String, + required: false, + default: s__( + 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.', + ), + }, + isEmpty: { + type: Boolean, + required: false, + default: false, + }, + isInvalid: { + type: Boolean, + required: false, + default: false, + }, + isUnavailable: { + type: Boolean, + required: false, + default: false, + }, + keepComponentMounted: { + type: Boolean, + required: false, + default: true, + }, + lazy: { + type: Boolean, + required: false, + default: false, + }, + title: { + type: String, + required: true, + }, + }, + data() { + return { + isLazy: this.lazy, + }; + }, + computed: { + hasBadgeTitle() { + return this.badgeTitle.length > 0; + }, + slots() { + // eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots + return Object.keys(this.$slots); + }, + }, + methods: { + onContentMounted() { + // When a child is first mounted make the entire tab + // permanently mounted by setting 'lazy' to false unless + // explicitly opted out. + if (this.keepComponentMounted) { + this.isLazy = false; + } + }, + }, +}; +</script> +<template> + <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners"> + <template #title> + <span>{{ title }}</span> + <gl-badge v-if="hasBadgeTitle" class="gl-ml-2" size="sm" :variant="badgeVariant">{{ + badgeTitle + }}</gl-badge> + </template> + <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert> + <gl-alert v-else-if="isUnavailable" variant="danger" :dismissible="false"> + {{ $options.i18n.unavailable }}</gl-alert + > + <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert> + <template v-else> + <slot v-for="slot in slots" :name="slot"></slot> + <mount-spy @hook:mounted="onContentMounted" /> + </template> + </gl-tab> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue new file mode 100644 index 00000000000..d7b8e7151d9 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -0,0 +1,72 @@ +<script> +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; + +export default { + components: { + GlButton, + GlSprintf, + PipelineEditorFileNav, + }, + i18n: { + title: __('Optimize your workflow with CI/CD Pipelines'), + body: __( + 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.', + ), + btnText: __('Configure pipeline'), + externalCiNote: __("This project's pipeline configuration is located outside this repository"), + externalCiInstructions: __( + 'To edit the pipeline configuration, you must go to the project or external site that hosts the file.', + ), + }, + inject: { + emptyStateIllustrationPath: { + default: '', + }, + usesExternalConfig: { + default: false, + type: Boolean, + required: false, + }, + }, + methods: { + createEmptyConfigFile() { + this.$emit('createEmptyConfigFile'); + }, + }, +}; +</script> +<template> + <div> + <pipeline-editor-file-nav v-on="$listeners" /> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <div + v-if="usesExternalConfig" + class="gl-display-flex gl-flex-direction-column gl-align-items-center" + > + <h1 class="gl-font-size-h1">{{ $options.i18n.externalCiNote }}</h1> + <p class="gl-mt-3">{{ $options.i18n.externalCiInstructions }}</p> + </div> + <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-mt-3"> + <gl-sprintf :message="$options.i18n.body"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <gl-button + variant="confirm" + class="gl-mt-3" + data-qa-selector="create_new_ci_button" + @click="createEmptyConfigFile" + > + {{ $options.i18n.btnText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue new file mode 100644 index 00000000000..c72cff4c6f8 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -0,0 +1,145 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + COMMIT_SUCCESS_WITH_REDIRECT, + DEFAULT_FAILURE, + DEFAULT_SUCCESS, + LOAD_FAILURE_UNKNOWN, + PIPELINE_FAILURE, +} from '../../constants'; +import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue'; +import { + CODE_SNIPPET_SOURCE_URL_PARAM, + CODE_SNIPPET_SOURCES, +} from '../code_snippet_alert/constants'; + +export default { + components: { + GlAlert, + CodeSnippetAlert, + }, + + errors: { + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + [DEFAULT_FAILURE]: __('Something went wrong on our end.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + [PIPELINE_FAILURE]: s__('Pipelines|There was a problem with loading the pipeline data.'), + }, + success: { + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + [COMMIT_SUCCESS_WITH_REDIRECT]: s__( + 'Pipelines|Your changes have been successfully committed. Now redirecting to the new merge request page.', + ), + [DEFAULT_SUCCESS]: __('Your action succeeded.'), + }, + props: { + failureType: { + type: String, + required: false, + default: null, + }, + failureReasons: { + type: Array, + required: false, + default: () => [], + }, + showFailure: { + type: Boolean, + required: false, + default: false, + }, + showSuccess: { + type: Boolean, + required: false, + default: false, + }, + successType: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + codeSnippetCopiedFrom: '', + }; + }, + computed: { + failure() { + const { errors } = this.$options; + + return { + text: errors[this.failureType] ?? errors[DEFAULT_FAILURE], + variant: 'danger', + }; + }, + success() { + const { success } = this.$options; + + return { + text: success[this.successType] ?? success[DEFAULT_SUCCESS], + variant: 'info', + }; + }, + }, + created() { + this.parseCodeSnippetSourceParam(); + }, + methods: { + dismissCodeSnippetAlert() { + this.codeSnippetCopiedFrom = ''; + }, + dismissFailure() { + this.$emit('hide-failure'); + }, + dismissSuccess() { + this.$emit('hide-success'); + }, + parseCodeSnippetSourceParam() { + const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); + if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { + this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; + window.history.replaceState( + {}, + document.title, + removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), + ); + } + }, + }, +}; +</script> + +<template> + <div> + <code-snippet-alert + v-if="codeSnippetCopiedFrom" + :source="codeSnippetCopiedFrom" + class="gl-mb-5" + @dismiss="dismissCodeSnippetAlert" + /> + <gl-alert + v-if="showSuccess" + :variant="success.variant" + class="gl-mb-5" + @dismiss="dismissSuccess" + > + {{ success.text }} + </gl-alert> + <gl-alert + v-if="showFailure" + :variant="failure.variant" + class="gl-mb-5" + @dismiss="dismissFailure" + > + {{ failure.text }} + <ul v-if="failureReasons.length" class="gl-mb-0"> + <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> + </ul> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue new file mode 100644 index 00000000000..83fcab4b343 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue @@ -0,0 +1,301 @@ +<script> +import { + GlAlert, + GlButton, + GlDropdown, + GlIcon, + GlLoadingIcon, + GlLink, + GlTooltip, + GlTooltipDirective, + GlSprintf, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import { pipelineEditorTrackingOptions } from '../../constants'; +import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue'; +import CiLintResults from '../lint/ci_lint_results.vue'; +import getBlobContent from '../../graphql/queries/blob_content.query.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; +import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql'; + +export const i18n = { + alertDesc: s__( + 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}', + ), + cancelBtn: __('Cancel'), + contentChange: s__( + 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.', + ), + cta: s__('PipelineEditor|Validate pipeline'), + ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'), + errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'), + help: __('Help'), + loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'), + pipelineSource: s__('PipelineEditor|Pipeline Source'), + pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'), + pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'), + title: s__('PipelineEditor|Validate pipeline under selected conditions'), + contentNote: s__( + 'PipelineEditor|Current content in the Edit tab will be used for the simulation.', + ), + simulationNote: s__( + 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.', + ), + successAlertTitle: s__('PipelineEditor|Simulation completed successfully'), +}; + +export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT'; +export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS'; +export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING'; +const BASE_CLASSES = [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-align-items-center', + 'gl-mt-11', +]; + +export default { + name: 'CiValidateTab', + components: { + CiLintResults, + GlAlert, + GlButton, + GlDropdown, + GlIcon, + GlLoadingIcon, + GlLink, + GlSprintf, + GlTooltip, + ValidatePipelinePopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin()], + inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'], + props: { + ciFileContent: { + type: String, + required: true, + }, + }, + apollo: { + initialBlobContent: { + query: getBlobContent, + variables() { + return { + projectPath: this.projectFullPath, + path: this.ciConfigPath, + ref: this.currentBranch, + }; + }, + update(data) { + return data?.project?.repository?.blobs?.nodes[0]?.rawBlob; + }, + }, + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches?.current?.name; + }, + }, + }, + data() { + return { + yaml: this.ciFileContent, + state: VALIDATE_TAB_INIT, + errors: [], + hasCiContentChanged: false, + isValid: false, + jobs: [], + warnings: [], + }; + }, + computed: { + canResimulatePipeline() { + return this.hasSimulationResults && this.hasCiContentChanged; + }, + isInitialCiContentLoading() { + return this.$apollo.queries.initialBlobContent.loading; + }, + isInitState() { + return this.state === VALIDATE_TAB_INIT; + }, + isSimulationLoading() { + return this.state === VALIDATE_TAB_LOADING; + }, + hasSimulationResults() { + return this.state === VALIDATE_TAB_RESULTS; + }, + resultStatus() { + return { + title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle, + variant: this.isValid ? 'success' : 'danger', + }; + }, + trackingAction() { + const { actions } = pipelineEditorTrackingOptions; + return this.canResimulatePipeline ? actions.resimulatePipeline : actions.simulatePipeline; + }, + }, + watch: { + ciFileContent(value) { + this.yaml = value; + this.hasCiContentChanged = true; + }, + }, + methods: { + cancelSimulation() { + this.state = VALIDATE_TAB_INIT; + }, + trackSimulation() { + const { label } = pipelineEditorTrackingOptions; + this.track(this.trackingAction, { label }); + }, + async validateYaml() { + this.trackSimulation(); + this.state = VALIDATE_TAB_LOADING; + + try { + const { + data: { + lintCI: { errors, jobs, valid, warnings }, + }, + } = await this.$apollo.mutate({ + mutation: lintCiMutation, + variables: { + dry: true, + content: this.yaml, + endpoint: this.ciLintPath, + }, + }); + + // only save the result if the user did not cancel the simulation + if (this.state === VALIDATE_TAB_LOADING) { + this.errors = errors; + this.jobs = jobs; + this.warnings = warnings; + this.isValid = valid; + this.state = VALIDATE_TAB_RESULTS; + this.hasCiContentChanged = false; + } + } catch (error) { + this.cancelSimulation(); + } + }, + }, + i18n, + BASE_CLASSES, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mt-3"> + <div> + <label>{{ $options.i18n.pipelineSource }}</label> + <gl-dropdown + v-gl-tooltip.hover + class="gl-ml-3" + :title="$options.i18n.pipelineSourceTooltip" + :text="$options.i18n.pipelineSourceDefault" + disabled + data-testid="pipeline-source" + /> + <validate-pipeline-popover /> + <gl-icon + id="validate-pipeline-help" + name="question-o" + class="gl-ml-1 gl-fill-blue-500" + category="secondary" + variant="confirm" + :aria-label="$options.i18n.help" + /> + </div> + <div v-if="canResimulatePipeline"> + <span class="gl-text-gray-400" data-testid="content-status"> + {{ $options.i18n.contentChange }} + </span> + <gl-button + variant="confirm" + class="gl-ml-2 gl-mb-2" + data-testid="resimulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + </div> + <div v-if="isInitState" :class="$options.BASE_CLASSES"> + <img :src="validateTabIllustrationPath" /> + <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1> + <ul> + <li class="gl-mb-3">{{ $options.i18n.contentNote }}</li> + <li class="gl-mb-3"> + <gl-sprintf :message="$options.i18n.simulationNote"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </li> + </ul> + <div ref="simulatePipelineButton"> + <gl-button + ref="simulatePipelineButton" + variant="confirm" + class="gl-mt-3" + :disabled="isInitialCiContentLoading" + data-testid="simulate-pipeline-button" + data-qa-selector="simulate_pipeline_button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + <gl-tooltip + v-if="isInitialCiContentLoading" + :target="() => $refs.simulatePipelineButton" + :title="$options.i18n.ctaDisabledTooltip" + data-testid="cta-tooltip" + /> + </div> + <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES"> + <gl-loading-icon size="lg" class="gl-m-3" /> + <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1> + <div> + <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation"> + {{ $options.i18n.cancelBtn }} + </gl-button> + <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button"> + {{ $options.i18n.cta }} + </gl-button> + </div> + </div> + <div v-else-if="hasSimulationResults" class="gl-mt-5"> + <gl-alert + class="gl-mb-5" + :dismissible="false" + :title="resultStatus.title" + :variant="resultStatus.variant" + > + <gl-sprintf :message="$options.i18n.alertDesc"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link target="_blank" href="#">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <ci-lint-results + dry-run + hide-alert + :is-valid="isValid" + :jobs="jobs" + :errors="errors" + :warnings="warnings" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js new file mode 100644 index 00000000000..dd25c4d433b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js @@ -0,0 +1,127 @@ +import { s__ } from '~/locale'; + +// Values for CI_CONFIG_STATUS_* comes from lint graphQL +export const CI_CONFIG_STATUS_INVALID = 'INVALID'; +export const CI_CONFIG_STATUS_VALID = 'VALID'; + +// Values for EDITOR_APP_STATUS_* are frontend specifics and +// represent the global state of the pipeline editor app. +export const EDITOR_APP_STATUS_EMPTY = 'EMPTY'; +export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID; +export const EDITOR_APP_STATUS_LINT_UNAVAILABLE = 'LINT_DOWN'; +export const EDITOR_APP_STATUS_LOADING = 'LOADING'; +export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID; + +export const EDITOR_APP_VALID_STATUSES = [ + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +]; + +export const COMMIT_FAILURE = 'COMMIT_FAILURE'; +export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; +export const COMMIT_SUCCESS_WITH_REDIRECT = 'COMMIT_SUCCESS_WITH_REDIRECT'; + +export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS'; +export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; +export const PIPELINE_FAILURE = 'PIPELINE_FAILURE'; + +export const CREATE_TAB = 'CREATE_TAB'; +export const MERGED_TAB = 'MERGED_TAB'; +export const VALIDATE_TAB = 'VALIDATE_TAB'; +export const VISUALIZE_TAB = 'VISUALIZE_TAB'; + +export const TABS_INDEX = { + [CREATE_TAB]: '0', + [VISUALIZE_TAB]: '1', + [VALIDATE_TAB]: '2', + [MERGED_TAB]: '3', +}; +export const TAB_QUERY_PARAM = 'tab'; + +export const COMMIT_ACTION_CREATE = 'CREATE'; +export const COMMIT_ACTION_UPDATE = 'UPDATE'; + +export const BRANCH_PAGINATION_LIMIT = 20; +export const BRANCH_SEARCH_DEBOUNCE = '500'; +export const SOURCE_EDITOR_DEBOUNCE = 500; + +export const FILE_TREE_DISPLAY_KEY = 'pipeline_editor_file_tree_display'; +export const FILE_TREE_POPOVER_DISMISSED_KEY = 'pipeline_editor_file_tree_popover_dismissed'; +export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismissed'; +export const VALIDATE_TAB_BADGE_DISMISSED_KEY = 'pipeline_editor_validate_tab_badge_dismissed'; + +export const STARTER_TEMPLATE_NAME = 'Getting-Started'; + +export const CI_EXAMPLES_LINK = 'CI_EXAMPLES_LINK'; +export const CI_HELP_LINK = 'CI_HELP_LINK'; +export const CI_NEEDS_LINK = 'CI_NEEDS_LINK'; +export const CI_RUNNERS_LINK = 'CI_RUNNERS_LINK'; +export const CI_YAML_LINK = 'CI_YAML_LINK'; + +export const pipelineEditorTrackingOptions = { + label: 'pipeline_editor', + actions: { + browseTemplates: 'browse_templates', + closeHelpDrawer: 'close_help_drawer', + helpDrawerLinks: { + [CI_EXAMPLES_LINK]: 'visit_help_drawer_link_ci_examples', + [CI_HELP_LINK]: 'visit_help_drawer_link_ci_help', + [CI_NEEDS_LINK]: 'visit_help_drawer_link_needs', + [CI_RUNNERS_LINK]: 'visit_help_drawer_link_runners', + [CI_YAML_LINK]: 'visit_help_drawer_link_yaml', + }, + openHelpDrawer: 'open_help_drawer', + resimulatePipeline: 'resimulate_pipeline', + simulatePipeline: 'simulate_pipeline', + }, +}; + +export const TEMPLATE_REPOSITORY_URL = + 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates'; +export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/346687'; + +export const COMMIT_SHA_POLL_INTERVAL = 1000; + +export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section'; +export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked'; +export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked'; +export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked'; +export const I18N = { + title: s__('Pipelines|Get started with GitLab CI/CD'), + runners: { + title: s__('Pipelines|Runners are available to run your jobs now'), + subtitle: s__( + 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.', + ), + }, + noRunners: { + title: s__('Pipelines|No runners detected'), + subtitle: s__( + 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.', + ), + cta: s__('Pipelines|Install GitLab Runner'), + }, + learnBasics: { + title: s__('Pipelines|Learn the basics of pipelines and .yml files'), + subtitle: s__( + 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', + ), + gettingStarted: { + title: s__('Pipelines|"Hello world" with GitLab CI'), + description: s__( + 'Pipelines|Get familiar with GitLab CI syntax by setting up a simple pipeline running a "Hello world" script to see how it runs, explore how CI/CD works.', + ), + cta: s__('Pipelines|Try test template'), + }, + }, + templates: { + title: s__('Pipelines|Ready to set up CI/CD for your project?'), + subtitle: s__( + "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", + ), + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql new file mode 100644 index 00000000000..2d42ebb6ac3 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql @@ -0,0 +1,21 @@ +mutation lintCI($endpoint: String, $content: String, $dry: Boolean) { + lintCI(endpoint: $endpoint, content: $content, dry_run: $dry) @client { + valid + errors + warnings + jobs { + afterScript + allowFailure + beforeScript + environment + except + name + only { + refs + } + stage + tags + when + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql new file mode 100644 index 00000000000..7487e328668 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateAppStatus($appStatus: String) { + updateAppStatus(appStatus: $appStatus) @client +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql new file mode 100644 index 00000000000..b722c147f5f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentBranch($currentBranch: String) { + updateCurrentBranch(currentBranch: $currentBranch) @client +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql new file mode 100644 index 00000000000..9561312f2b6 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateLastCommitBranch($lastCommitBranch: String) { + updateLastCommitBranch(lastCommitBranch: $lastCommitBranch) @client +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql new file mode 100644 index 00000000000..9025f00b343 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql @@ -0,0 +1,3 @@ +mutation updatePipelineEtag($pipelineEtag: String) { + updatePipelineEtag(pipelineEtag: $pipelineEtag) @client +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql new file mode 100644 index 00000000000..3495ca51283 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -0,0 +1,29 @@ +mutation commitCIFile( + $action: CommitActionMode! + $projectPath: ID! + $branch: String! + $startBranch: String + $message: String! + $filePath: String! + $lastCommitId: String! + $content: String +) { + commitCreate( + input: { + projectPath: $projectPath + branch: $branch + startBranch: $startBranch + message: $message + actions: [ + { action: $action, filePath: $filePath, lastCommitId: $lastCommitId, content: $content } + ] + } + ) { + commit { + id + sha + } + commitPipelinePath + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql new file mode 100644 index 00000000000..359b4a846c7 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql @@ -0,0 +1,13 @@ +query getAvailableBranches( + $limit: Int! + $offset: Int! + $projectFullPath: ID! + $searchPattern: String! +) { + project(fullPath: $projectFullPath) { + id + repository { + branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern) + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql new file mode 100644 index 00000000000..5928d90f7c4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql @@ -0,0 +1,13 @@ +query getBlobContent($projectPath: ID!, $path: String!, $ref: String) { + project(fullPath: $projectPath) { + id + repository { + blobs(paths: [$path], ref: $ref) { + nodes { + id + rawBlob + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql new file mode 100644 index 00000000000..5354ed7c2d5 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql @@ -0,0 +1,18 @@ +#import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql" + +query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) { + ciConfig(projectPath: $projectPath, sha: $sha, content: $content) { + errors + includes { + location + type + blob + raw + } + mergedYaml + status + stages { + ...PipelineStagesConnection + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql new file mode 100644 index 00000000000..0df8cafa3cb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql @@ -0,0 +1,5 @@ +query getAppStatus { + app @client { + status + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql new file mode 100644 index 00000000000..1f4f9d26f24 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql @@ -0,0 +1,7 @@ +query getCurrentBranch { + workBranches @client { + current { + name + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql new file mode 100644 index 00000000000..a83129759de --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql @@ -0,0 +1,7 @@ +query getLastCommitBranchQuery { + workBranches @client { + lastCommit { + name + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql new file mode 100644 index 00000000000..8df6e74a5d9 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql @@ -0,0 +1,5 @@ +query getPipelineEtag { + etags @client { + pipeline + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql new file mode 100644 index 00000000000..a34c8f365f4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql @@ -0,0 +1,8 @@ +query getTemplate($projectPath: ID!, $templateName: String!) { + project(fullPath: $projectPath) { + id + ciTemplate(name: $templateName) { + content + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql new file mode 100644 index 00000000000..d62fda40237 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql @@ -0,0 +1,13 @@ +query getLatestCommitSha($projectPath: ID!, $ref: String) { + project(fullPath: $projectPath) { + id + repository { + tree(ref: $ref) { + lastCommit { + id + sha + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql new file mode 100644 index 00000000000..021b858d72e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql @@ -0,0 +1,41 @@ +query getPipeline($fullPath: ID!, $sha: String!) { + project(fullPath: $fullPath) { + id + pipeline(sha: $sha) { + id + iid + status + commit { + id + title + webPath + } + detailedStatus { + id + detailsPath + icon + group + text + } + stages { + edges { + node { + id + name + status + detailedStatus { + detailsPath + group + hasDetails + icon + id + label + text + tooltip + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js new file mode 100644 index 00000000000..fa1c70c1994 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js @@ -0,0 +1,86 @@ +import axios from '~/lib/utils/axios_utils'; +import getAppStatus from './queries/client/app_status.query.graphql'; +import getCurrentBranch from './queries/client/current_branch.query.graphql'; +import getLastCommitBranch from './queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './queries/client/pipeline_etag.query.graphql'; + +export const resolvers = { + Mutation: { + lintCI: (_, { endpoint, content, dry_run }) => { + return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ + valid: data.valid, + errors: data.errors, + warnings: data.warnings, + jobs: data.jobs.map((job) => { + const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; + + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tags: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + })); + }, + updateAppStatus: (_, { appStatus }, { cache }) => { + cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, + }, + }); + }, + updateCurrentBranch: (_, { currentBranch }, { cache }) => { + cache.writeQuery({ + query: getCurrentBranch, + data: { + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: currentBranch, + }, + }, + }, + }); + }, + updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { + cache.writeQuery({ + query: getLastCommitBranch, + data: { + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: lastCommitBranch, + }, + }, + }, + }); + }, + updatePipelineEtag: (_, { pipelineEtag }, { cache }) => { + cache.writeQuery({ + query: getPipelineEtag, + data: { + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, + }, + }); + }, + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql new file mode 100644 index 00000000000..508ff22c46e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql @@ -0,0 +1,23 @@ +type PipelineEditorApp { + status: String! +} + +type BranchList { + current: WorkBranch! + lastCommit: WorkBranch! +} + +type EtagValues { + pipeline: String! +} + +type WorkBranch { + name: String! + commit: String +} + +extend type Query { + app: PipelineEditorApp + etags: EtagValues + workBranches: BranchList +} diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js new file mode 100644 index 00000000000..6d91c339833 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -0,0 +1,146 @@ +import Vue from 'vue'; + +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { EDITOR_APP_STATUS_LOADING } from './constants'; +import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql'; +import { resolvers } from './graphql/resolvers'; +import typeDefs from './graphql/typedefs.graphql'; +import PipelineEditorApp from './pipeline_editor_app.vue'; + +export const initPipelineEditor = (selector = '#js-pipeline-editor') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { + // Add to apollo cache as it can be updated by future queries + initialBranchName, + pipelineEtag, + // Add to provide/inject API for static values + ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + ciLintPath, + defaultBranch, + emptyStateIllustrationPath, + helpPaths, + includesHelpPagePath, + lintHelpPagePath, + lintUnavailableHelpPagePath, + needsHelpPagePath, + newMergeRequestPath, + pipelinePagePath, + projectFullPath, + projectPath, + projectNamespace, + simulatePipelineHelpPagePath, + totalBranches, + usesExternalConfig, + validateTabIllustrationPath, + ymlHelpPagePath, + } = el.dataset; + + const configurationPaths = Object.fromEntries( + Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [ + source, + el.dataset[datasetKey], + ]), + ); + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, { + typeDefs, + useGet: true, + }), + }); + const { cache } = apolloProvider.clients.defaultClient; + + cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: EDITOR_APP_STATUS_LOADING, + }, + }, + }); + + cache.writeQuery({ + query: getCurrentBranch, + data: { + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: initialBranchName || defaultBranch, + }, + }, + }, + }); + + cache.writeQuery({ + query: getLastCommitBranch, + data: { + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: '', + }, + }, + }, + }); + + cache.writeQuery({ + query: getPipelineEtag, + data: { + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, + }, + }); + + return new Vue({ + el, + apolloProvider, + provide: { + ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + ciLintPath, + configurationPaths, + dataMethod: 'graphql', + defaultBranch, + emptyStateIllustrationPath, + helpPaths, + includesHelpPagePath, + lintHelpPagePath, + lintUnavailableHelpPagePath, + needsHelpPagePath, + newMergeRequestPath, + pipelinePagePath, + projectFullPath, + projectPath, + projectNamespace, + simulatePipelineHelpPagePath, + totalBranches: parseInt(totalBranches, 10), + usesExternalConfig: parseBoolean(usesExternalConfig), + validateTabIllustrationPath, + ymlHelpPagePath, + }, + render(h) { + return h(PipelineEditorApp); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue new file mode 100644 index 00000000000..ff848a973e3 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -0,0 +1,440 @@ +<script> +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; +import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; + +import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; + +import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; +import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; +import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue'; +import { + COMMIT_SHA_POLL_INTERVAL, + COMMIT_SUCCESS_WITH_REDIRECT, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + EDITOR_APP_VALID_STATUSES, + LOAD_FAILURE_UNKNOWN, + STARTER_TEMPLATE_NAME, +} from './constants'; +import updateAppStatus from './graphql/mutations/client/update_app_status.mutation.graphql'; +import getBlobContent from './graphql/queries/blob_content.query.graphql'; +import getCiConfigData from './graphql/queries/ci_config.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; +import getTemplate from './graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; +import PipelineEditorHome from './pipeline_editor_home.vue'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +export default { + components: { + ConfirmUnsavedChangesDialog, + GlLoadingIcon, + GlModal, + PipelineEditorEmptyState, + PipelineEditorHome, + PipelineEditorMessages, + }, + inject: ['ciConfigPath', 'newMergeRequestPath', 'projectFullPath', 'usesExternalConfig'], + data() { + return { + ciConfigData: {}, + currentCiFileContent: '', + failureType: null, + failureReasons: [], + hasBranchLoaded: false, + initialCiFileContent: '', + isFetchingCommitSha: false, + isLintUnavailable: false, + isNewCiConfigFile: false, + lastCommittedContent: '', + shouldSkipStartScreen: false, + showFailure: false, + showResetConfirmationModal: false, + showStartScreen: false, + showSuccess: false, + starterTemplate: '', + starterTemplateName: STARTER_TEMPLATE_NAME, + successType: null, + }; + }, + apollo: { + initialCiFileContent: { + fetchPolicy: fetchPolicies.NETWORK_ONLY, + query: getBlobContent, + // If it's a brand new file, we don't want to fetch the content. + // Then when the user commits the first time, the query would run + // to get the initial file content, but we already have it in `lastCommitedContent` + // so we skip the loading altogether. We also wait for the currentBranch + // to have been fetched + skip() { + return this.shouldSkipBlobContentQuery; + }, + variables() { + return { + projectPath: this.projectFullPath, + path: this.ciConfigPath, + ref: this.currentBranch, + }; + }, + update(data) { + return data?.project?.repository?.blobs?.nodes[0]?.rawBlob; + }, + result({ data }) { + const nodes = data?.project?.repository?.blobs?.nodes; + if (!nodes) { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + } else { + const rawBlob = nodes[0]?.rawBlob; + const fileContent = rawBlob ?? ''; + + this.lastCommittedContent = fileContent; + this.currentCiFileContent = fileContent; + + // If rawBlob is defined and returns a string, it means that there is + // a CI config file with empty content. If `rawBlob` is not defined + // at all, it means there was no file found. + const hasCIFile = rawBlob === '' || fileContent.length > 0; + + if (!fileContent.length) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); + } + + this.isNewCiConfigFile = false; + if (!hasCIFile) { + if (this.shouldSkipStartScreen) { + this.setNewEmptyCiConfigFile(); + } else { + this.showStartScreen = true; + } + } else if (fileContent.length) { + // If the file content is > 0, then we make sure to reset the + // start screen flag during a refetch + // e.g. when switching branches + this.showStartScreen = false; + } + } + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + }, + watchLoading(isLoading) { + if (isLoading) { + this.setAppStatus(EDITOR_APP_STATUS_LOADING); + } + }, + }, + ciConfigData: { + query: getCiConfigData, + skip() { + return this.shouldSkipCiConfigQuery; + }, + variables() { + return { + projectPath: this.projectFullPath, + sha: this.commitSha, + content: this.currentCiFileContent, + }; + }, + update(data) { + const { ciConfig } = data || {}; + const stageNodes = ciConfig?.stages?.nodes || []; + const stages = unwrapStagesWithNeeds(JSON.parse(JSON.stringify(stageNodes))); + + return { ...ciConfig, stages }; + }, + result({ data }) { + if (data?.ciConfig?.status) { + this.setAppStatus(data.ciConfig.status); + if (this.isLintUnavailable) { + this.isLintUnavailable = false; + } + } + }, + error() { + // We are not using `reportFailure` here because we don't + // need to bring attention to the linter being down. We let + // the user work on their file and if they look at their + // lint status, they will notice that the service is down + this.isLintUnavailable = true; + }, + watchLoading(isLoading) { + if (isLoading) { + this.setAppStatus(EDITOR_APP_STATUS_LOADING); + } + }, + }, + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + commitSha: { + query: getLatestCommitShaQuery, + skip({ currentBranch }) { + return !currentBranch; + }, + variables() { + return { + projectPath: this.projectFullPath, + ref: this.currentBranch, + }; + }, + update(data) { + const latestCommitSha = data?.project?.repository?.tree?.lastCommit?.sha; + + if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) { + this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL); + return this.commitSha; + } + + this.isFetchingCommitSha = false; + this.$apollo.queries.commitSha.stopPolling(); + return latestCommitSha; + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + }, + }, + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches?.current?.name; + }, + }, + starterTemplate: { + query: getTemplate, + variables() { + return { + projectPath: this.projectFullPath, + templateName: this.starterTemplateName, + }; + }, + skip({ isNewCiConfigFile }) { + return !isNewCiConfigFile; + }, + update(data) { + return data.project?.ciTemplate?.content || ''; + }, + result({ data }) { + this.updateCiConfig(data?.project?.ciTemplate?.content || ''); + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + }, + }, + }, + computed: { + hasUnsavedChanges() { + return this.lastCommittedContent !== this.currentCiFileContent; + }, + isBlobContentLoading() { + return !this.hasBranchLoaded || this.$apollo.queries.initialCiFileContent.loading; + }, + isCiConfigDataLoading() { + return this.$apollo.queries.ciConfigData.loading; + }, + isEmpty() { + return this.currentCiFileContent === ''; + }, + shouldSkipBlobContentQuery() { + return this.isNewCiConfigFile || this.lastCommittedContent || !this.hasBranchLoaded; + }, + shouldSkipCiConfigQuery() { + return !this.currentCiFileContent || !this.commitSha; + }, + }, + i18n: { + resetModal: { + actionPrimary: { + text: __('Reset file'), + }, + actionCancel: { + text: __('Cancel'), + }, + body: s__( + 'Pipeline Editor|Are you sure you want to reset the file to its last committed version?', + ), + title: __('Discard changes'), + }, + }, + watch: { + currentBranch: { + immediate: true, + handler(branch) { + // currentBranch is a client query so it starts off undefined. In the index.js, + // write to the apollo cache. Once that operation is done, we can safely do operations + // that require the branch to have loaded. + if (branch) { + this.hasBranchLoaded = true; + } + }, + }, + isEmpty(flag) { + if (flag) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); + } + }, + isLintUnavailable(flag) { + if (flag) { + // We cannot set this status directly in the `error` + // hook otherwise we get an infinite loop caused by apollo. + this.setAppStatus(EDITOR_APP_STATUS_LINT_UNAVAILABLE); + } + }, + }, + mounted() { + this.loadTemplateFromURL(); + this.checkShouldSkipStartScreen(); + }, + methods: { + checkShouldSkipStartScreen() { + const params = queryToObject(window.location.search); + this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); + }, + confirmReset() { + if (this.hasUnsavedChanges) { + this.showResetConfirmationModal = true; + } + }, + hideFailure() { + this.showFailure = false; + }, + hideSuccess() { + this.showSuccess = false; + }, + loadTemplateFromURL() { + const templateName = queryToObject(window.location.search)?.template; + + if (templateName) { + this.starterTemplateName = templateName; + this.setNewEmptyCiConfigFile(); + } + }, + redirectToNewMergeRequest(sourceBranch, targetBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: targetBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, + async refetchContent() { + this.$apollo.queries.initialCiFileContent.skip = false; + await this.$apollo.queries.initialCiFileContent.refetch(); + }, + reportFailure(type, reasons = []) { + this.showFailure = true; + this.failureType = type; + this.failureReasons = reasons; + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + reportSuccess(type) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + this.showSuccess = true; + this.successType = type; + }, + resetContent() { + this.showResetConfirmationModal = false; + this.currentCiFileContent = this.lastCommittedContent; + }, + setAppStatus(appStatus) { + if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { + this.$apollo.mutate({ + mutation: updateAppStatus, + variables: { appStatus }, + }); + } + }, + setNewEmptyCiConfigFile() { + this.isNewCiConfigFile = true; + this.showStartScreen = false; + }, + showErrorAlert({ type, reasons = [] }) { + this.reportFailure(type, reasons); + }, + updateCiConfig(ciFileContent) { + this.currentCiFileContent = ciFileContent; + }, + updateCommitSha() { + this.isFetchingCommitSha = true; + this.$apollo.queries.commitSha.refetch(); + }, + async updateOnCommit({ type, params = {} }) { + this.reportSuccess(type); + + if (this.isNewCiConfigFile) { + this.isNewCiConfigFile = false; + } + + // Keep track of the latest committed content to know + // if the user has made changes to the file that are unsaved. + this.lastCommittedContent = this.currentCiFileContent; + + if (type === COMMIT_SUCCESS_WITH_REDIRECT) { + const { sourceBranch, targetBranch } = params; + // This force update does 2 things for us: + // 1. It make sure `hasUnsavedChanges` is updated so + // we don't show a modal when the user creates an MR + // 2. Ensure the commit success banner is visible. + await this.$forceUpdate(); + this.redirectToNewMergeRequest(sourceBranch, targetBranch); + } + }, + }, +}; +</script> + +<template> + <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app"> + <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> + <pipeline-editor-empty-state + v-else-if="showStartScreen || usesExternalConfig" + @createEmptyConfigFile="setNewEmptyCiConfigFile" + @refetchContent="refetchContent" + /> + <div v-else> + <pipeline-editor-messages + :failure-type="failureType" + :failure-reasons="failureReasons" + :show-failure="showFailure" + :show-success="showSuccess" + :success-type="successType" + @hide-success="hideSuccess" + @hide-failure="hideFailure" + /> + <pipeline-editor-home + :ci-config-data="ciConfigData" + :ci-file-content="currentCiFileContent" + :commit-sha="commitSha" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" + @commit="updateOnCommit" + @resetContent="confirmReset" + @showError="showErrorAlert" + @refetchContent="refetchContent" + @updateCiConfig="updateCiConfig" + @updateCommitSha="updateCommitSha" + /> + <gl-modal + v-model="showResetConfirmationModal" + modal-id="reset-content" + :title="$options.i18n.resetModal.title" + :action-cancel="$options.i18n.resetModal.actionCancel" + :action-primary="$options.i18n.resetModal.actionPrimary" + @primary="resetContent" + > + {{ $options.i18n.resetModal.body }} + </gl-modal> + <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue new file mode 100644 index 00000000000..1972125ed56 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -0,0 +1,181 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; +import CommitSection from './components/commit/commit_section.vue'; +import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; +import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileTree from './components/file_tree/container.vue'; +import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; +import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; +import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants'; + +export default { + commitSectionRef: 'commitSectionRef', + modal: { + switchBranch: { + title: __('You have unsaved changes'), + body: __('Uncommitted changes will be lost if you change branches. Do you want to continue?'), + actionPrimary: { + text: __('Switch Branches'), + }, + actionSecondary: { + text: __('Cancel'), + attributes: { variant: 'default' }, + }, + }, + }, + components: { + CommitSection, + GlModal, + PipelineEditorDrawer, + PipelineEditorFileNav, + PipelineEditorFileTree, + PipelineEditorHeader, + PipelineEditorTabs, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + ciFileContent: { + type: String, + required: true, + }, + commitSha: { + type: String, + required: false, + default: '', + }, + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, + }, + data() { + return { + currentTab: CREATE_TAB, + scrollToCommitForm: false, + shouldLoadNewBranch: false, + showDrawer: false, + showFileTree: false, + showSwitchBranchModal: false, + }; + }, + computed: { + showCommitForm() { + return this.currentTab === CREATE_TAB; + }, + includesFiles() { + return this.ciConfigData?.includes || []; + }, + }, + mounted() { + this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false; + }, + methods: { + closeBranchModal() { + this.showSwitchBranchModal = false; + }, + closeDrawer() { + this.showDrawer = false; + }, + handleConfirmSwitchBranch() { + this.showSwitchBranchModal = true; + }, + openDrawer() { + this.showDrawer = true; + }, + toggleFileTree() { + this.showFileTree = !this.showFileTree; + localStorage.setItem(FILE_TREE_DISPLAY_KEY, this.showFileTree); + }, + switchBranch() { + this.showSwitchBranchModal = false; + this.shouldLoadNewBranch = true; + }, + setCurrentTab(tabName) { + this.currentTab = tabName; + }, + setScrollToCommitForm(newValue = true) { + this.scrollToCommitForm = newValue; + }, + }, +}; +</script> + +<template> + <div class="gl-transition-medium gl-w-full"> + <gl-modal + v-if="showSwitchBranchModal" + visible + modal-id="switchBranchModal" + :title="$options.modal.switchBranch.title" + :action-primary="$options.modal.switchBranch.actionPrimary" + :action-secondary="$options.modal.switchBranch.actionSecondary" + @primary="switchBranch" + @secondary="closeBranchModal" + @cancel="closeBranchModal" + @hide="closeBranchModal" + > + {{ $options.modal.switchBranch.body }} + </gl-modal> + <pipeline-editor-file-nav + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" + :should-load-new-branch="shouldLoadNewBranch" + @select-branch="handleConfirmSwitchBranch" + @toggle-file-tree="toggleFileTree" + v-on="$listeners" + /> + <div class="gl-display-flex gl-w-full gl-sm-flex-direction-column"> + <pipeline-editor-file-tree + v-if="showFileTree" + class="gl-flex-shrink-0" + :includes="includesFiles" + /> + <div class="gl-flex-grow-1 gl-min-w-0"> + <pipeline-editor-header + :ci-config-data="ciConfigData" + :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" + v-on="$listeners" + /> + <pipeline-editor-tabs + :ci-config-data="ciConfigData" + :ci-file-content="ciFileContent" + :commit-sha="commitSha" + :current-tab="currentTab" + :is-new-ci-config-file="isNewCiConfigFile" + :show-drawer="showDrawer" + v-on="$listeners" + @open-drawer="openDrawer" + @close-drawer="closeDrawer" + @set-current-tab="setCurrentTab" + @walkthrough-popover-cta-clicked="setScrollToCommitForm" + /> + </div> + </div> + <commit-section + v-show="showCommitForm" + :ref="$options.commitSectionRef" + :ci-file-content="ciFileContent" + :commit-sha="commitSha" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" + :scroll-to-commit-form="scrollToCommitForm" + @scrolled-to-commit-form="setScrollToCommitForm(false)" + v-on="$listeners" + /> + <pipeline-editor-drawer + :is-visible="showDrawer" + v-on="$listeners" + @close-drawer="closeDrawer" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 6e24ac6b8d4..a4ef7827f73 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -1,18 +1,321 @@ <script> -import { GlForm } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import Vue from 'vue'; +import { __, s__ } from '~/locale'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; export default { components: { + GlButton, + GlDropdown, + GlDropdownItem, GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + RefSelector, + TimezoneDropdown, + IntervalPatternInput, }, - inject: { - fullPath: { + inject: [ + 'fullPath', + 'projectId', + 'defaultBranch', + 'cron', + 'cronTimezone', + 'dailyLimit', + 'settingsLink', + ], + props: { + timezoneData: { + type: Array, + required: true, + }, + refParam: { + type: String, + required: false, default: '', }, }, + data() { + return { + refValue: { + shortName: this.refParam, + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, + }, + description: '', + scheduleRef: this.defaultBranch, + activated: true, + timezone: this.cronTimezone, + formCiVariables: {}, + // TODO: Add the GraphQL query to help populate the predefined variables + // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131 + predefinedValueOptions: {}, + }; + }, + i18n: { + activated: __('Activated'), + cronTimezone: s__('PipelineSchedules|Cron timezone'), + description: s__('PipelineSchedules|Description'), + shortDescriptionPipeline: s__( + 'PipelineSchedules|Provide a short description for this pipeline', + ), + savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + cancel: __('Cancel'), + targetBranchTag: __('Select target branch or tag'), + intervalPattern: s__('PipelineSchedules|Interval Pattern'), + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + removeVariableLabel: s__('CiVariables|Remove variable'), + variables: s__('Pipeline|Variables'), + }, + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, + formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', + computed: { + dropdownTranslations() { + return { + dropdownHeader: this.$options.i18n.targetBranchTag, + }; + }, + refFullName() { + return this.refValue.fullName; + }, + variables() { + return this.formCiVariables[this.refFullName]?.variables ?? []; + }, + descriptions() { + return this.formCiVariables[this.refFullName]?.descriptions ?? {}; + }, + typeOptionsListbox() { + return [ + { + text: __('Variable'), + value: VARIABLE_TYPE, + }, + { + text: __('File'), + value: FILE_TYPE, + }, + ]; + }, + getEnabledRefTypes() { + return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; + }, + }, + created() { + Vue.set(this.formCiVariables, this.refFullName, { + variables: [], + descriptions: {}, + }); + + this.addEmptyVariable(this.refFullName); + }, + methods: { + addEmptyVariable(refValue) { + const { variables } = this.formCiVariables[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariableAttribute(key, attribute, value) { + const { variables } = this.formCiVariables[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable[attribute] = value; + }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, + removeVariable(index) { + this.variables.splice(index, 1); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + }, }; </script> <template> - <gl-form /> + <div class="col-lg-8"> + <gl-form> + <!--Description--> + <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> + <gl-form-input + id="schedule-description" + v-model="description" + type="text" + :placeholder="$options.i18n.shortDescriptionPipeline" + data-testid="schedule-description" + /> + </gl-form-group> + <!--Interval Pattern--> + <gl-form-group :label="$options.i18n.intervalPattern" label-for="schedule-interval"> + <interval-pattern-input + id="schedule-interval" + :initial-cron-interval="cron" + :daily-limit="dailyLimit" + :send-native-errors="false" + /> + </gl-form-group> + <!--Timezone--> + <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <timezone-dropdown + id="schedule-timezone" + :value="timezone" + :timezone-data="timezoneData" + name="schedule-timezone" + /> + </gl-form-group> + <!--Branch/Tag Selector--> + <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag"> + <ref-selector + id="schedule-target-branch-tag" + :enabled-ref-types="getEnabledRefTypes" + :project-id="projectId" + :value="scheduleRef" + :use-symbolic-ref-names="true" + :translations="dropdownTranslations" + class="gl-w-full" + /> + </gl-form-group> + <!--Variable List--> + <gl-form-group :label="$options.i18n.variables"> + <div + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" + > + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-type" + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableAttribute(variable.key, 'variable_type', type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + @change="addEmptyVariable(refFullName)" + /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-textarea + v-else + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3 gl-h-7!" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + @click="removeVariable(index)" + /> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> + </div> + + <template #description + ><gl-sprintf :message="$options.i18n.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <!--Activated--> + <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{ + $options.i18n.activated + }}</gl-form-checkbox> + + <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{ + $options.i18n.savePipelineSchedule + }}</gl-button> + <gl-button type="reset" data-testid="schedule-cancel-button">{{ + $options.i18n.cancel + }}</gl-button> + </gl-form> + </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js new file mode 100644 index 00000000000..b4ab1143f60 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -0,0 +1,2 @@ +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index d83417ab84a..445161f99cb 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -16,7 +16,16 @@ export default (selector) => { return false; } - const { fullPath } = containerEl.dataset; + const { + fullPath, + cron, + dailyLimit, + timezoneData, + cronTimezone, + projectId, + defaultBranch, + settingsLink, + } = containerEl.dataset; return new Vue({ el: containerEl, @@ -24,9 +33,20 @@ export default (selector) => { apolloProvider, provide: { fullPath, + projectId, + defaultBranch, + dailyLimit: dailyLimit ?? '', + cronTimezone: cronTimezone ?? '', + cron: cron ?? '', + settingsLink, }, render(createElement) { - return createElement(PipelineSchedulesForm); + return createElement(PipelineSchedulesForm, { + props: { + timezoneData: JSON.parse(timezoneData), + refParam: defaultBranch, + }, + }); }, }); }; diff --git a/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue new file mode 100644 index 00000000000..5a7ee9c9b28 --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue @@ -0,0 +1,76 @@ +<script> +/** + * Renders Code quality body text + * Fixed: [name] in [link]:[line] + */ +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import ReportLink from '~/ci/reports/components/report_link.vue'; +import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/ci/reports/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants'; + +export default { + name: 'CodequalityIssueBody', + components: { + GlIcon, + ReportLink, + }, + directives: { + tooltip: GlTooltipDirective, + }, + props: { + status: { + type: String, + required: false, + default: STATUS_NEUTRAL, + }, + issue: { + type: Object, + required: true, + }, + }, + computed: { + issueName() { + return `${this.severityLabel} - ${this.issue.name}`; + }, + issueSeverity() { + return this.issue.severity?.toLowerCase(); + }, + isStatusSuccess() { + return this.status === STATUS_SUCCESS; + }, + severityClass() { + return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown; + }, + severityIcon() { + return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown; + }, + severityLabel() { + return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown; + }, + }, + severityText: { + info: s__('severity|Info'), + minor: s__('severity|Minor'), + major: s__('severity|Major'), + critical: s__('severity|Critical'), + blocker: s__('severity|Blocker'), + unknown: s__('severity|Unknown'), + }, +}; +</script> +<template> + <div class="gl-display-flex gl-mt-2 gl-mb-2 gl-w-full"> + <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon"> + <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" /> + </span> + <div class="gl-flex-grow-1"> + <div> + <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong> + {{ issueName }} + </div> + + <report-link v-if="issue.path" :issue="issue" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js new file mode 100644 index 00000000000..5e81245037f --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js @@ -0,0 +1,53 @@ +export const SEVERITY_CLASSES = { + info: 'text-primary-400', + minor: 'text-warning-200', + major: 'text-warning-400', + critical: 'text-danger-600', + blocker: 'text-danger-800', + unknown: 'text-secondary-400', +}; + +export const SEVERITY_ICONS = { + info: 'severity-info', + minor: 'severity-low', + major: 'severity-medium', + critical: 'severity-high', + blocker: 'severity-critical', + unknown: 'severity-unknown', +}; + +export const SEVERITY_ICONS_MR_WIDGET = { + info: 'severityInfo', + minor: 'severityLow', + major: 'severityMedium', + critical: 'severityHigh', + blocker: 'severityCritical', + unknown: 'severityUnknown', +}; + +export const SEVERITIES = { + info: { + class: SEVERITY_CLASSES.info, + name: SEVERITY_ICONS.info, + }, + minor: { + class: SEVERITY_CLASSES.minor, + name: SEVERITY_ICONS.minor, + }, + major: { + class: SEVERITY_CLASSES.major, + name: SEVERITY_ICONS.major, + }, + critical: { + class: SEVERITY_CLASSES.critical, + name: SEVERITY_ICONS.critical, + }, + blocker: { + class: SEVERITY_CLASSES.blocker, + name: SEVERITY_ICONS.blocker, + }, + unknown: { + class: SEVERITY_CLASSES.unknown, + name: SEVERITY_ICONS.unknown, + }, +}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js new file mode 100644 index 00000000000..04aca11b945 --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js @@ -0,0 +1,30 @@ +import pollUntilComplete from '~/lib/utils/poll_until_complete'; +import { STATUS_NOT_FOUND } from '../../constants'; +import * as types from './mutation_types'; +import { parseCodeclimateMetrics } from './utils/codequality_parser'; + +export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); + +export const fetchReports = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORTS); + + return pollUntilComplete(state.reportsPath) + .then(({ data }) => { + if (data.status === STATUS_NOT_FOUND) { + return dispatch('receiveReportsError', data); + } + return dispatch('receiveReportsSuccess', { + newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), + resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), + }); + }) + .catch((error) => dispatch('receiveReportsError', error)); +}; + +export const receiveReportsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_REPORTS_SUCCESS, data); +}; + +export const receiveReportsError = ({ commit }, error) => { + commit(types.RECEIVE_REPORTS_ERROR, error); +}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js new file mode 100644 index 00000000000..70d11e96a54 --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js @@ -0,0 +1,63 @@ +import { spriteIcon } from '~/lib/utils/common_utils'; +import { sprintf, s__, n__ } from '~/locale'; +import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants'; + +export const hasCodequalityIssues = (state) => + Boolean(state.newIssues?.length || state.resolvedIssues?.length); + +export const codequalityStatus = (state) => { + if (state.isLoading) { + return LOADING; + } + if (state.hasError) { + return ERROR; + } + + return SUCCESS; +}; + +export const codequalityText = (state) => { + const { newIssues, resolvedIssues } = state; + let text; + if (!newIssues.length && !resolvedIssues.length) { + text = s__('ciReport|No changes to code quality'); + } else if (newIssues.length && resolvedIssues.length) { + text = sprintf( + s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`), + { + issueCount: newIssues.length + resolvedIssues.length, + }, + ); + } else if (resolvedIssues.length) { + text = n__( + `ciReport|Code quality improved due to 1 resolved issue`, + `ciReport|Code quality improved due to %d resolved issues`, + resolvedIssues.length, + ); + } else if (newIssues.length) { + text = n__( + `ciReport|Code quality degraded due to 1 new issue`, + `ciReport|Code quality degraded due to %d new issues`, + newIssues.length, + ); + } + + return text; +}; + +export const codequalityPopover = (state) => { + if (state.status === STATUS_NOT_FOUND) { + return { + title: s__('ciReport|Base pipeline codequality artifact not found'), + content: sprintf( + s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'), + { + linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`, + linkEndTag: `${spriteIcon('external-link', 's16')}</a>`, + }, + false, + ), + }; + } + return {}; +}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js new file mode 100644 index 00000000000..5bfcd69edec --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const getStoreConfig = (initialState) => ({ + actions, + getters, + mutations, + state: state(initialState), +}); + +export default (initialState) => new Vuex.Store(getStoreConfig(initialState)); diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js new file mode 100644 index 00000000000..c362c973ae1 --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_PATHS = 'SET_PATHS'; + +export const REQUEST_REPORTS = 'REQUEST_REPORTS'; +export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; +export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js new file mode 100644 index 00000000000..249c2f35c0b --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js @@ -0,0 +1,27 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_PATHS](state, paths) { + state.baseBlobPath = paths.baseBlobPath; + state.headBlobPath = paths.headBlobPath; + state.reportsPath = paths.reportsPath; + state.helpPath = paths.helpPath; + }, + [types.REQUEST_REPORTS](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORTS_SUCCESS](state, data) { + state.hasError = false; + state.status = ''; + state.statusReason = ''; + state.isLoading = false; + state.newIssues = data.newIssues; + state.resolvedIssues = data.resolvedIssues; + }, + [types.RECEIVE_REPORTS_ERROR](state, error) { + state.isLoading = false; + state.hasError = true; + state.status = error?.status || ''; + state.statusReason = error?.response?.data?.status_reason; + }, +}; diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js new file mode 100644 index 00000000000..f68dbc2a5fa --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/state.js @@ -0,0 +1,16 @@ +export default () => ({ + reportsPath: null, + + baseBlobPath: null, + headBlobPath: null, + + isLoading: false, + hasError: false, + status: '', + statusReason: '', + + newIssues: [], + resolvedIssues: [], + + helpPath: null, +}); diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js new file mode 100644 index 00000000000..417297df43c --- /dev/null +++ b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js @@ -0,0 +1,29 @@ +export const parseCodeclimateMetrics = (issues = [], blobPath = '') => { + return issues.map((issue) => { + // the `file_path` attribute from the artifact is returned as `file` by GraphQL + const issuePath = issue.file_path || issue.path; + const parsedIssue = { + name: issue.description, + path: issuePath, + urlPath: `${blobPath}/${issuePath}#L${issue.line}`, + ...issue, + }; + + if (issue?.location?.path) { + let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`; + parsedIssue.path = issue.location.path; + + if (issue?.location?.lines?.begin) { + parsedIssue.line = issue.location.lines.begin; + parseCodeQualityUrl += `#L${issue.location.lines.begin}`; + } else if (issue?.location?.positions?.begin?.line) { + parsedIssue.line = issue.location.positions.begin.line; + parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`; + } + + parsedIssue.urlPath = parseCodeQualityUrl; + } + + return parsedIssue; + }); +}; diff --git a/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue new file mode 100644 index 00000000000..b21a486e259 --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue @@ -0,0 +1,106 @@ +<script> +import { s__ } from '~/locale'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; + +export default { + components: { + ReportItem, + SmartVirtualList, + }, + props: { + component: { + type: String, + required: false, + default: '', + }, + nestedLevel: { + type: Number, + required: false, + default: 0, + validator: (value) => [0, 1, 2].includes(value), + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedHeading: { + type: String, + required: false, + default: s__('ciReport|Fixed'), + }, + unresolvedHeading: { + type: String, + required: false, + default: s__('ciReport|New'), + }, + }, + groups: ['unresolved', 'resolved'], + typicalReportItemHeight: 32, + maxShownReportItems: 20, + computed: { + groups() { + return this.$options.groups + .map((group) => ({ + name: group, + issues: this[`${group}Issues`], + heading: this[`${group}Heading`], + })) + .filter(({ issues }) => issues.length > 0); + }, + listLength() { + // every group has a header which is rendered as a list item + const groupsCount = this.groups.length; + const issuesCount = this.groups.reduce( + (totalIssues, { issues }) => totalIssues + issues.length, + 0, + ); + + return groupsCount + issuesCount; + }, + listClasses() { + return { + 'gl-pl-9': this.nestedLevel === 1, + 'gl-pl-11-5': this.nestedLevel === 2, + }; + }, + }, +}; +</script> + +<template> + <smart-virtual-list + :length="listLength" + :remain="$options.maxShownReportItems" + :size="$options.typicalReportItemHeight" + :class="listClasses" + class="report-block-container" + wtag="ul" + wclass="report-block-list" + > + <template v-for="(group, groupIndex) in groups"> + <h2 + :key="group.name" + :data-testid="`${group.name}Heading`" + :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']" + class="h5 mb-1" + > + {{ group.heading }} + </h2> + <report-item + v-for="(issue, issueIndex) in group.issues" + :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`" + :issue="issue" + :show-report-section-status-icon="false" + :component="component" + status="none" + /> + </template> + </smart-virtual-list> +</template> diff --git a/app/assets/javascripts/ci/reports/components/issue_body.js b/app/assets/javascripts/ci/reports/components/issue_body.js new file mode 100644 index 00000000000..daff1be30ff --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/issue_body.js @@ -0,0 +1,17 @@ +import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue'; + +export const components = { + CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'), +}; + +export const componentNames = { + CodequalityIssueBody: 'CodequalityIssueBody', +}; + +export const iconComponents = { + IssueStatusIcon, +}; + +export const iconComponentNames = { + IssueStatusIcon: IssueStatusIcon.name, +}; diff --git a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue new file mode 100644 index 00000000000..bd41b8d23f1 --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue @@ -0,0 +1,54 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '../constants'; + +export default { + name: 'IssueStatusIcon', + components: { + GlIcon, + }, + props: { + status: { + type: String, + required: true, + }, + statusIconSize: { + type: Number, + required: false, + default: 24, + }, + }, + computed: { + iconName() { + if (this.isStatusFailed) { + return 'status_failed_borderless'; + } else if (this.isStatusSuccess) { + return 'status_success_borderless'; + } + + return 'dash'; + }, + isStatusFailed() { + return this.status === STATUS_FAILED; + }, + isStatusSuccess() { + return this.status === STATUS_SUCCESS; + }, + isStatusNeutral() { + return this.status === STATUS_NEUTRAL; + }, + }, +}; +</script> +<template> + <div + :class="{ + failed: isStatusFailed, + success: isStatusSuccess, + neutral: isStatusNeutral, + }" + class="report-block-list-icon" + > + <gl-icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/reports/components/issues_list.vue b/app/assets/javascripts/ci/reports/components/issues_list.vue new file mode 100644 index 00000000000..ababd4b5e49 --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/issues_list.vue @@ -0,0 +1,119 @@ +<script> +import ReportItem from '~/ci/reports/components/report_item.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; + +const wrapIssueWithState = (status, isNew = false) => (issue) => ({ + status: issue.status || status, + isNew, + issue, +}); + +/** + * Renders block of issues + */ +export default { + components: { + SmartVirtualList, + ReportItem, + }, + // Typical height of a report item in px + typicalReportItemHeight: 32, + /* + The maximum amount of shown issues. This is calculated by + ( max-height of report-block-list / typicalReportItemHeight ) + some safety margin + We will use VirtualList if we have more items than this number. + For entries lower than this number, the virtual scroll list calculates the total height of the element wrongly. + */ + maxShownReportItems: 20, + props: { + newIssues: { + type: Array, + required: false, + default: () => [], + }, + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + neutralIssues: { + type: Array, + required: false, + default: () => [], + }, + component: { + type: String, + required: false, + default: '', + }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, + issuesUlElementClass: { + type: String, + required: false, + default: '', + }, + issueItemClass: { + type: String, + required: false, + default: null, + }, + nestedLevel: { + type: Number, + required: false, + default: 0, + validator: (value) => [0, 1, 2].includes(value), + }, + }, + computed: { + issuesWithState() { + return [ + ...this.newIssues.map(wrapIssueWithState(STATUS_FAILED, true)), + ...this.unresolvedIssues.map(wrapIssueWithState(STATUS_FAILED)), + ...this.neutralIssues.map(wrapIssueWithState(STATUS_NEUTRAL)), + ...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)), + ]; + }, + wclass() { + return `report-block-list ${this.issuesUlElementClass}`; + }, + listClasses() { + return { + 'gl-pl-9': this.nestedLevel === 1, + 'gl-pl-11-5': this.nestedLevel === 2, + }; + }, + }, +}; +</script> +<template> + <smart-virtual-list + :length="issuesWithState.length" + :remain="$options.maxShownReportItems" + :size="$options.typicalReportItemHeight" + class="report-block-container" + :class="listClasses" + wtag="ul" + :wclass="wclass" + > + <report-item + v-for="(wrapped, index) in issuesWithState" + :key="index" + :issue="wrapped.issue" + :status="wrapped.status" + :component="component" + :is-new="wrapped.isNew" + :show-report-section-status-icon="showReportSectionStatusIcon" + :class="issueItemClass" + /> + </smart-virtual-list> +</template> diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue new file mode 100644 index 00000000000..97d4ac7bf6f --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/report_item.vue @@ -0,0 +1,67 @@ +<script> +import { + components, + componentNames, + iconComponents, + iconComponentNames, +} from 'ee_else_ce/ci/reports/components/issue_body'; + +export default { + name: 'ReportItem', + components: { + ...components, + ...iconComponents, + }, + props: { + issue: { + type: Object, + required: true, + }, + component: { + type: String, + required: false, + default: '', + validator: (value) => value === '' || Object.values(componentNames).includes(value), + }, + iconComponent: { + type: String, + required: false, + default: iconComponentNames.IssueStatusIcon, + validator: (value) => Object.values(iconComponentNames).includes(value), + }, + // failed || success + status: { + type: String, + required: true, + }, + statusIconSize: { + type: Number, + required: false, + default: 24, + }, + isNew: { + type: Boolean, + required: false, + default: false, + }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> +<template> + <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row"> + <component + :is="iconComponent" + v-if="showReportSectionStatusIcon" + :status="status" + :status-icon-size="statusIconSize" + class="gl-mr-2" + /> + + <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> + </li> +</template> diff --git a/app/assets/javascripts/ci/reports/components/report_link.vue b/app/assets/javascripts/ci/reports/components/report_link.vue new file mode 100644 index 00000000000..1f68f79e487 --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/report_link.vue @@ -0,0 +1,30 @@ +<script> +/* eslint-disable @gitlab/vue-require-i18n-strings */ +export default { + name: 'ReportIssueLink', + props: { + issue: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue-description-link"> + in + + <a + v-if="issue.urlPath" + :href="issue.urlPath" + target="_blank" + rel="noopener noreferrer nofollow" + class="break-link" + > + {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template> + </a> + <template v-else> + {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue new file mode 100644 index 00000000000..468c8916b8d --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/report_section.vue @@ -0,0 +1,237 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import api from '~/api'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; +import IssuesList from './issues_list.vue'; + +export default { + name: 'ReportSection', + components: { + GlButton, + IssuesList, + HelpPopover, + StatusIcon, + }, + mixins: [glFeatureFlagsMixin()], + props: { + alwaysOpen: { + type: Boolean, + required: false, + default: false, + }, + component: { + type: String, + required: false, + default: '', + }, + status: { + type: String, + required: true, + }, + loadingText: { + type: String, + required: false, + default: '', + }, + errorText: { + type: String, + required: false, + default: '', + }, + successText: { + type: String, + required: false, + default: '', + }, + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + neutralIssues: { + type: Array, + required: false, + default: () => [], + }, + infoText: { + type: [String, Boolean], + required: false, + default: false, + }, + hasIssues: { + type: Boolean, + required: true, + }, + popoverOptions: { + type: Object, + default: () => ({}), + required: false, + }, + showReportSectionStatusIcon: { + type: Boolean, + required: false, + default: true, + }, + issuesUlElementClass: { + type: String, + required: false, + default: undefined, + }, + issuesListContainerClass: { + type: String, + required: false, + default: undefined, + }, + issueItemClass: { + type: String, + required: false, + default: undefined, + }, + shouldEmitToggleEvent: { + type: Boolean, + required: false, + default: false, + }, + trackAction: { + type: String, + required: false, + default: null, + }, + }, + + data() { + return { + isCollapsed: true, + }; + }, + + computed: { + isLoading() { + return this.status === status.LOADING; + }, + loadingFailed() { + return this.status === status.ERROR; + }, + isSuccess() { + return this.status === status.SUCCESS; + }, + isCollapsible() { + return !this.alwaysOpen && this.hasIssues; + }, + isExpanded() { + return this.alwaysOpen || !this.isCollapsed; + }, + statusIconName() { + if (this.isLoading) { + return 'loading'; + } + if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) { + return 'warning'; + } + return 'success'; + }, + headerText() { + if (this.isLoading) { + return this.loadingText; + } + + if (this.isSuccess) { + return this.successText; + } + + if (this.loadingFailed) { + return this.errorText; + } + + return ''; + }, + hasPopover() { + return Object.keys(this.popoverOptions).length > 0; + }, + slotName() { + if (this.isSuccess) { + return SLOT_SUCCESS; + } else if (this.isLoading) { + return SLOT_LOADING; + } + + return SLOT_ERROR; + }, + }, + methods: { + toggleCollapsed() { + if (this.trackAction) { + api.trackRedisHllUserEvent(this.trackAction); + } + + if (this.shouldEmitToggleEvent) { + this.$emit('toggleEvent'); + } + this.isCollapsed = !this.isCollapsed; + }, + }, +}; +</script> +<template> + <section class="media-section"> + <div class="media"> + <status-icon :status="statusIconName" :size="24" class="align-self-center" /> + <div class="media-body gl-display-flex gl-align-items-flex-start gl-flex-direction-row!"> + <div + data-testid="report-section-code-text" + class="js-code-text code-text gl-align-self-center gl-flex-grow-1" + > + <div class="gl-display-flex gl-align-items-center"> + <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p> + <slot :name="slotName"></slot> + <help-popover + v-if="hasPopover" + :options="popoverOptions" + class="gl-ml-2 gl-display-inline-flex" + /> + </div> + <slot name="sub-heading"></slot> + </div> + + <slot name="action-buttons" :is-collapsible="isCollapsible"></slot> + + <div + v-if="isCollapsible" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3" + > + <gl-button + data-testid="report-section-expand-button" + data-qa-selector="expand_report_button" + category="tertiary" + size="small" + :icon="isExpanded ? 'chevron-lg-up' : 'chevron-lg-down'" + @click="toggleCollapsed" + /> + </div> + </div> + </div> + + <div v-if="hasIssues" v-show="isExpanded" class="js-report-section-container"> + <slot name="body"> + <issues-list + :unresolved-issues="unresolvedIssues" + :resolved-issues="resolvedIssues" + :neutral-issues="neutralIssues" + :component="component" + :show-report-section-status-icon="showReportSectionStatusIcon" + :issues-ul-element-class="issuesUlElementClass" + :class="issuesListContainerClass" + :issue-item-class="issueItemClass" + /> + </slot> + </div> + </section> +</template> diff --git a/app/assets/javascripts/ci/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue new file mode 100644 index 00000000000..ee55368c829 --- /dev/null +++ b/app/assets/javascripts/ci/reports/components/summary_row.vue @@ -0,0 +1,93 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { ICON_WARNING } from '../constants'; + +/** + * Renders the summary row for each report + * + * Used both in MR widget and Pipeline's view for: + * - Unit tests reports + * - Security reports + */ + +export default { + name: 'ReportSummaryRow', + components: { + CiIcon, + HelpPopover, + GlLoadingIcon, + }, + props: { + nestedSummary: { + type: Boolean, + required: false, + default: false, + }, + summary: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: true, + }, + popoverOptions: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + iconStatus() { + return { + group: this.statusIcon, + icon: `status_${this.statusIcon}`, + }; + }, + rowClasses() { + if (!this.nestedSummary) { + return ['gl-px-5']; + } + return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; + }, + statusIconSize() { + if (!this.nestedSummary) { + return 24; + } + return 16; + }, + }, +}; +</script> +<template> + <div + class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center" + :class="rowClasses" + > + <div class="gl-mr-3"> + <gl-loading-icon + v-if="statusIcon === 'loading'" + css-class="report-block-list-loading-icon" + size="lg" + /> + <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" /> + </div> + <div class="report-block-list-issue-description"> + <div class="report-block-list-issue-description-text" data-testid="summary-row-description"> + <slot name="summary">{{ summary }}</slot + ><span v-if="popoverOptions" class="text-nowrap" + > <help-popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> + </span> + </div> + </div> + <div + v-if="$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */" + class="text-right flex-fill d-flex justify-content-end flex-column flex-sm-row" + > + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js new file mode 100644 index 00000000000..bad6fa1e7b9 --- /dev/null +++ b/app/assets/javascripts/ci/reports/constants.js @@ -0,0 +1,38 @@ +export const fieldTypes = { + codeBlock: 'codeBlock', + link: 'link', + seconds: 'seconds', + text: 'text', +}; + +export const LOADING = 'LOADING'; +export const ERROR = 'ERROR'; +export const SUCCESS = 'SUCCESS'; + +export const STATUS_FAILED = 'failed'; +export const STATUS_SUCCESS = 'success'; +export const STATUS_NEUTRAL = 'neutral'; +export const STATUS_NOT_FOUND = 'not_found'; + +export const ICON_WARNING = 'warning'; +export const ICON_SUCCESS = 'success'; +export const ICON_NOTFOUND = 'notfound'; +export const ICON_PENDING = 'pending'; +export const ICON_FAILED = 'failed'; + +export const status = { + LOADING, + ERROR, + SUCCESS, +}; + +export const ACCESSIBILITY_ISSUE_ERROR = 'error'; +export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; + +/** + * Slot names for the ReportSection component, corresponding to the success, + * loading and error statuses. + */ +export const SLOT_SUCCESS = 'success'; +export const SLOT_LOADING = 'loading'; +export const SLOT_ERROR = 'error'; diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 9fa4b521ebc..66d790acb00 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import VueRouter from 'vue-router'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -11,11 +12,28 @@ import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants'; +import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; +const ROUTE_DETAILS = 'details'; +const ROUTE_JOBS = 'jobs'; + +const routes = [ + { + path: '/', + name: ROUTE_DETAILS, + component: RunnerDetails, + }, + { + path: '/jobs', + name: ROUTE_JOBS, + component: RunnerJobs, + }, + { path: '*', redirect: { name: ROUTE_DETAILS } }, +]; + export default { name: 'AdminRunnerShowApp', components: { @@ -26,12 +44,10 @@ export default { RunnerEditButton, RunnerPauseButton, RunnerHeader, - RunnerDetails, - RunnerJobs, - }, - directives: { - GlTooltip: GlTooltipDirective, }, + router: new VueRouter({ + routes, + }), props: { runnerId: { type: String, @@ -72,11 +88,17 @@ export default { jobCount() { return formatJobCount(this.runner?.jobCount); }, + tabIndex() { + return routes.findIndex(({ name }) => name === this.$route.name); + }, }, errorCaptured(error) { this.reportToSentry(error); }, methods: { + goTo(name) { + this.$router.push({ name }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -85,7 +107,10 @@ export default { redirectTo(this.runnersPath); }, }, + ROUTE_DETAILS, + ROUTE_JOBS, I18N_DETAILS, + I18N_JOBS, }; </script> <template> @@ -98,15 +123,13 @@ export default { </template> </runner-header> - <gl-tabs> - <gl-tab> + <gl-tabs :value="tabIndex"> + <gl-tab @click="goTo($options.ROUTE_DETAILS)"> <template #title>{{ $options.I18N_DETAILS }}</template> - - <runner-details v-if="runner" :runner="runner" /> </gl-tab> - <gl-tab> + <gl-tab @click="goTo($options.ROUTE_JOBS)"> <template #title> - {{ s__('Runners|Jobs') }} + {{ $options.I18N_JOBS }} <gl-badge v-if="jobCount" data-testid="job-count-badge" @@ -116,9 +139,9 @@ export default { {{ jobCount }} </gl-badge> </template> - - <runner-jobs v-if="runner" :runner="runner" /> </gl-tab> + + <router-view v-if="runner" :runner="runner" /> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js index ea455416648..cbd25819303 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/index.js +++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import AdminRunnerShowApp from './admin_runner_show_app.vue'; Vue.use(VueApollo); +Vue.use(VueRouter); export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { showAlertFromLocalStorage(); diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 2915e460085..3bd20dff9cc 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -23,6 +23,7 @@ import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; @@ -48,6 +49,7 @@ export default { RunnerPagination, RunnerTypeTabs, RunnerActionsCell, + RunnerJobStatusBadge, }, mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], @@ -69,6 +71,9 @@ export default { apollo: { runners: { query: allRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -134,6 +139,12 @@ export default { this.reportToSentry(error); }, methods: { + jobsUrl(runner) { + const url = new URL(runner.adminUrl); + url.hash = '#/jobs'; + + return url.href; + }, onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. @@ -208,6 +219,12 @@ export default { <runner-name :runner="runner" /> </gl-link> </template> + <template #runner-job-status-badge="{ runner }"> + <runner-job-status-badge + :href="jobsUrl(runner)" + :job-status="runner.jobExecutionStatus" + /> + </template> <template #runner-actions-cell="{ runner }"> <runner-actions-cell :runner="runner" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index 67b9b0a266f..cfbe37f5ba2 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -7,8 +7,6 @@ import RunnerPausedBadge from '../runner_paused_badge.vue'; export default { components: { RunnerStatusBadge, - RunnerUpgradeStatusBadge: () => - import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), RunnerPausedBadge, }, directives: { @@ -34,10 +32,6 @@ export default { :runner="runner" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> - <runner-upgrade-status-badge - :runner="runner" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - /> <runner-paused-badge v-if="paused" class="gl-display-inline-block gl-max-w-full gl-text-truncate" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 1e44d5fccc2..4a72023b6a0 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -6,9 +6,11 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; +import RunnerJobStatusBadge from '../runner_job_status_badge.vue'; import { formatJobCount } from '../../utils'; import { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -25,6 +27,7 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, + RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), TooltipOnTruncate, @@ -44,6 +47,7 @@ export default { }, }, i18n: { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -75,12 +79,21 @@ export default { </gl-sprintf> </div> <div class="gl-text-secondary gl-mx-2" aria-hidden="true">ยท</div> - <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + <tooltip-on-truncate + v-if="runner.description" + class="gl-text-truncate gl-display-block" + :title="runner.description" + > {{ runner.description }} </tooltip-on-truncate> + <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span> </div> <div> + <slot :runner="runner" name="runner-job-status-badge"> + <runner-job-status-badge :job-status="runner.jobExecutionStatus" /> + </slot> + <runner-summary-field icon="clock"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue index c260670b517..9e8055a8432 100644 --- a/app/assets/javascripts/ci/runner/components/runner_detail.vue +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -49,7 +49,7 @@ export default { <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> - <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + <span v-else class="gl-text-secondary">{{ emptyValue }}</span> </dd> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue index c3b35bd52a9..8501d165157 100644 --- a/app/assets/javascripts/ci/runner/components/runner_groups.vue +++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue @@ -32,6 +32,6 @@ export default { :avatar-url="group.avatarUrl" /> </template> - <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + <span v-else class="gl-text-secondary">{{ __('None') }}</span> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue new file mode 100644 index 00000000000..1e52acecfb8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -0,0 +1,55 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + I18N_JOB_STATUS_RUNNING, + I18N_JOB_STATUS_IDLE, + JOB_STATUS_RUNNING, + JOB_STATUS_IDLE, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + jobStatus: { + required: false, + default: null, + type: String, + }, + }, + computed: { + badge() { + switch (this.jobStatus) { + case JOB_STATUS_RUNNING: + return { + classes: 'gl-text-blue-600! gl-border gl-border-blue-600!', + label: I18N_JOB_STATUS_RUNNING, + }; + case JOB_STATUS_IDLE: + return { + classes: 'gl-text-gray-700! gl-border gl-border-gray-500!', + label: I18N_JOB_STATUS_IDLE, + }; + default: + return null; + } + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-bind="$attrs" + size="sm" + class="gl-mr-3 gl-bg-transparent!" + variant="muted" + :class="badge.classes" + > + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index e895537dcdc..b2aad0aac4f 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -7,7 +7,7 @@ import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.grap import { formatJobCount, tableField } from '../utils'; import RunnerBulkDelete from './runner_bulk_delete.vue'; import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; -import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; +import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerOwnerCell from './cells/runner_owner_cell.vue'; @@ -28,7 +28,7 @@ export default { RunnerBulkDelete, RunnerBulkDeleteCheckbox, RunnerStatusPopover, - RunnerStackedSummaryCell, + RunnerSummaryCell, RunnerStatusCell, RunnerOwnerCell, }, @@ -154,11 +154,14 @@ export default { </template> <template #cell(summary)="{ item, index }"> - <runner-stacked-summary-cell :runner="item"> + <runner-summary-cell :runner="item"> <template #runner-name="{ runner }"> <slot name="runner-name" :runner="runner" :index="index"></slot> </template> - </runner-stacked-summary-cell> + <template #runner-job-status-badge="{ runner }"> + <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot> + </template> + </runner-summary-cell> </template> <template #head(owner)="{ label }"> diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue index 84008e8eee8..4a6e90b44a9 100644 --- a/app/assets/javascripts/ci/runner/components/runner_projects.vue +++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue @@ -133,7 +133,7 @@ export default { :is-owner="isOwner(project.id)" /> </template> - <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> + <div v-else class="gl-py-5 gl-text-secondary">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> <runner-pagination :disabled="loading" diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue index 584236168ac..70226074993 100644 --- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -59,21 +59,20 @@ export default { return [ { title: I18N_ALL_TYPES, - runnerType: null, }, ...tabs, ]; }, }, methods: { - onTabSelected({ runnerType }) { + onTabSelected(runnerType) { this.$emit('input', { ...this.value, runnerType, pagination: { page: 1 }, }); }, - isTabActive({ runnerType }) { + isTabActive(runnerType = null) { return runnerType === this.value.runnerType; }, tabBadgeCountVariables(runnerType) { @@ -102,8 +101,8 @@ export default { <gl-tab v-for="tab in tabs" :key="`${tab.runnerType}`" - :active="isTabActive(tab)" - @click="onTabSelected(tab)" + :active="isTabActive(tab.runnerType)" + @click="onTabSelected(tab.runnerType)" > <template #title> {{ tab.title }} diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js index 97ee8ec3eef..71a145dd4a3 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -1,5 +1,5 @@ import { __ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; @@ -24,5 +24,5 @@ export const pausedTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js index 117a630719e..4bc32909777 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -1,5 +1,5 @@ import { - OPERATOR_IS_ONLY, + OPERATORS_IS, TOKEN_TITLE_STATUS, } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -38,5 +38,5 @@ export const statusTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js index fdeba714385..369b214f952 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js @@ -1,5 +1,5 @@ import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { PARAM_KEY_TAG } from '../../constants'; import TagToken from './tag_token.vue'; @@ -8,5 +8,5 @@ export const tagTokenConfig = { title: s__('Runners|Tags'), type: PARAM_KEY_TAG, token: TagToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue index 4ad9259f59d..c33c42f3afe 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -16,13 +16,13 @@ import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; * <strong/> tag. * * ```vue - * <runner-count-stat + * <runner-count * #default="{ count }" * :scope="INSTANCE_TYPE" * :variables="{ status: 'ONLINE' }" * > * <strong>{{ count }}</strong> - * </runner-count-stat> + * </runner-count> * ``` * * Use `:skip="true"` to prevent data from being fetched and diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index 3965e5551f1..2e50dc13d2d 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -1,5 +1,4 @@ <script> -import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; import { I18N_STATUS_ONLINE, I18N_STATUS_OFFLINE, @@ -8,9 +7,19 @@ import { STATUS_OFFLINE, STATUS_STALE, } from '../../constants'; +import RunnerSingleStat from './runner_single_stat.vue'; +import RunnerCount from './runner_count.vue'; + +/** + * Shows general stats about the runners. + * + * First it checks if there are any runners in this context, and if so, + * shows more details for different status. + */ export default { components: { + RunnerCount, RunnerSingleStat, RunnerUpgradeStatusStats: () => import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), @@ -71,19 +80,21 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-py-6"> - <runner-single-stat - v-for="stat in stats" - :key="stat.key" - :scope="scope" - v-bind="stat.props" - class="gl-px-5" - /> + <runner-count #default="{ count }" :scope="scope" :variables="variables"> + <div v-if="count" class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> - <runner-upgrade-status-stats - class="gl-display-contents" - :scope="scope" - :variables="variables" - /> - </div> + <runner-upgrade-status-stats + class="gl-display-contents" + :scope="scope" + :variables="variables" + /> + </div> + </runner-count> </template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index dfc5f0c4152..31900a1fe89 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -32,6 +32,10 @@ export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted'); export const I18N_STATUS_OFFLINE = s__('Runners|Offline'); export const I18N_STATUS_STALE = s__('Runners|Stale'); +// Executor Status +export const I18N_JOB_STATUS_RUNNING = s__('Runners|Running'); +export const I18N_JOB_STATUS_IDLE = s__('Runners|Idle'); + // Status help popover export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); @@ -82,6 +86,7 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List +export const I18N_NO_DESCRIPTION = s__('Runners|No description'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); @@ -94,6 +99,7 @@ export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details export const I18N_DETAILS = s__('Runners|Details'); +export const I18N_JOBS = s__('Runners|Jobs'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects'); export const I18N_CLEAR_FILTER_PROJECTS = __('Clear'); @@ -134,6 +140,11 @@ export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_STALE = 'STALE'; +// CiRunnerJobExecutionStatus + +export const JOB_STATUS_RUNNING = 'RUNNING'; +export const JOB_STATUS_IDLE = 'IDLE'; + // CiRunnerAccessLevel export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 0dff011daaa..6f72509f599 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -12,6 +12,7 @@ fragment ListItemShared on CiRunner { createdAt contactedAt status(legacyMode: null) + jobExecutionStatus userPermissions { updateRunner deleteRunner diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 91c22923075..57ceaa24b6e 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -82,6 +82,9 @@ export default { apollo: { runners: { query: groupRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index adc832b0600..3dc99baa329 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -176,6 +176,7 @@ export const fromSearchToUrl = ( [PARAM_KEY_RUNNER_TYPE]: [], [PARAM_KEY_MEMBERSHIP]: [], [PARAM_KEY_TAG]: [], + [PARAM_KEY_PAUSED]: [], // Current filters ...filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, |