diff options
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue')
-rw-r--r-- | app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue | 440 |
1 files changed, 440 insertions, 0 deletions
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> |