diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 16:16:36 +0300 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /app/assets/javascripts/pipeline_editor | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipeline_editor')
21 files changed, 475 insertions, 85 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 index f1fe8cf10fd..905a5f2d271 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -36,6 +36,11 @@ export default { required: false, default: false, }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -52,6 +57,13 @@ export default { return !(this.message && this.targetBranch); }, }, + watch: { + scrollToCommitForm(flag) { + if (flag) { + this.scrollIntoView(); + } + }, + }, methods: { onSubmit() { this.$emit('submit', { @@ -63,6 +75,10 @@ export default { onReset() { this.$emit('cancel'); }, + scrollIntoView() { + this.$el.scrollIntoView({ behavior: 'smooth' }); + this.$emit('scrolled-to-commit-form'); + }, }, i18n: { commitMessage: __('Commit message'), diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 0308cd9c565..14c11099756 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -10,9 +10,8 @@ import { import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql'; import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql'; +import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql'; import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; -import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql'; -import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql'; import CommitForm from './commit_form.vue'; @@ -41,18 +40,24 @@ export default { required: false, default: '', }, + isNewCiConfigFile: { + type: Boolean, + required: false, + default: false, + }, + scrollToCommitForm: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { commit: {}, - isNewCiConfigFile: false, isSaving: false, }; }, apollo: { - isNewCiConfigFile: { - query: getIsNewCiConfigFile, - }, currentBranch: { query: getCurrentBranch, }, @@ -96,10 +101,10 @@ export default { content: this.ciFileContent, lastCommitId: this.commitSha, }, - update(store, { data }) { + update(_, { data }) { const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath; if (pipelineEtag) { - store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } }); + this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag }); } }, }); @@ -146,6 +151,8 @@ export default { :current-branch="currentBranch" :default-message="defaultCommitMessage" :is-saving="isSaving" + :scroll-to-commit-form="scrollToCommitForm" + v-on="$listeners" @cancel="onCommitCancel" @submit="onCommitSubmit" /> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index ff1e0b6388f..d7594fb318a 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -2,6 +2,7 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { experiment } from '~/experimentation/utils'; import { DRAWER_EXPANDED_KEY } from '../../constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; @@ -53,12 +54,23 @@ export default { }, methods: { setInitialExpandState() { + let isExpanded; + + experiment('pipeline_editor_walkthrough', { + control: () => { + isExpanded = true; + }, + candidate: () => { + isExpanded = false; + }, + }); + // We check in the local storage and if no value is defined, we want the default // to be true. We want to explicitly set it to true here so that the drawer // animates to open on load. const localValue = localStorage.getItem(this.$options.localDrawerKey); if (localValue === null) { - this.isExpanded = true; + this.isExpanded = isExpanded; } }, setTopPosition() { diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 68065cc3c73..baf1d17b233 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -12,7 +12,7 @@ import { produce } from 'immer'; import { fetchPolicies } from '~/lib/graphql'; import { historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { __ } from '~/locale'; import { BRANCH_PAGINATION_LIMIT, BRANCH_SEARCH_DEBOUNCE, @@ -25,9 +25,9 @@ import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/l export default { i18n: { - dropdownHeader: s__('Switch branch'), - title: s__('Branches'), - fetchError: s__('Unable to fetch branch list for this project.'), + dropdownHeader: __('Switch branch'), + title: __('Branches'), + fetchError: __('Unable to fetch branch list for this project.'), }, inputDebounce: BRANCH_SEARCH_DEBOUNCE, components: { @@ -43,14 +43,25 @@ export default { }, 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 { + branchSelected: null, availableBranches: [], filteredBranches: [], isSearchingBranches: false, @@ -101,10 +112,17 @@ export default { isBranchesLoading() { return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; }, - showBranchSwitcher() { + enableBranchSwitcher() { return this.branches.length > 0 || this.searchTerm.length > 0; }, }, + watch: { + shouldLoadNewBranch(flag) { + if (flag) { + this.changeBranch(this.branchSelected); + } + }, + }, methods: { availableBranchesQueryVars(varsOverride = {}) { if (this.searchTerm.length > 0) { @@ -149,11 +167,7 @@ export default { }) .catch(this.showFetchError); }, - async selectBranch(newBranch) { - if (newBranch === this.currentBranch) { - return; - } - + async changeBranch(newBranch) { this.updateCurrentBranch(newBranch); const updatedPath = setUrlParams({ branch_name: newBranch }); historyPushState(updatedPath); @@ -164,6 +178,19 @@ export default { 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(); @@ -203,11 +230,11 @@ export default { <template> <gl-dropdown - v-if="showBranchSwitcher" 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" diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 551a0430fbf..83b074dd55c 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -5,10 +5,26 @@ export default { components: { BranchSwitcher, }, + props: { + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, + shouldLoadNewBranch: { + type: Boolean, + required: false, + default: false, + }, + }, }; </script> <template> <div class="gl-mb-4"> - <branch-switcher v-on="$listeners" /> + <branch-switcher + :has-unsaved-changes="hasUnsavedChanges" + :should-load-new-branch="shouldLoadNewBranch" + v-on="$listeners" + /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index fcc31f087ff..ec6ee52b6b2 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -63,6 +63,7 @@ export default { v-if="showPipelineStatus" :commit-sha="commitSha" :class="$options.pipelineStatusClasses" + v-on="$listeners" /> <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> </div> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index 75b1398a3c2..25a78aab933 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,17 +1,52 @@ <script> +import { __ } from '~/locale'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/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, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), }, + 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 || []; + }, pipelinePath() { return this.pipeline.detailedStatus?.detailsPath || ''; }, @@ -38,12 +73,29 @@ export default { }; }); }, + showDownstreamPipelines() { + return this.downstreamPipelines.length > 0; + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, }, }; </script> <template> <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5"> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="[upstreamPipeline]" + data-testid="pipeline-editor-mini-graph-upstream" + /> <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" /> + <linked-pipelines-mini-list + v-if="showDownstreamPipelines" + :triggered="downstreamPipelines" + :pipeline-path="pipelinePath" + data-testid="pipeline-editor-mini-graph-downstream" + /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index a1fa2147994..6fe1459c80c 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +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'; @@ -10,7 +10,6 @@ import { toggleQueryPollingByVisibility, } from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -21,6 +20,10 @@ export const i18n = { `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, ), viewBtn: s__('Pipeline|View pipeline'), + viewCommit: s__('Pipeline|View commit'), + pipelineNotTriggeredMsg: s__( + 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.', + ), }; export default { @@ -34,7 +37,9 @@ export default { GlSprintf, PipelineEditorMiniGraph, }, - mixins: [glFeatureFlagMixin()], + directives: { + GlTooltip: GlTooltipDirective, + }, inject: ['projectFullPath'], props: { commitSha: { @@ -59,12 +64,13 @@ export default { }; }, update(data) { - const { id, commitPath = '', detailedStatus = {}, stages, status } = + const { id, iid, commit = {}, detailedStatus = {}, stages, status } = data.project?.pipeline || {}; return { id, - commitPath, + iid, + commit, detailedStatus, stages, status, @@ -73,20 +79,36 @@ export default { result(res) { if (res.data?.project?.pipeline) { this.hasError = false; + } else { + this.hasError = true; + this.pipelineNotTriggered = true; } }, error() { this.hasError = true; + this.networkError = true; }, pollInterval: POLL_INTERVAL, }, }, data() { return { + networkError: false, + pipelineNotTriggered: false, 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); }, @@ -126,13 +148,19 @@ export default { </div> </template> <template v-else-if="hasError"> - <div> + <div v-if="networkError"> <gl-icon class="gl-mr-auto" name="warning-solid" /> <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> </div> + <div v-else> + <gl-icon class="gl-mr-auto" name="information-o" /> + <span data-testid="pipeline-not-triggered-error-msg"> + {{ $options.i18n.pipelineNotTriggeredMsg }} + </span> + </div> </template> <template v-else> - <div> + <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> @@ -144,25 +172,21 @@ export default { <template #status>{{ status.text }}</template> <template #commit> <gl-link - :href="pipeline.commitPath" - class="commit-sha gl-font-weight-normal" - target="_blank" + v-gl-tooltip.hover + :href="pipeline.commit.webPath" + :title="$options.i18n.viewCommit" data-testid="pipeline-commit" > - {{ shortSha }} + {{ commitText }} </gl-link> </template> </gl-sprintf> </span> </div> <div class="gl-display-flex gl-flex-wrap"> - <pipeline-editor-mini-graph - v-if="glFeatures.pipelineEditorMiniGraph" - :pipeline="pipeline" - /> + <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> <gl-button class="gl-mt-2 gl-md-mt-0" - target="_blank" category="secondary" variant="confirm" :href="status.detailsPath" diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 8bffd893473..611b78b3c5e 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -75,7 +75,7 @@ export default { return this.$options.i18n.valid; default: // Only display first error as a reason - return this.ciConfig?.errors.length > 0 + return this.ciConfig?.errors?.length > 0 ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false) : this.$options.i18n.invalid; } diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index f7c9f10ea46..0cd0d17d944 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -3,15 +3,18 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { CREATE_TAB, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, LINT_TAB, MERGED_TAB, + TAB_QUERY_PARAM, + TABS_INDEX, VISUALIZE_TAB, } from '../constants'; import getAppStatus from '../graphql/queries/client/app_status.graphql'; @@ -20,6 +23,7 @@ import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; import EditorTab from './ui/editor_tab.vue'; +import WalkthroughPopover from './walkthrough_popover.vue'; export default { i18n: { @@ -42,6 +46,9 @@ export default { errorTexts: { loadMergedYaml: s__('Pipelines|Could not load merged YAML content'), }, + query: { + TAB_QUERY_PARAM, + }, tabConstants: { CREATE_TAB, LINT_TAB, @@ -58,6 +65,8 @@ export default { GlTabs, PipelineGraph, TextEditor, + GitlabExperiment, + WalkthroughPopover, }, mixins: [glFeatureFlagsMixin()], props: { @@ -74,6 +83,10 @@ export default { required: false, default: '', }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, }, apollo: { appStatus: { @@ -81,9 +94,8 @@ export default { }, }, computed: { - hasAppError() { - // Not an invalid config and with `mergedYaml` data missing - return this.appStatus === EDITOR_APP_STATUS_ERROR; + isMergedYamlAvailable() { + return this.ciConfigData?.mergedYaml; }, isEmpty() { return this.appStatus === EDITOR_APP_STATUS_EMPTY; @@ -98,22 +110,51 @@ export default { return this.appStatus === EDITOR_APP_STATUS_LOADING; }, }, + created() { + const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM); + + if (tabQueryParam && TABS_INDEX[tabQueryParam]) { + this.setDefaultTab(tabQueryParam); + } + }, methods: { setCurrentTab(tabName) { 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"> + <gl-tabs + class="file-editor gl-mb-3" + :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)" > + <gitlab-experiment name="pipeline_editor_walkthrough"> + <template #candidate> + <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> + </template> + </gitlab-experiment> <ci-editor-header /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> @@ -154,7 +195,7 @@ export default { @click="setCurrentTab($options.tabConstants.MERGED_TAB)" > <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> - <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false"> + <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" /> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue index 091b202e10b..7206f19d060 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -8,6 +8,7 @@ import { DEFAULT_FAILURE, DEFAULT_SUCCESS, LOAD_FAILURE_UNKNOWN, + PIPELINE_FAILURE, } from '../../constants'; import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue'; import { @@ -24,6 +25,7 @@ export default { [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.'), }, successTexts: { [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), @@ -74,6 +76,11 @@ export default { text: this.$options.errorTexts[COMMIT_FAILURE], variant: 'danger', }; + case PIPELINE_FAILURE: + return { + text: this.$options.errorTexts[PIPELINE_FAILURE], + variant: 'danger', + }; default: return { text: this.$options.errorTexts[DEFAULT_FAILURE], diff --git a/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue b/app/assets/javascripts/pipeline_editor/components/walkthrough_popover.vue new file mode 100644 index 00000000000..5742b11b841 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/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" /> + {{ this.$options.i18n.ctaText }} + </gl-button> + </div> + </gl-popover> +</template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index bb03fa126a5..a2eaeeef286 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -5,24 +5,37 @@ 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_ERROR = 'ERROR'; export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID; 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_LOADING, + EDITOR_APP_STATUS_VALID, +]; + export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; 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 LINT_TAB = 'LINT_TAB'; export const MERGED_TAB = 'MERGED_TAB'; export const VISUALIZE_TAB = 'VISUALIZE_TAB'; -export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB]; +export const TABS_INDEX = { + [CREATE_TAB]: '0', + [VISUALIZE_TAB]: '1', + [LINT_TAB]: '2', + [MERGED_TAB]: '3', +}; +export const TAB_QUERY_PARAM = 'tab'; export const COMMIT_ACTION_CREATE = 'CREATE'; export const COMMIT_ACTION_UPDATE = 'UPDATE'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql new file mode 100644 index 00000000000..7487e328668 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateAppStatus($appStatus: String) { + updateAppStatus(appStatus: $appStatus) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql new file mode 100644 index 00000000000..9025f00b343 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql @@ -0,0 +1,3 @@ +mutation updatePipelineEtag($pipelineEtag: String) { + updatePipelineEtag(pipelineEtag: $pipelineEtag) @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql deleted file mode 100644 index 8c2ca276f50..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/is_new_ci_config_file.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getIsNewCiConfigFile { - isNewCiConfigFile @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql index 0c3653a2880..34e98ae3eb3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -1,10 +1,13 @@ query getPipeline($fullPath: ID!, $sha: String!) { project(fullPath: $fullPath) { pipeline(sha: $sha) { - commitPath id iid status + commit { + title + webPath + } detailedStatus { detailsPath icon diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index a34652b1495..e4965e00af3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,7 +1,8 @@ -import produce from 'immer'; import axios from '~/lib/utils/axios_utils'; +import getAppStatus from './queries/client/app_status.graphql'; import getCurrentBranchQuery from './queries/client/current_branch.graphql'; import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './queries/client/pipeline_etag.graphql'; export const resolvers = { Mutation: { @@ -31,20 +32,28 @@ export const resolvers = { __typename: 'CiLintContent', })); }, + updateAppStatus: (_, { appStatus }, { cache }) => { + cache.writeQuery({ + query: getAppStatus, + data: { appStatus }, + }); + }, updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ query: getCurrentBranchQuery, - data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => { - draftData.currentBranch = currentBranch; - }), + data: { currentBranch }, }); }, updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ query: getLastCommitBranchQuery, - data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => { - draftData.lastCommitBranch = lastCommitBranch; - }), + data: { lastCommitBranch }, + }); + }, + updatePipelineEtag: (_, { pipelineEtag }, { cache }) => { + cache.writeQuery({ + query: getPipelineEtag, + data: { pipelineEtag }, }); }, }, diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 89b9091e6f9..4f7f2743aca 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -3,8 +3,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +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.graphql'; +import getAppStatus from './graphql/queries/client/app_status.graphql'; import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql'; import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; import { resolvers } from './graphql/resolvers'; @@ -59,12 +61,18 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true, - assumeImmutableResults: true, }), }); const { cache } = apolloProvider.clients.defaultClient; cache.writeQuery({ + query: getAppStatus, + data: { + appStatus: EDITOR_APP_STATUS_LOADING, + }, + }); + + cache.writeQuery({ query: getCurrentBranch, data: { currentBranch: initialBranchName || defaultBranch, @@ -93,6 +101,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciExamplesHelpPagePath, ciHelpPagePath, configurationPaths, + dataMethod: 'graphql', defaultBranch, emptyStateIllustrationPath, helpPaths, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index e70417145ab..68db5d8078f 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -12,16 +12,16 @@ import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue import { COMMIT_SHA_POLL_INTERVAL, EDITOR_APP_STATUS_EMPTY, - EDITOR_APP_STATUS_ERROR, + EDITOR_APP_VALID_STATUSES, EDITOR_APP_STATUS_LOADING, LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; +import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; import getAppStatus from './graphql/queries/client/app_status.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; -import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.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'; @@ -44,23 +44,23 @@ export default { }, data() { return { - starterTemplateName: STARTER_TEMPLATE_NAME, ciConfigData: {}, + currentCiFileContent: '', failureType: null, failureReasons: [], initialCiFileContent: '', isFetchingCommitSha: false, isNewCiConfigFile: false, lastCommittedContent: '', - currentCiFileContent: '', - successType: null, + shouldSkipStartScreen: false, + showFailure: false, showStartScreen: false, showSuccess: false, - showFailure: false, starterTemplate: '', + starterTemplateName: STARTER_TEMPLATE_NAME, + successType: null, }; }, - apollo: { initialCiFileContent: { fetchPolicy: fetchPolicies.NETWORK_ONLY, @@ -103,7 +103,11 @@ export default { } if (!hasCIFile) { - this.showStartScreen = true; + 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 @@ -141,10 +145,10 @@ export default { return { ...ciConfig, stages }; }, result({ data }) { - this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR); + this.setAppStatus(data?.ciConfig?.status); }, - error() { - this.reportFailure(LOAD_FAILURE_UNKNOWN); + error(err) { + this.reportFailure(LOAD_FAILURE_UNKNOWN, [String(err)]); }, watchLoading(isLoading) { if (isLoading) { @@ -179,9 +183,6 @@ export default { currentBranch: { query: getCurrentBranch, }, - isNewCiConfigFile: { - query: getIsNewCiConfigFile, - }, starterTemplate: { query: getTemplate, variables() { @@ -232,6 +233,7 @@ export default { }, mounted() { this.loadTemplateFromURL(); + this.checkShouldSkipStartScreen(); }, methods: { hideFailure() { @@ -245,8 +247,6 @@ export default { await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { - this.setAppStatus(EDITOR_APP_STATUS_ERROR); - window.scrollTo({ top: 0, behavior: 'smooth' }); this.showFailure = true; this.failureType = type; @@ -261,12 +261,12 @@ export default { this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { - this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } }); + if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { + this.$apollo.mutate({ mutation: updateAppStatus, variables: { appStatus } }); + } }, setNewEmptyCiConfigFile() { - this.$apollo - .getClient() - .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: true } }); + this.isNewCiConfigFile = true; this.showStartScreen = false; }, showErrorAlert({ type, reasons = [] }) { @@ -283,9 +283,7 @@ export default { this.reportSuccess(type); if (this.isNewCiConfigFile) { - this.$apollo - .getClient() - .writeQuery({ query: getIsNewCiConfigFile, data: { isNewCiConfigFile: false } }); + this.isNewCiConfigFile = false; } // Keep track of the latest committed content to know @@ -300,6 +298,10 @@ export default { this.setNewEmptyCiConfigFile(); } }, + checkShouldSkipStartScreen() { + const params = queryToObject(window.location.search); + this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); + }, }, }; </script> @@ -325,8 +327,9 @@ export default { <pipeline-editor-home :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" - :is-new-ci-config-file="isNewCiConfigFile" :commit-sha="commitSha" + :has-unsaved-changes="hasUnsavedChanges" + :is-new-ci-config-file="isNewCiConfigFile" @commit="updateOnCommit" @resetContent="resetContent" @showError="showErrorAlert" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index ba567023946..8e8f31a4acc 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,14 +1,31 @@ <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 PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; -import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; +import { CREATE_TAB } 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, PipelineEditorHeader, @@ -28,6 +45,11 @@ export default { required: false, default: '', }, + hasUnsavedChanges: { + type: Boolean, + required: false, + default: false, + }, isNewCiConfigFile: { type: Boolean, required: true, @@ -36,40 +58,82 @@ export default { data() { return { currentTab: CREATE_TAB, + scrollToCommitForm: false, + shouldLoadNewBranch: false, + showSwitchBranchModal: false, }; }, computed: { showCommitForm() { - return TABS_WITH_COMMIT_FORM.includes(this.currentTab); + return this.currentTab === CREATE_TAB; }, }, methods: { + closeBranchModal() { + this.showSwitchBranchModal = false; + }, + handleConfirmSwitchBranch() { + this.showSwitchBranchModal = true; + }, + switchBranch() { + this.showSwitchBranchModal = false; + this.shouldLoadNewBranch = true; + }, setCurrentTab(tabName) { this.currentTab = tabName; }, + setScrollToCommitForm(newValue = true) { + this.scrollToCommitForm = newValue; + }, }, }; </script> <template> <div class="gl-pr-9 gl-transition-medium gl-w-full"> - <pipeline-editor-file-nav v-on="$listeners" /> + <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" + :should-load-new-branch="shouldLoadNewBranch" + @select-branch="handleConfirmSwitchBranch" + v-on="$listeners" + /> <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" + :is-new-ci-config-file="isNewCiConfigFile" v-on="$listeners" @set-current-tab="setCurrentTab" + @walkthrough-popover-cta-clicked="setScrollToCommitForm" /> <commit-section v-if="showCommitForm" + :ref="$options.commitSectionRef" :ci-file-content="ciFileContent" :commit-sha="commitSha" + :is-new-ci-config-file="isNewCiConfigFile" + :scroll-to-commit-form="scrollToCommitForm" + @scrolled-to-commit-form="setScrollToCommitForm(false)" v-on="$listeners" /> <pipeline-editor-drawer /> |