diff options
Diffstat (limited to 'app/assets/javascripts/pipeline_editor')
22 files changed, 489 insertions, 191 deletions
diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue new file mode 100644 index 00000000000..7b33d98bca0 --- /dev/null +++ b/app/assets/javascripts/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/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js new file mode 100644 index 00000000000..582fdfea6c9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js @@ -0,0 +1,11 @@ +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_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING]; +export const CODE_SNIPPET_SOURCE_SETTINGS = { + [CODE_SNIPPET_SOURCE_API_FUZZING]: { + datasetKey: 'apiFuzzingConfigurationPath', + docsPath: helpPagePath('user/application_security/api_fuzzing/index'), + }, +}; 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 b088678fee8..f6e88738002 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -124,7 +124,7 @@ export default { type="submit" class="js-no-auto-disable" category="primary" - variant="success" + variant="confirm" :disabled="submitDisabled" :loading="isSaving" > diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index f36b22f33c3..455990f2791 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -1,22 +1,15 @@ <script> -import { GlAlert, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; -import { __, s__ } from '~/locale'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import { s__ } from '~/locale'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; export default { i18n: { viewOnlyMessage: s__('Pipelines|Merged YAML is view only'), }, - errorTexts: { - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), - [DEFAULT]: __('An unknown error occurred.'), - }, components: { EditorLite, - GlAlert, GlIcon, }, inject: ['ciConfigPath'], @@ -32,69 +25,30 @@ export default { }; }, computed: { - failure() { - switch (this.failureType) { - case INVALID_CI_CONFIG: - return this.$options.errorTexts[INVALID_CI_CONFIG]; - default: - return this.$options.errorTexts[DEFAULT]; - } - }, fileGlobalId() { return `${this.ciConfigPath}-${uniqueId()}`; }, - hasError() { - return this.failureType; - }, - isInvalidConfiguration() { - return this.ciConfigData.status === CI_CONFIG_STATUS_INVALID; - }, mergedYaml() { return this.ciConfigData.mergedYaml; }, }, - watch: { - ciConfigData: { - immediate: true, - handler() { - if (this.isInvalidConfiguration) { - this.reportFailure(INVALID_CI_CONFIG); - } else if (this.hasError) { - this.resetFailure(); - } - }, - }, - }, - methods: { - reportFailure(errorType) { - this.failureType = errorType; - }, - resetFailure() { - this.failureType = null; - }, - }, }; </script> <template> <div> - <gl-alert v-if="hasError" variant="danger" :dismissible="false"> - {{ failure }} - </gl-alert> - <div v-else> - <div class="gl-display-flex gl-align-items-center"> - <gl-icon :size="18" name="lock" use-deprecated-sizes 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"> - <editor-lite - ref="editor" - :value="mergedYaml" - :file-name="ciConfigPath" - :file-global-id="fileGlobalId" - :editor-options="{ readOnly: true }" - v-on="$listeners" - /> - </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"> + <editor-lite + ref="editor" + :value="mergedYaml" + :file-name="ciConfigPath" + :file-global-id="fileGlobalId" + :editor-options="{ readOnly: true }" + v-on="$listeners" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 872da88d3e6..a3410d7b837 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -27,7 +27,7 @@ export default { registerCiSchema() { const editorInstance = this.$refs.editor.getEditor(); - editorInstance.use(new CiSchemaExtension()); + editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); editorInstance.registerCiSchema({ projectPath: this.projectPath, projectNamespace: this.projectNamespace, 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 new file mode 100644 index 00000000000..b3eba0fcc19 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -0,0 +1,65 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; +import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; + +export default { + i18n: { + title: s__('Branches'), + fetchError: s__('Unable to fetch branch list for this project.'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlIcon, + }, + inject: ['projectFullPath'], + apollo: { + branches: { + query: getAvailableBranches, + variables() { + return { + projectFullPath: this.projectFullPath, + }; + }, + update(data) { + return data.project?.repository?.branches || []; + }, + error() { + this.$emit('showError', { + type: DEFAULT_FAILURE, + reasons: [this.$options.i18n.fetchError], + }); + }, + }, + currentBranch: { + query: getCurrentBranch, + }, + }, + computed: { + hasBranchList() { + return this.branches?.length > 0; + }, + }, +}; +</script> + +<template> + <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch"> + <gl-dropdown-section-header> + {{ this.$options.i18n.title }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="branch in branches" + :key="branch.name" + :is-checked="currentBranch === branch.name" + :is-check-item="true" + > + <gl-icon name="check" class="gl-visibility-hidden" /> + {{ branch.name }} + </gl-dropdown-item> + </gl-dropdown> +</template> 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 new file mode 100644 index 00000000000..a945fc542a5 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -0,0 +1,21 @@ +<script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import BranchSwitcher from './branch_switcher.vue'; + +export default { + components: { + BranchSwitcher, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + showBranchSwitcher() { + return this.glFeatures.pipelineEditorBranchSwitcher; + }, + }, +}; +</script> +<template> + <div class="gl-mb-5"> + <branch-switcher v-if="showBranchSwitcher" 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 7a35e31e9ce..fefa784f060 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 @@ -31,22 +31,18 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { - ciFileContent: { - type: String, - required: true, - }, ciConfigData: { type: Object, required: true, }, - isCiConfigDataLoading: { + isNewCiConfigFile: { type: Boolean, required: true, }, }, computed: { showPipelineStatus() { - return this.glFeatures.pipelineStatusForPipelineEditor; + return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile; }, // make sure corners are rounded correctly depending on if // pipeline status is rendered @@ -61,11 +57,6 @@ export default { <template> <div class="gl-mb-5"> <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" /> - <validation-segment - :class="validationStyling" - :loading="isCiConfigDataLoading" - :ci-file-content="ciFileContent" - :ci-config="ciConfigData" - /> + <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> </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 b1ea464be99..4a92e106da1 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -1,9 +1,11 @@ <script> import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql'; import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; const POLL_INTERVAL = 10000; @@ -38,13 +40,11 @@ export default { }; }, update: (data) => { - const { id, commitPath = '', shortSha = '', detailedStatus = {} } = - data.project?.pipeline || {}; + const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {}; return { id, commitPath, - shortSha, detailedStatus, }; }, @@ -61,24 +61,34 @@ export default { }, computed: { hasPipelineData() { - return Boolean(this.$apollo.queries.pipeline?.id); + return Boolean(this.pipeline?.id); }, - isQueryLoading() { - return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + 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.hasPipelineData && !this.hasError); + }, + shortSha() { + return truncateSha(this.commitSha); }, status() { return this.pipeline.detailedStatus; }, - pipelineId() { - return getIdFromGraphQLId(this.pipeline.id); - }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL); }, }; </script> <template> <div class="gl-white-space-nowrap gl-max-w-full"> - <template v-if="isQueryLoading"> + <template v-if="showLoadingState"> <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> </template> @@ -88,7 +98,7 @@ export default { </template> <template v-else> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="18" /> + <ci-icon :status="status" :size="16" /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> @@ -110,7 +120,7 @@ export default { target="_blank" data-testid="pipeline-commit" > - {{ pipeline.shortSha }} + {{ shortSha }} </gl-link> </template> </gl-sprintf> 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 541ab74b177..d1534655a00 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -1,8 +1,13 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import { CI_CONFIG_STATUS_VALID } from '../../constants'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '../../constants'; export const i18n = { empty: __( @@ -29,47 +34,51 @@ export default { }, }, props: { - ciFileContent: { - type: String, - required: true, - }, ciConfig: { type: Object, required: false, default: () => ({}), }, - loading: { - type: Boolean, - required: false, - default: false, + }, + apollo: { + appStatus: { + query: getAppStatus, }, }, computed: { isEmpty() { - return !this.ciFileContent; + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; }, isValid() { - return this.ciConfig?.status === CI_CONFIG_STATUS_VALID; + return this.appStatus === EDITOR_APP_STATUS_VALID; }, icon() { - if (this.isValid || this.isEmpty) { - return 'check'; + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return 'check'; + case EDITOR_APP_STATUS_VALID: + return 'check'; + default: + return 'warning-solid'; } - return 'warning-solid'; }, message() { - if (this.isEmpty) { - return this.$options.i18n.empty; - } else if (this.isValid) { - return this.$options.i18n.valid; - } - - // Only display first error as a reason const [reason] = this.ciConfig?.errors || []; - if (reason) { - return sprintf(this.$options.i18n.invalidWithReason, { reason }, false); + + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return this.$options.i18n.empty; + 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; } - return this.$options.i18n.invalid; }, }, }; @@ -77,7 +86,7 @@ export default { <template> <div> - <template v-if="loading"> + <template v-if="isLoading"> <gl-loading-icon inline /> {{ $options.i18n.loading }} </template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue index b27ab9a39d3..f1cf5630fbf 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue @@ -1,6 +1,5 @@ <script> import { flatten } from 'lodash'; -import { CI_CONFIG_STATUS_VALID } from '../../constants'; import CiLintResults from './ci_lint_results.vue'; export default { @@ -13,15 +12,16 @@ export default { }, }, props: { + isValid: { + type: Boolean, + required: true, + }, ciConfig: { type: Object, required: true, }, }, computed: { - isValid() { - return this.ciConfig?.status === CI_CONFIG_STATUS_VALID; - }, stages() { return this.ciConfig?.stages || []; }, @@ -45,9 +45,9 @@ export default { <template> <ci-lint-results - :valid="isValid" - :jobs="jobs" :errors="ciConfig.errors" + :is-valid="isValid" + :jobs="jobs" :lint-help-page-path="lintHelpPagePath" /> </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 index 5d9697c9427..7f6dce05b6e 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -42,34 +42,34 @@ export default { CiLintResultsParam, }, props: { - valid: { - type: Boolean, - required: true, - }, - jobs: { - type: Array, - required: false, - default: () => [], - }, errors: { type: Array, required: false, default: () => [], }, - warnings: { - type: Array, - required: false, - default: () => [], - }, dryRun: { type: Boolean, required: false, default: false, }, + isValid: { + type: Boolean, + required: true, + }, + jobs: { + type: Array, + required: false, + default: () => [], + }, lintHelpPagePath: { type: String, required: true, }, + warnings: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -78,7 +78,7 @@ export default { }, computed: { status() { - return this.valid ? this.$options.correct : this.$options.incorrect; + return this.isValid ? this.$options.correct : this.$options.incorrect; }, shouldShowTable() { return this.errors.length === 0; 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 3bdcf383bee..5acb3355b23 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,15 +1,20 @@ <script> -import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +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 { - CI_CONFIG_STATUS_INVALID, 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, VISUALIZE_TAB, } from '../constants'; +import getAppStatus from '../graphql/queries/client/app_status.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; @@ -21,6 +26,17 @@ export default { tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), tabMergedYaml: s__('Pipelines|View merged YAML'), + 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'), @@ -37,7 +53,6 @@ export default { EditorTab, GlAlert, GlLoadingIcon, - GlTab, GlTabs, PipelineGraph, TextEditor, @@ -52,17 +67,28 @@ export default { type: String, required: true, }, - isCiConfigDataLoading: { - type: Boolean, - required: false, - default: false, + }, + apollo: { + appStatus: { + query: getAppStatus, }, }, computed: { - hasMergedYamlLoadError() { - return ( - !this.ciConfigData?.mergedYaml && this.ciConfigData.status !== CI_CONFIG_STATUS_INVALID - ); + hasAppError() { + // Not an invalid config and with `mergedYaml` data missing + return this.appStatus === EDITOR_APP_STATUS_ERROR; + }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isInvalid() { + return this.appStatus === EDITOR_APP_STATUS_INVALID; + }, + isValid() { + return this.appStatus === EDITOR_APP_STATUS_VALID; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; }, }, methods: { @@ -83,39 +109,48 @@ export default { > <text-editor :value="ciFileContent" v-on="$listeners" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigVisualizationTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.visualization" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabGraph" lazy data-testid="visualization-tab" @click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> <pipeline-graph v-else :pipeline-data="ciConfigData" /> - </gl-tab> + </editor-tab> <editor-tab class="gl-mb-3" + :empty-message="$options.i18n.empty.lint" + :is-empty="isEmpty" :title="$options.i18n.tabLint" data-testid="lint-tab" @click="setCurrentTab($options.tabConstants.LINT_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <ci-lint v-else :ci-config="ciConfigData" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigMergedTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.merge" + :keep-component-mounted="false" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabMergedYaml" lazy data-testid="merged-tab" @click="setCurrentTab($options.tabConstants.MERGED_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <gl-alert v-else-if="hasMergedYamlLoadError" variant="danger" :dismissible="false"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false"> {{ $options.errorTexts.loadMergedYaml }} </gl-alert> <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" /> - </gl-tab> + </editor-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue index b0acd3ca2ee..7c032441a04 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue @@ -1,6 +1,6 @@ <script> -import { GlTab } from '@gitlab/ui'; - +import { GlAlert, 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**. @@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui'; * API is the same as <gl-tab>, for example: * * <gl-tabs> - * <editor-tab title="Tab 1" :lazy="true"> + * <editor-tab title="Tab 1" lazy> * lazily mounted content (gets mounted if this is first tab) * </editor-tab> - * <editor-tab title="Tab 2" :lazy="true"> + * <editor-tab title="Tab 2" lazy> * lazily mounted content * </editor-tab> * <editor-tab title="Tab 3"> @@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui'; * 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. View Lint tab for more details.'), + }, components: { + GlAlert, 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: @@ -40,29 +56,63 @@ export default { }, inheritAttrs: false, props: { + 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: null, + }, + isInvalid: { + type: Boolean, + required: false, + default: null, + }, lazy: { type: Boolean, required: false, default: false, }, + keepComponentMounted: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { isLazy: this.lazy, }; }, + computed: { + slots() { + return Object.keys(this.$slots); + }, + }, methods: { onContentMounted() { // When a child is first mounted make the entire tab - // permanently mounted by setting 'lazy' to false. - this.isLazy = false; + // 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"> - <slot v-for="slot in Object.keys($slots)" :slot="slot" :name="slot"></slot> - <mount-spy @hook:mounted="onContentMounted" /> + <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</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/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 353deafe770..8d0ec6c3e2d 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -1,5 +1,14 @@ -export const CI_CONFIG_STATUS_VALID = 'VALID'; +// 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_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 COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql new file mode 100644 index 00000000000..f162bb11d47 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql @@ -0,0 +1,9 @@ +query getAvailableBranches($projectFullPath: ID!) { + project(fullPath: $projectFullPath) @client { + repository { + branches { + name + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql new file mode 100644 index 00000000000..938f36c7d5c --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql @@ -0,0 +1,3 @@ +query getAppStatus { + appStatus @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 7cc7f92fb60..d3a7387ad2d 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,9 @@ query getPipeline($fullPath: ID!, $sha: String!) { - project(fullPath: $fullPath) @client { + project(fullPath: $fullPath) { pipeline(sha: $sha) { commitPath id iid - shortSha status detailedStatus { detailsPath diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 13f6200693b..caa2a65d424 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -11,25 +11,19 @@ export const resolvers = { }), }; }, - /* eslint-disable @gitlab/require-i18n-strings */ project() { return { __typename: 'Project', - pipeline: { - __typename: 'Pipeline', - commitPath: `/-/commit/aabbccdd`, - id: 'gid://gitlab/Ci::Pipeline/118', - iid: '28', - shortSha: 'aabbccdd', - status: 'SUCCESS', - detailedStatus: { - __typename: 'DetailedStatus', - detailsPath: '/root/sample-ci-project/-/pipelines/118"', - group: 'success', - icon: 'status_success', - text: 'passed', - }, + repository: { + __typename: 'Repository', + branches: [ + { __typename: 'Branch', name: 'master' }, + { __typename: 'Branch', name: 'main' }, + { __typename: 'Branch', name: 'develop' }, + { __typename: 'Branch', name: 'production' }, + { __typename: 'Branch', name: 'test' }, + ], }, }; }, diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index b17ec2d5c25..8a1e26f9bff 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -3,6 +3,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; +import getCommitSha from './graphql/queries/client/commit_sha.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; @@ -35,15 +38,30 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { 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 }), }); + const { cache } = apolloProvider.clients.defaultClient; - apolloProvider.clients.defaultClient.cache.writeData({ + cache.writeQuery({ + query: getCurrentBranch, data: { currentBranch: initialBranchName || defaultBranch, + }, + }); + + cache.writeQuery({ + query: getCommitSha, + data: { commitSha, }, }); @@ -61,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectPath, projectNamespace, ymlHelpPagePath, + configurationPaths, }, render(h) { return h(PipelineEditorApp); diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index c1168979e9f..e0fb38004ec 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,14 +1,29 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import httpStatusCodes from '~/lib/utils/http_status'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue'; +import { + CODE_SNIPPET_SOURCE_URL_PARAM, + CODE_SNIPPET_SOURCES, +} from './components/code_snippet_alert/constants'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; -import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_ERROR, + EDITOR_APP_STATUS_LOADING, + LOAD_FAILURE_UNKNOWN, +} from './constants'; 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 PipelineEditorHome from './pipeline_editor_home.vue'; @@ -20,6 +35,7 @@ export default { GlLoadingIcon, PipelineEditorEmptyState, PipelineEditorHome, + CodeSnippetAlert, }, inject: { ciConfigPath: { @@ -32,7 +48,6 @@ export default { data() { return { ciConfigData: {}, - // Success and failure state failureType: null, failureReasons: [], showStartScreen: false, @@ -43,8 +58,10 @@ export default { showFailureAlert: false, showSuccessAlert: false, successType: null, + codeSnippetCopiedFrom: '', }; }, + apollo: { initialCiFileContent: { query: getBlobContent, @@ -77,8 +94,7 @@ export default { }, ciConfigData: { query: getCiConfigData, - // If content is not loaded, we can't lint the data - skip: ({ currentCiFileContent }) => { + skip({ currentCiFileContent }) { return !currentCiFileContent; }, variables() { @@ -94,9 +110,20 @@ export default { return { ...ciConfig, stages }; }, + result({ data }) { + this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR); + }, error() { this.reportFailure(LOAD_FAILURE_UNKNOWN); }, + watchLoading(isLoading) { + if (isLoading) { + this.setAppStatus(EDITOR_APP_STATUS_LOADING); + } + }, + }, + appStatus: { + query: getAppStatus, }, currentBranch: { query: getCurrentBranch, @@ -115,6 +142,9 @@ export default { isCiConfigDataLoading() { return this.$apollo.queries.ciConfigData.loading; }, + isEmpty() { + return this.currentCiFileContent === ''; + }, failure() { switch (this.failureType) { case LOAD_FAILURE_UNKNOWN: @@ -159,6 +189,16 @@ export default { successTexts: { [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), }, + watch: { + isEmpty(flag) { + if (flag) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); + } + }, + }, + created() { + this.parseCodeSnippetSourceParam(); + }, methods: { handleBlobContentError(error = {}) { const { networkError } = error; @@ -170,6 +210,7 @@ export default { response?.status === httpStatusCodes.NOT_FOUND || response?.status === httpStatusCodes.BAD_REQUEST ) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); this.showStartScreen = true; } else { this.reportFailure(LOAD_FAILURE_UNKNOWN); @@ -183,6 +224,8 @@ export default { this.showSuccessAlert = false; }, reportFailure(type, reasons = []) { + this.setAppStatus(EDITOR_APP_STATUS_ERROR); + window.scrollTo({ top: 0, behavior: 'smooth' }); this.showFailureAlert = true; this.failureType = type; @@ -196,6 +239,9 @@ export default { resetContent() { this.currentCiFileContent = this.lastCommittedContent; }, + setAppStatus(appStatus) { + this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } }); + }, setNewEmptyCiConfigFile() { this.$apollo .getClient() @@ -220,6 +266,20 @@ export default { // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, + 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]), + ); + } + }, + dismissCodeSnippetAlert() { + this.codeSnippetCopiedFrom = ''; + }, }, }; </script> @@ -232,19 +292,35 @@ export default { @createEmptyConfigFile="setNewEmptyCiConfigFile" /> <div v-else> - <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess"> + <code-snippet-alert + v-if="codeSnippetCopiedFrom" + :source="codeSnippetCopiedFrom" + class="gl-mb-5" + @dismiss="dismissCodeSnippetAlert" + /> + <gl-alert + v-if="showSuccessAlert" + :variant="success.variant" + class="gl-mb-5" + @dismiss="dismissSuccess" + > {{ success.text }} </gl-alert> - <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure"> + <gl-alert + v-if="showFailureAlert" + :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> <pipeline-editor-home - :is-ci-config-data-loading="isCiConfigDataLoading" :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" + :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 ef46040153f..adba55f9f4b 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,5 +1,6 @@ <script> import CommitSection from './components/commit/commit_section.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'; @@ -7,6 +8,7 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; export default { components: { CommitSection, + PipelineEditorFileNav, PipelineEditorHeader, PipelineEditorTabs, }, @@ -19,7 +21,7 @@ export default { type: String, required: true, }, - isCiConfigDataLoading: { + isNewCiConfigFile: { type: Boolean, required: true, }, @@ -44,15 +46,14 @@ export default { <template> <div> + <pipeline-editor-file-nav v-on="$listeners" /> <pipeline-editor-header - :ci-file-content="ciFileContent" :ci-config-data="ciConfigData" - :is-ci-config-data-loading="isCiConfigDataLoading" + :is-new-ci-config-file="isNewCiConfigFile" /> <pipeline-editor-tabs :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" - :is-ci-config-data-loading="isCiConfigDataLoading" v-on="$listeners" @set-current-tab="setCurrentTab" /> |