diff options
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue')
-rw-r--r-- | app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue | 474 |
1 files changed, 474 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue new file mode 100644 index 00000000000..5692627abef --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -0,0 +1,474 @@ +<script> +import { + GlAlert, + GlIcon, + GlButton, + GlDropdown, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + GlLoadingIcon, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; +import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__, __, n__ } from '~/locale'; +import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; +import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql'; +import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; +import filterVariables from '../utils/filter_variables'; +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}'), + removeVariableLabel: s__('CiVariables|Remove variable'), +}; + +export default { + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, + i18n, + formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', + // 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' }, + components: { + GlAlert, + GlIcon, + GlButton, + GlDropdown, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + GlLoadingIcon, + RefsDropdown, + CcValidationRequiredAlert: () => + import('ee_component/billings/components/cc_validation_required_alert.vue'), + }, + directives: { SafeHtml }, + props: { + pipelinesPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + settingsLink: { + type: String, + required: true, + }, + fileParams: { + type: Object, + required: false, + default: () => ({}), + }, + projectPath: { + type: String, + required: true, + }, + refParam: { + type: String, + required: false, + default: '', + }, + variableParams: { + type: Object, + required: false, + default: () => ({}), + }, + maxWarnings: { + type: Number, + required: true, + }, + }, + data() { + return { + refValue: { + shortName: this.refParam, + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, + }, + form: {}, + errorTitle: null, + error: null, + predefinedValueOptions: {}, + warnings: [], + totalWarnings: 0, + isWarningDismissed: false, + submitted: false, + ccAlertDismissed: false, + }; + }, + apollo: { + ciConfigVariables: { + query: ciConfigVariablesQuery, + // Skip when variables already cached in `form` + skip() { + return Object.keys(this.form).includes(this.refFullName); + }, + variables() { + return { + fullPath: this.projectPath, + ref: this.refQueryParam, + }; + }, + update({ project }) { + return project?.ciConfigVariables || []; + }, + result({ data }) { + const predefinedVars = data?.project?.ciConfigVariables || []; + const params = {}; + const descriptions = {}; + + predefinedVars.forEach(({ description, key, value, valueOptions }) => { + if (description) { + params[key] = value; + descriptions[key] = description; + this.predefinedValueOptions[key] = valueOptions; + } + }); + + Vue.set(this.form, this.refFullName, { descriptions, variables: [] }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + + // 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); + }, + error(error) { + Sentry.captureException(error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.ciConfigVariables.loading; + }, + overMaxWarningsLimit() { + return this.totalWarnings > this.maxWarnings; + }, + warningsSummary() { + return n__('%d warning found:', '%d warnings found:', this.warnings.length); + }, + summaryMessage() { + return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; + }, + shouldShowWarning() { + return this.warnings.length > 0 && !this.isWarningDismissed; + }, + refShortName() { + return this.refValue.shortName; + }, + refFullName() { + return this.refValue.fullName; + }, + refQueryParam() { + return this.refFullName || this.refShortName; + }, + variables() { + return this.form[this.refFullName]?.variables ?? []; + }, + descriptions() { + return this.form[this.refFullName]?.descriptions ?? {}; + }, + ccRequiredError() { + return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; + }, + }, + methods: { + addEmptyVariable(refValue) { + const { variables } = this.form[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariable(refValue, type, key, value) { + const { variables } = this.form[refValue]; + + const variable = variables.find((v) => v.key === key); + if (variable) { + variable.type = type; + variable.value = value; + } else { + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + key, + value, + variable_type: type, + }); + } + }, + setVariableAttribute(key, attribute, value) { + const { variables } = this.form[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable[attribute] = value; + }, + setVariableParams(refValue, type, paramsObj) { + Object.entries(paramsObj).forEach(([key, value]) => { + this.setVariable(refValue, type, key, value); + }); + }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, + removeVariable(index) { + this.variables.splice(index, 1); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + async createPipeline() { + this.submitted = true; + this.ccAlertDismissed = false; + + const { data } = await this.$apollo.mutate({ + mutation: createPipelineMutation, + variables: { + endpoint: this.pipelinesPath, + // send shortName as fall back for query params + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + ref: this.refQueryParam, + variablesAttributes: filterVariables(this.variables), + }, + }); + + const { id, errors, totalWarnings, warnings } = data.createPipeline; + + if (id) { + redirectTo(`${this.pipelinesPath}/${id}`); + return; + } + + // always re-enable submit button + this.submitted = false; + const [error] = errors; + + 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; + }, + dismissError() { + this.ccAlertDismissed = true; + this.error = null; + }, + }, +}; +</script> + +<template> + <gl-form @submit.prevent="createPipeline"> + <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" /> + <gl-alert + v-else-if="error" + :title="errorTitle" + :dismissible="false" + variant="danger" + class="gl-mb-4" + data-testid="run-pipeline-error-alert" + > + <span v-safe-html="error"></span> + </gl-alert> + <gl-alert + v-if="shouldShowWarning" + :title="$options.i18n.warningTitle" + variant="warning" + class="gl-mb-4" + data-testid="run-pipeline-warning-alert" + @dismiss="isWarningDismissed = true" + > + <details> + <summary> + <gl-sprintf :message="summaryMessage"> + <template #total> + {{ totalWarnings }} + </template> + <template #warningsDisplayed> + {{ maxWarnings }} + </template> + </gl-sprintf> + </summary> + <p + v-for="(warning, index) in warnings" + :key="`warning-${index}`" + data-testid="run-pipeline-warning" + > + {{ warning }} + </p> + </details> + </gl-alert> + <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> + <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> + </gl-form-group> + + <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> + + <gl-form-group v-else :label="s__('Pipeline|Variables')"> + <div + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" + > + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-type" + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableAttribute(variable.key, 'variable_type', type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + @change="addEmptyVariable(refFullName)" + /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + data-qa-selector="ci_variable_value_dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + data-qa-selector="ci_variable_value_dropdown_item" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-textarea + v-else + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + :aria-label="$options.i18n.removeVariableLabel" + @click="removeVariable(index)" + > + <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" /> + <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span> + </gl-button> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> + </div> + + <template #description + ><gl-sprintf :message="$options.i18n.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <div class="gl-pt-5 gl-display-flex"> + <gl-button + type="submit" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-3" + data-qa-selector="run_pipeline_button" + data-testid="run_pipeline_button" + :disabled="submitted" + >{{ s__('Pipeline|Run pipeline') }}</gl-button + > + <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> + </div> + </gl-form> +</template> |