diff options
Diffstat (limited to 'app/assets/javascripts/pipeline_new')
4 files changed, 200 insertions, 117 deletions
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 5070971c563..ff6a354f673 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -9,14 +9,11 @@ import { GlFormSelect, GlFormTextarea, GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, GlSprintf, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; @@ -24,21 +21,27 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; +import RefsDropdown from './refs_dropdown.vue'; + +const i18n = { + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + defaultError: __('Something went wrong on our end. Please try again.'), + refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), + submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'), + warningTitle: __('The form contains the following warning:'), + maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), +}; export default { typeOptions: [ { value: VARIABLE_TYPE, text: __('Variable') }, { value: FILE_TYPE, text: __('File') }, ], - variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', - ), + i18n, formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', - errorTitle: __('Pipeline cannot be run.'), - warningTitle: __('The form contains the following warning:'), - maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), // this height value is used inline on the textarea to match the input field height // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used textAreaStyle: { height: '32px' }, @@ -52,12 +55,9 @@ export default { GlFormSelect, GlFormTextarea, GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, GlSprintf, GlLoadingIcon, + RefsDropdown, }, directives: { SafeHtml }, props: { @@ -77,14 +77,6 @@ export default { type: String, required: true, }, - branches: { - type: Array, - required: true, - }, - tags: { - type: Array, - required: true, - }, settingsLink: { type: String, required: true, @@ -111,11 +103,11 @@ export default { }, data() { return { - searchTerm: '', refValue: { shortName: this.refParam, }, form: {}, + errorTitle: null, error: null, warnings: [], totalWarnings: 0, @@ -125,22 +117,6 @@ export default { }; }, computed: { - lowerCasedSearchTerm() { - return this.searchTerm.toLowerCase(); - }, - filteredBranches() { - return this.branches.filter((branch) => - branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), - ); - }, - filteredTags() { - return this.tags.filter((tag) => - tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), - ); - }, - hasTags() { - return this.tags.length > 0; - }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -148,7 +124,7 @@ export default { return n__('%d warning found:', '%d warnings found:', this.warnings.length); }, summaryMessage() { - return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary; + return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; }, shouldShowWarning() { return this.warnings.length > 0 && !this.isWarningDismissed; @@ -166,6 +142,11 @@ export default { return this.form[this.refFullName]?.descriptions ?? {}; }, }, + watch: { + refValue() { + this.loadConfigVariablesForm(); + }, + }, created() { // this is needed until we add support for ref type in url query strings // ensure default branch is called with full ref on load @@ -174,7 +155,7 @@ export default { this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; } - this.setRefSelected(this.refValue); + this.loadConfigVariablesForm(); }, methods: { addEmptyVariable(refValue) { @@ -213,49 +194,47 @@ export default { this.setVariable(refValue, type, key, value); }); }, - setRefSelected(refValue) { - this.refValue = refValue; - - if (!this.form[this.refFullName]) { - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - } - }, - isSelected(ref) { - return ref.fullName === this.refValue.fullName; - }, removeVariable(index) { this.variables.splice(index, 1); }, canRemove(index) { return index < this.variables.length - 1; }, + loadConfigVariablesForm() { + // Skip when variables already cached in `form` + if (this.form[this.refFullName]) { + return; + } + + this.fetchConfigVariables(this.refFullName || this.refShortName) + .then(({ descriptions, params }) => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions, + }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + }) + .catch(() => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions: {}, + }); + }) + .finally(() => { + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }); + }, fetchConfigVariables(refValue) { this.isLoading = true; @@ -330,11 +309,25 @@ export default { } = err?.response?.data; const [error] = errors; - this.error = error; - this.warnings = warnings; - this.totalWarnings = totalWarnings; + this.reportError({ + title: i18n.submitErrorTitle, + error, + warnings, + totalWarnings, + }); }); }, + onRefsLoadingError(error) { + this.reportError({ title: i18n.refsLoadingErrorTitle }); + + Sentry.captureException(error); + }, + reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) { + this.errorTitle = title; + this.error = error; + this.warnings = warnings; + this.totalWarnings = totalWarnings; + }, }, }; </script> @@ -343,7 +336,7 @@ export default { <gl-form @submit.prevent="createPipeline"> <gl-alert v-if="error" - :title="$options.errorTitle" + :title="errorTitle" :dismissible="false" variant="danger" class="gl-mb-4" @@ -353,7 +346,7 @@ export default { </gl-alert> <gl-alert v-if="shouldShowWarning" - :title="$options.warningTitle" + :title="$options.i18n.warningTitle" variant="warning" class="gl-mb-4" data-testid="run-pipeline-warning-alert" @@ -380,31 +373,7 @@ export default { </details> </gl-alert> <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> - <gl-dropdown :text="refShortName" block> - <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" /> - <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="branch in filteredBranches" - :key="branch.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(branch)" - @click="setRefSelected(branch)" - > - {{ branch.shortName }} - </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="tag in filteredTags" - :key="tag.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(tag)" - @click="setRefSelected(tag)" - > - {{ tag.shortName }} - </gl-dropdown-item> - </gl-dropdown> + <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> </gl-form-group> <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> @@ -465,7 +434,7 @@ export default { </div> <template #description - ><gl-sprintf :message="$options.variablesDescription"> + ><gl-sprintf :message="$options.i18n.variablesDescription"> <template #link="{ content }"> <gl-link :href="settingsLink">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue new file mode 100644 index 00000000000..ed5c659d1df --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue @@ -0,0 +1,113 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants'; +import formatRefs from '../utils/format_refs'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + inject: ['projectRefsEndpoint'], + props: { + value: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + isLoading: false, + searchTerm: '', + branches: [], + tags: [], + }; + }, + computed: { + lowerCasedSearchTerm() { + return this.searchTerm.toLowerCase(); + }, + refShortName() { + return this.value.shortName; + }, + hasTags() { + return this.tags.length > 0; + }, + }, + watch: { + searchTerm() { + this.debouncedLoadRefs(); + }, + }, + methods: { + loadRefs() { + this.isLoading = true; + + axios + .get(this.projectRefsEndpoint, { + params: { + search: this.lowerCasedSearchTerm, + }, + }) + .then(({ data }) => { + // Note: These keys are uppercase in API + const { Branches = [], Tags = [] } = data; + + this.branches = formatRefs(Branches, BRANCH_REF_TYPE); + this.tags = formatRefs(Tags, TAG_REF_TYPE); + }) + .catch((e) => { + this.$emit('loadingError', e); + }) + .finally(() => { + this.isLoading = false; + }); + }, + debouncedLoadRefs: debounce(function debouncedLoadRefs() { + this.loadRefs(); + }, DEBOUNCE_REFS_SEARCH_MS), + setRefSelected(ref) { + this.$emit('input', ref); + }, + isSelected(ref) { + return ref.fullName === this.value.fullName; + }, + }, +}; +</script> +<template> + <gl-dropdown :text="refShortName" block @show.once="loadRefs"> + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isLoading" + :placeholder="__('Search refs')" + /> + <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="branch in branches" + :key="branch.fullName" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(branch)" + @click="setRefSelected(branch)" + > + {{ branch.shortName }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="tag in tags" + :key="tag.fullName" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(tag)" + @click="setRefSelected(tag)" + > + {{ tag.shortName }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index 004bbe7daf4..681755dc6ab 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -1,5 +1,6 @@ export const VARIABLE_TYPE = 'env_var'; export const FILE_TYPE = 'file'; +export const DEBOUNCE_REFS_SEARCH_MS = 250; export const CONFIG_VARIABLES_TIMEOUT = 5000; export const BRANCH_REF_TYPE = 'branch'; export const TAG_REF_TYPE = 'tag'; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 0b85184ec90..a645ea8603b 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,10 +1,13 @@ import Vue from 'vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; -import formatRefs from './utils/format_refs'; export default () => { const el = document.getElementById('js-new-pipeline'); const { + // provide/inject + projectRefsEndpoint, + + // props projectId, pipelinesPath, configVariablesPath, @@ -12,19 +15,18 @@ export default () => { refParam, varParam, fileParam, - branchRefs, - tagRefs, settingsLink, maxWarnings, } = el?.dataset; const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); - const branches = formatRefs(JSON.parse(branchRefs), 'branch'); - const tags = formatRefs(JSON.parse(tagRefs), 'tag'); return new Vue({ el, + provide: { + projectRefsEndpoint, + }, render(createElement) { return createElement(PipelineNewForm, { props: { @@ -35,8 +37,6 @@ export default () => { refParam, variableParams, fileParams, - branches, - tags, settingsLink, maxWarnings: Number(maxWarnings), }, |