diff options
Diffstat (limited to 'app/assets/javascripts/pipeline_editor')
13 files changed, 774 insertions, 48 deletions
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue new file mode 100644 index 00000000000..9279273283e --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -0,0 +1,139 @@ +<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: { + defaultBranch: { + type: String, + required: false, + default: '', + }, + defaultMessage: { + type: String, + required: false, + default: '', + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + message: this.defaultMessage, + branch: this.defaultBranch, + openMergeRequest: false, + }; + }, + computed: { + isDefaultBranch() { + return this.branch === this.defaultBranch; + }, + submitDisabled() { + return !(this.message && this.branch); + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { + message: this.message, + branch: this.branch, + openMergeRequest: this.openMergeRequest, + }); + }, + onReset() { + this.$emit('cancel'); + }, + }, + i18n: { + commitMessage: __('Commit message'), + targetBranch: __('Target Branch'), + startMergeRequest: __('Start a %{new_merge_request} with these changes'), + newMergeRequest: __('new merge request'), + commitChanges: __('Commit changes'), + cancel: __('Cancel'), + }, +}; +</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="target-branch-group" + :label="$options.i18n.targetBranch" + label-cols-sm="2" + label-for="target-branch-field" + > + <gl-form-input + id="target-branch-field" + v-model="branch" + class="gl-font-monospace!" + required + /> + <gl-form-checkbox + v-if="!isDefaultBranch" + v-model="openMergeRequest" + data-testid="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-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1" + > + <gl-button + type="submit" + class="js-no-auto-disable" + category="primary" + variant="success" + :disabled="submitDisabled" + :loading="isSaving" + > + {{ $options.i18n.commitChanges }} + </gl-button> + <gl-button type="reset" category="secondary" class="gl-mr-3"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue new file mode 100644 index 00000000000..0d1c214c5b1 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -0,0 +1,143 @@ +<script> +import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import CiLintWarnings from './ci_lint_warnings.vue'; +import CiLintResultsValue from './ci_lint_results_value.vue'; +import CiLintResultsParam from './ci_lint_results_param.vue'; +import { __ } from '~/locale'; + +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}includes%{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, + GlTable, + CiLintWarnings, + CiLintResultsValue, + CiLintResultsParam, + }, + props: { + valid: { + type: Boolean, + required: true, + }, + jobs: { + type: Array, + required: true, + }, + errors: { + type: Array, + required: true, + }, + warnings: { + type: Array, + required: true, + }, + dryRun: { + type: Boolean, + required: true, + }, + lintHelpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isWarningDismissed: false, + }; + }, + computed: { + status() { + return this.valid ? 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 + 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 + 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> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue new file mode 100644 index 00000000000..23808bcb292 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue @@ -0,0 +1,26 @@ +<script> +import { __ } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + +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/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue new file mode 100644 index 00000000000..4929c3206df --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue @@ -0,0 +1,81 @@ +<script> +import { isEmpty } from 'lodash'; + +export default { + props: { + item: { + type: Object, + required: true, + }, + dryRun: { + type: Boolean, + required: true, + }, + }, + computed: { + tagList() { + return this.item.tagList.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> + <pre v-if="scripts.beforeScript.show" data-testid="ci-lint-before-script">{{ + scripts.beforeScript.content + }}</pre> + <pre v-if="scripts.script.show" data-testid="ci-lint-script">{{ scripts.script.content }}</pre> + <pre v-if="scripts.afterScript.show" data-testid="ci-lint-after-script">{{ + scripts.afterScript.content + }}</pre> + + <ul class="gl-list-style-none gl-pl-0 gl-mb-0"> + <li> + <b>{{ __('Tag list:') }}</b> + {{ tagList }} + </li> + <div v-if="!dryRun" data-testid="ci-lint-only-except"> + <li> + <b>{{ __('Only policy:') }}</b> + {{ onlyPolicy }} + </li> + <li> + <b>{{ __('Except policy:') }}</b> + {{ exceptPolicy }} + </li> + </div> + <li> + <b>{{ __('Environment:') }}</b> + {{ item.environment }} + </li> + <li> + <b>{{ __('When:') }}</b> + {{ item.when }} + <b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue new file mode 100644 index 00000000000..ac0332cb0bd --- /dev/null +++ b/app/assets/javascripts/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/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue index a925077c906..22f2a32c9ac 100644 --- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -5,22 +5,10 @@ export default { components: { EditorLite, }, - props: { - value: { - type: String, - required: false, - default: '', - }, - }, }; </script> <template> <div class="gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite - v-model="value" - file-name="*.yml" - :editor-options="{ readOnly: true }" - @editor-ready="$emit('editor-ready')" - /> + <editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js new file mode 100644 index 00000000000..70bab8092c0 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -0,0 +1,2 @@ +export const CI_CONFIG_STATUS_VALID = 'VALID'; +export const CI_CONFIG_STATUS_INVALID = 'INVALID'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql new file mode 100644 index 00000000000..11bca42fd69 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -0,0 +1,26 @@ +mutation commitCIFileMutation( + $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: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content } + ] + } + ) { + commit { + id + } + errors + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql new file mode 100644 index 00000000000..496036f690f --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql @@ -0,0 +1,22 @@ +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 + } + afterScript + stage + tagList + when + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql new file mode 100644 index 00000000000..d65d9892260 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql @@ -0,0 +1,11 @@ +#import "~/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql" + +query getCiConfigData($content: String!) { + ciConfig(content: $content) { + errors + status + stages { + ...PipelineStagesConnection + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 7b8c70ac93e..c1cdb5eb2ee 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,4 +1,5 @@ import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; export const resolvers = { Query: { @@ -11,6 +12,32 @@ 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; -export default resolvers; + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tagList: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + })); + }, + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index ccd7b74064f..8268a907a29 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -10,7 +10,11 @@ import PipelineEditorApp from './pipeline_editor_app.vue'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); - const { projectPath, defaultBranch, ciConfigPath } = el?.dataset; + if (!el) { + return null; + } + + const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset; Vue.use(VueApollo); @@ -24,9 +28,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { render(h) { return h(PipelineEditorApp, { props: { - projectPath, - defaultBranch, ciConfigPath, + commitId, + defaultBranch, + newMergeRequestPath, + projectPath, }, }); }, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 50b946af456..96dc782964b 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,21 +1,38 @@ <script> -import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import TextEditor from './components/text_editor.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import CommitForm from './components/commit/commit_form.vue'; +import TextEditor from './components/text_editor.vue'; +import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; +import getCiConfigData from './graphql/queries/ci_config.graphql'; +import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +const COMMIT_FAILURE = 'COMMIT_FAILURE'; +const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; +const LOAD_FAILURE_NO_REF = 'LOAD_FAILURE_NO_REF'; +const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export default { components: { - GlLoadingIcon, + CommitForm, GlAlert, - GlTabs, + GlLoadingIcon, GlTab, - TextEditor, + GlTabs, PipelineGraph, + TextEditor, }, + mixins: [glFeatureFlagsMixin()], props: { projectPath: { type: String, @@ -26,16 +43,31 @@ export default { required: false, default: null, }, + commitId: { + type: String, + required: false, + default: null, + }, ciConfigPath: { type: String, required: true, }, + newMergeRequestPath: { + type: String, + required: true, + }, }, data() { return { - error: null, + ciConfigData: {}, content: '', + contentModel: '', + currentTabIndex: 0, editorIsReady: false, + failureType: null, + failureReasons: [], + isSaving: false, + showFailureAlert: false, }; }, apollo: { @@ -51,58 +83,212 @@ export default { update(data) { return data?.blobContent?.rawData; }, + result({ data }) { + this.contentModel = data?.blobContent?.rawData ?? ''; + }, error(error) { - this.error = error; + this.handleBlobContentError(error); + }, + }, + ciConfigData: { + query: getCiConfigData, + // If content is not loaded, we can't lint the data + skip: ({ contentModel }) => { + return !contentModel; + }, + variables() { + return { + content: this.contentModel, + }; + }, + update(data) { + const { ciConfigData } = data || {}; + const stageNodes = ciConfigData?.stages?.nodes || []; + const stages = unwrapStagesWithNeeds(stageNodes); + + return { ...ciConfigData, stages }; + }, + error() { + this.reportFailure(LOAD_FAILURE_UNKNOWN); }, }, }, computed: { - loading() { + isBlobContentLoading() { return this.$apollo.queries.content.loading; }, - errorMessage() { - const { message: generalReason, networkError } = this.error ?? {}; - - const { data } = networkError?.response ?? {}; - // 404 for missing file uses `message` - // 400 for a missing ref uses `error` - const networkReason = data?.message ?? data?.error; - - const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError; - return sprintf(this.$options.i18n.errorMessageWithReason, { reason }); + isVisualizationTabLoading() { + return this.$apollo.queries.ciConfigData.loading; + }, + isVisualizeTabActive() { + return this.currentTabIndex === 1; }, - pipelineData() { - // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141 - return {}; + defaultCommitMessage() { + return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE_NO_REF: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_REF], + variant: 'danger', + }; + case LOAD_FAILURE_NO_FILE: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_FILE], + variant: 'danger', + }; + case LOAD_FAILURE_UNKNOWN: + return { + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], + variant: 'danger', + }; + case COMMIT_FAILURE: + return { + text: this.$options.errorTexts[COMMIT_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT_FAILURE], + variant: 'danger', + }; + } }, }, i18n: { - unknownError: __('Unknown Error'), - errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'), + defaultCommitMessage: __('Update %{sourcePath} file'), tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), }, + errorTexts: { + [LOAD_FAILURE_NO_REF]: s__( + 'Pipelines|Repository does not have a default branch, please set one.', + ), + [LOAD_FAILURE_NO_FILE]: s__('Pipelines|No CI file found in this repository, please add one.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + }, + methods: { + handleBlobContentError(error = {}) { + const { networkError } = error; + + const { response } = networkError; + if (response?.status === 404) { + // 404 for missing CI file + this.reportFailure(LOAD_FAILURE_NO_FILE); + } else if (response?.status === 400) { + // 400 for a missing ref when no default branch is set + this.reportFailure(LOAD_FAILURE_NO_REF); + } else { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + } + }, + dismissFailure() { + this.showFailureAlert = false; + }, + reportFailure(type, reasons = []) { + this.showFailureAlert = true; + this.failureType = type; + this.failureReasons = reasons; + }, + redirectToNewMergeRequest(sourceBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: this.defaultBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, + async onCommitSubmit(event) { + this.isSaving = true; + const { message, branch, openMergeRequest } = event; + + try { + const { + data: { + commitCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: commitCiFileMutation, + variables: { + projectPath: this.projectPath, + branch, + startBranch: this.defaultBranch, + message, + filePath: this.ciConfigPath, + content: this.contentModel, + lastCommitId: this.commitId, + }, + }); + + if (errors?.length) { + this.reportFailure(COMMIT_FAILURE, errors); + return; + } + + if (openMergeRequest) { + this.redirectToNewMergeRequest(branch); + } else { + // Refresh the page to ensure commit is updated + refreshCurrentPage(); + } + } catch (error) { + this.reportFailure(COMMIT_FAILURE, [error?.message]); + } finally { + this.isSaving = false; + } + }, + onCommitCancel() { + this.contentModel = this.content; + }, + }, }; </script> <template> <div class="gl-mt-4"> - <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert> + <gl-alert + v-if="showFailureAlert" + :variant="failure.variant" + :dismissible="true" + @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 class="gl-mt-4"> - <gl-loading-icon v-if="loading" size="lg" /> - <div v-else class="file-editor"> - <gl-tabs> + <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> + <div v-else class="file-editor gl-mb-3"> + <gl-tabs v-model="currentTabIndex"> <!-- editor should be mounted when its tab is visible, so the container has a size --> <gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady"> <!-- editor should be mounted only once, when the tab is displayed --> - <text-editor v-model="content" @editor-ready="editorIsReady = true" /> + <text-editor v-model="contentModel" @editor-ready="editorIsReady = true" /> </gl-tab> - <gl-tab :title="$options.i18n.tabGraph"> - <pipeline-graph :pipeline-data="pipelineData" /> + <gl-tab + v-if="glFeatures.ciConfigVisualizationTab" + :title="$options.i18n.tabGraph" + :lazy="!isVisualizeTabActive" + data-testid="visualization-tab" + > + <gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" /> + <pipeline-graph v-else :pipeline-data="ciConfigData" /> </gl-tab> </gl-tabs> </div> + <commit-form + :default-branch="defaultBranch" + :default-message="defaultCommitMessage" + :is-saving="isSaving" + @cancel="onCommitCancel" + @submit="onCommitSubmit" + /> </div> </div> </template> |