diff options
Diffstat (limited to 'app/assets/javascripts/ci')
53 files changed, 3459 insertions, 63 deletions
diff --git a/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js new file mode 100644 index 00000000000..574a5e7fd99 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js @@ -0,0 +1,262 @@ +import $ from 'jquery'; +import SecretValues from '~/behaviors/secret_values'; +import CreateItemDropdown from '~/create_item_dropdown'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; + +const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments'); + +function createEnvironmentItem(value) { + return { + title: value === '*' ? ALL_ENVIRONMENTS_STRING : value, + id: value, + text: value === '*' ? s__('CiVariable|* (All environments)') : value, + }; +} + +export default class VariableList { + constructor({ container, formField, maskableRegex }) { + this.$container = $(container); + this.formField = formField; + this.maskableRegex = new RegExp(maskableRegex); + this.environmentDropdownMap = new WeakMap(); + + this.inputMap = { + id: { + selector: '.js-ci-variable-input-id', + default: '', + }, + variable_type: { + selector: '.js-ci-variable-input-variable-type', + default: 'env_var', + }, + key: { + selector: '.js-ci-variable-input-key', + default: '', + }, + secret_value: { + selector: '.js-ci-variable-input-value', + default: '', + }, + protected: { + selector: '.js-ci-variable-input-protected', + // use `attr` instead of `data` as we don't want the value to be + // converted. we need the value as a string. + default: $('.js-ci-variable-input-protected').attr('data-default'), + }, + masked: { + selector: '.js-ci-variable-input-masked', + // use `attr` instead of `data` as we don't want the value to be + // converted. we need the value as a string. + default: $('.js-ci-variable-input-masked').attr('data-default'), + }, + environment_scope: { + // We can't use a `.js-` class here because + // deprecated_jquery_dropdown replaces the <input> and doesn't copy over the class + // See https://gitlab.com/gitlab-org/gitlab-foss/issues/42458 + selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`, + default: '*', + }, + _destroy: { + selector: '.js-ci-variable-input-destroy', + default: '', + }, + }; + + this.secretValues = new SecretValues({ + container: this.$container[0], + valueSelector: '.js-row:not(:last-child) .js-secret-value', + placeholderSelector: '.js-row:not(:last-child) .js-secret-value-placeholder', + }); + } + + init() { + this.bindEvents(); + this.secretValues.init(); + } + + bindEvents() { + this.$container.find('.js-row').each((index, rowEl) => { + this.initRow(rowEl); + }); + + this.$container.on('click', '.js-row-remove-button', (e) => { + e.preventDefault(); + this.removeRow($(e.currentTarget).closest('.js-row')); + }); + + const inputSelector = Object.keys(this.inputMap) + .map((name) => this.inputMap[name].selector) + .join(','); + + // Remove any empty rows except the last row + this.$container.on('blur', inputSelector, (e) => { + const $row = $(e.currentTarget).closest('.js-row'); + + if ($row.is(':not(:last-child)') && !this.checkIfRowTouched($row)) { + this.removeRow($row); + } + }); + + this.$container.on('input trigger-change', inputSelector, (e) => { + // Always make sure there is an empty last row + const $lastRow = this.$container.find('.js-row').last(); + + if (this.checkIfRowTouched($lastRow)) { + this.insertRow($lastRow); + } + + // If masked, validate value against regex + this.validateMaskability($(e.currentTarget).closest('.js-row')); + }); + } + + initRow(rowEl) { + const $row = $(rowEl); + + // Reset the resizable textarea + $row.find(this.inputMap.secret_value.selector).css('height', ''); + + const $environmentSelect = $row.find('.js-variable-environment-toggle'); + if ($environmentSelect.length) { + const createItemDropdown = new CreateItemDropdown({ + $dropdown: $environmentSelect, + defaultToggleLabel: ALL_ENVIRONMENTS_STRING, + fieldName: `${this.formField}[variables_attributes][][environment_scope]`, + getData: (term, callback) => callback(this.getEnvironmentValues()), + createNewItemFromValue: createEnvironmentItem, + onSelect: () => { + // Refresh the other dropdowns in the variable list + // so they have the new value we just picked + this.refreshDropdownData(); + + $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change'); + }, + }); + + // Clear out any data that might have been left-over from the row clone + createItemDropdown.clearDropdown(); + + this.environmentDropdownMap.set($row[0], createItemDropdown); + } + } + + insertRow($row) { + const $rowClone = $row.clone(); + $rowClone.removeAttr('data-is-persisted'); + + // Reset the inputs to their defaults + Object.keys(this.inputMap).forEach((name) => { + const entry = this.inputMap[name]; + $rowClone.find(entry.selector).val(entry.default); + }); + + // Close any dropdowns + $rowClone.find('.dropdown-menu.show').each((index, $dropdown) => { + $dropdown.classList.remove('show'); + }); + + this.initRow($rowClone); + + $row.after($rowClone); + } + + removeRow(row) { + const $row = $(row); + const isPersisted = parseBoolean($row.attr('data-is-persisted')); + + if (isPersisted) { + $row.hide(); + $row + // eslint-disable-next-line no-underscore-dangle + .find(this.inputMap._destroy.selector) + .val(true); + } else { + $row.remove(); + } + + // Refresh the other dropdowns in the variable list + // so any value with the variable deleted is gone + this.refreshDropdownData(); + } + + checkIfRowTouched($row) { + return Object.keys(this.inputMap).some((name) => { + // Row should not qualify as touched if only switches have been touched + if (['protected', 'masked'].includes(name)) return false; + + const entry = this.inputMap[name]; + const $el = $row.find(entry.selector); + return $el.length && $el.val() !== entry.default; + }); + } + + validateMaskability($row) { + const invalidInputClass = 'gl-field-error-outline'; + + const variableValue = $row.find(this.inputMap.secret_value.selector).val(); + const isValueMaskable = this.maskableRegex.test(variableValue) || variableValue === ''; + const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true'; + + // Show a validation error if the user wants to mask an unmaskable variable value + $row + .find(this.inputMap.secret_value.selector) + .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); + $row + .find('.js-secret-value-placeholder') + .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); + $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable); + } + + toggleEnableRow(isEnabled = true) { + this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); + this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); + } + + hideValues() { + this.secretValues.updateDom(false); + } + + getAllData() { + // Ignore the last empty row because we don't want to try persist + // a blank variable and run into validation problems. + const validRows = this.$container.find('.js-row').toArray().slice(0, -1); + + return validRows.map((rowEl) => { + const resultant = {}; + Object.keys(this.inputMap).forEach((name) => { + const entry = this.inputMap[name]; + const $input = $(rowEl).find(entry.selector); + if ($input.length) { + resultant[name] = $input.val(); + } + }); + + return resultant; + }); + } + + getEnvironmentValues() { + const valueMap = this.$container + .find(this.inputMap.environment_scope.selector) + .toArray() + .reduce( + (prevValueMap, envInput) => ({ + ...prevValueMap, + [envInput.value]: envInput.value, + }), + {}, + ); + + return Object.keys(valueMap).map(createEnvironmentItem); + } + + refreshDropdownData() { + this.$container.find('.js-row').each((index, rowEl) => { + const environmentDropdown = this.environmentDropdownMap.get(rowEl); + if (environmentDropdown) { + environmentDropdown.refreshData(); + } + }); + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue new file mode 100644 index 00000000000..719696f682e --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue @@ -0,0 +1,36 @@ +<script> +import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants'; +import getAdminVariables from '../graphql/queries/variables.query.graphql'; +import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql'; +import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql'; +import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql'; +import CiVariableShared from './ci_variable_shared.vue'; + +export default { + components: { + CiVariableShared, + }, + mutationData: { + [ADD_MUTATION_ACTION]: addAdminVariable, + [UPDATE_MUTATION_ACTION]: updateAdminVariable, + [DELETE_MUTATION_ACTION]: deleteAdminVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.ciVariables, + query: getAdminVariables, + }, + }, +}; +</script> + +<template> + <ci-variable-shared + :are-scoped-variables-available="false" + component-name="InstanceVariables" + :hide-environment-scope="true" + :mutation-data="$options.mutationData" + :refetch-after-mutation="true" + :query-data="$options.queryData" + /> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue new file mode 100644 index 00000000000..7387a490177 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue @@ -0,0 +1,81 @@ +<script> +import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { convertEnvironmentScope } from '../utils'; + +export default { + name: 'CiEnvironmentsDropdown', + components: { + GlDropdownDivider, + GlDropdownItem, + GlCollapsibleListbox, + }, + props: { + environments: { + type: Array, + required: true, + }, + selectedEnvironmentScope: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + selectedEnvironment: '', + searchTerm: '', + }; + }, + computed: { + composedCreateButtonLabel() { + return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); + }, + filteredEnvironments() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + + return this.environments + .filter((environment) => { + return environment.toLowerCase().includes(lowerCasedSearchTerm); + }) + .map((environment) => ({ + value: environment, + text: environment, + })); + }, + shouldRenderCreateButton() { + return this.searchTerm && !this.environments.includes(this.searchTerm); + }, + environmentScopeLabel() { + return convertEnvironmentScope(this.selectedEnvironmentScope); + }, + }, + methods: { + selectEnvironment(selected) { + this.$emit('select-environment', selected); + this.selectedEnvironment = selected; + }, + createEnvironmentScope() { + this.$emit('create-environment-scope', this.searchTerm); + this.selectEnvironment(this.searchTerm); + }, + }, +}; +</script> +<template> + <gl-collapsible-listbox + v-model="selectedEnvironment" + searchable + :items="filteredEnvironments" + :toggle-text="environmentScopeLabel" + @search="searchTerm = $event.trim()" + @select="selectEnvironment" + > + <template v-if="shouldRenderCreateButton" #footer> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope"> + {{ composedCreateButtonLabel }} + </gl-dropdown-item> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue new file mode 100644 index 00000000000..4466a6a8081 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue @@ -0,0 +1,54 @@ +<script> +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + GRAPHQL_GROUP_TYPE, + UPDATE_MUTATION_ACTION, +} from '../constants'; +import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; +import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql'; +import CiVariableShared from './ci_variable_shared.vue'; + +export default { + components: { + CiVariableShared, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['groupPath', 'groupId'], + computed: { + areScopedVariablesAvailable() { + return this.glFeatures.groupScopedCiVariables; + }, + graphqlId() { + return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId); + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: addGroupVariable, + [UPDATE_MUTATION_ACTION]: updateGroupVariable, + [DELETE_MUTATION_ACTION]: deleteGroupVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.group?.ciVariables, + query: getGroupVariables, + }, + }, +}; +</script> + +<template> + <ci-variable-shared + :id="graphqlId" + :are-scoped-variables-available="areScopedVariablesAvailable" + component-name="GroupVariables" + entity="group" + :full-path="groupPath" + :mutation-data="$options.mutationData" + :query-data="$options.queryData" + /> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue new file mode 100644 index 00000000000..6326940148a --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue @@ -0,0 +1,56 @@ +<script> +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + GRAPHQL_PROJECT_TYPE, + UPDATE_MUTATION_ACTION, +} from '../constants'; +import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql'; +import getProjectVariables from '../graphql/queries/project_variables.query.graphql'; +import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql'; +import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql'; +import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql'; +import CiVariableShared from './ci_variable_shared.vue'; + +export default { + components: { + CiVariableShared, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['projectFullPath', 'projectId'], + computed: { + graphqlId() { + return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId); + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: addProjectVariable, + [UPDATE_MUTATION_ACTION]: updateProjectVariable, + [DELETE_MUTATION_ACTION]: deleteProjectVariable, + }, + queryData: { + ciVariables: { + lookup: (data) => data?.project?.ciVariables, + query: getProjectVariables, + }, + environments: { + lookup: (data) => data?.project?.environments, + query: getProjectEnvironments, + }, + }, +}; +</script> + +<template> + <ci-variable-shared + :id="graphqlId" + :are-scoped-variables-available="true" + component-name="ProjectVariables" + entity="project" + :full-path="projectFullPath" + :mutation-data="$options.mutationData" + :query-data="$options.queryData" + /> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..3f25e3df305 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,15 @@ +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + }, +}; + +export const awsTokenList = Object.keys(awsTokens); diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue new file mode 100644 index 00000000000..967125c7b0a --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue @@ -0,0 +1,502 @@ +<script> +import { + GlAlert, + GlButton, + GlCollapse, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormTextarea, + GlIcon, + GlLink, + GlModal, + GlSprintf, +} from '@gitlab/ui'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; + +import { + allEnvironments, + AWS_TOKEN_CONSTANTS, + ADD_CI_VARIABLE_MODAL_ID, + AWS_TIP_DISMISSED_COOKIE_NAME, + AWS_TIP_MESSAGE, + CONTAINS_VARIABLE_REFERENCE_MESSAGE, + defaultVariableState, + ENVIRONMENT_SCOPE_LINK_TITLE, + EVENT_LABEL, + EVENT_ACTION, + EXPANDED_VARIABLES_NOTE, + EDIT_VARIABLE_ACTION, + VARIABLE_ACTIONS, + variableOptions, +} from '../constants'; +import { createJoinedEnvironments } from '../utils'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; + +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); + +export default { + modalId: ADD_CI_VARIABLE_MODAL_ID, + tokens: awsTokens, + tokenList: awsTokenList, + awsTipMessage: AWS_TIP_MESSAGE, + containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, + environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedVariablesNote: EXPANDED_VARIABLES_NOTE, + components: { + CiEnvironmentsDropdown, + GlAlert, + GlButton, + GlCollapse, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormTextarea, + GlIcon, + GlLink, + GlModal, + GlSprintf, + }, + mixins: [trackingMixin], + inject: [ + 'awsLogoSvgPath', + 'awsTipCommandsLink', + 'awsTipDeployLink', + 'awsTipLearnLink', + 'containsVariableReferenceLink', + 'environmentScopeLink', + 'isProtectedByDefault', + 'maskedEnvironmentVariablesLink', + 'maskableRegex', + 'protectedEnvironmentVariablesLink', + ], + props: { + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, + environments: { + type: Array, + required: false, + default: () => [], + }, + hideEnvironmentScope: { + type: Boolean, + required: false, + default: false, + }, + mode: { + type: String, + required: true, + validator(val) { + return VARIABLE_ACTIONS.includes(val); + }, + }, + selectedVariable: { + type: Object, + required: false, + default: () => {}, + }, + variables: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + newEnvironments: [], + isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + validationErrorEventProperty: '', + variable: { ...defaultVariableState, ...this.selectedVariable }, + }; + }, + computed: { + canMask() { + const regex = RegExp(this.maskableRegex); + return regex.test(this.variable.value); + }, + canSubmit() { + return this.variableValidationState && this.variable.key !== '' && this.variable.value !== ''; + }, + containsVariableReference() { + const regex = /\$/; + return regex.test(this.variable.value) && this.isExpanded; + }, + displayMaskedError() { + return !this.canMask && this.variable.masked; + }, + isEditing() { + return this.mode === EDIT_VARIABLE_ACTION; + }, + isExpanded() { + return !this.variable.raw; + }, + isTipVisible() { + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); + }, + joinedEnvironments() { + return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments); + }, + maskedFeedback() { + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + maskedState() { + if (this.displayMaskedError) { + return false; + } + return true; + }, + modalActionText() { + return this.isEditing ? __('Update variable') : __('Add variable'); + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + const validator = this.$options.tokens?.[this.variable.key]?.validation; + + if (validator) { + return validator(this.variable.value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + return this.variable.value === '' || (this.tokenValidationState && this.maskedState); + }, + }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, + methods: { + addVariable() { + this.$emit('add-variable', this.variable); + }, + createEnvironmentScope(env) { + this.newEnvironments.push(env); + }, + deleteVariable() { + this.$emit('delete-variable', this.variable); + }, + updateVariable() { + this.$emit('update-variable', this.variable); + }, + dismissTip() { + setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); + this.isTipDismissed = true; + }, + deleteVarAndClose() { + this.deleteVariable(); + this.hideModal(); + }, + hideModal() { + this.$refs.modal.hide(); + }, + onShow() { + this.setVariableProtectedByDefault(); + }, + resetModalHandler() { + this.resetVariableData(); + this.resetValidationErrorEvents(); + + this.$emit('hideModal'); + }, + resetVariableData() { + this.variable = { ...defaultVariableState }; + }, + setEnvironmentScope(scope) { + this.variable = { ...this.variable, environmentScope: scope }; + }, + setVariableRaw(expanded) { + this.variable = { ...this.variable, raw: !expanded }; + }, + setVariableProtected() { + this.variable = { ...this.variable, protected: true }; + }, + updateOrAddVariable() { + if (this.isEditing) { + this.updateVariable(); + } else { + this.addVariable(); + } + this.hideModal(); + }, + setVariableProtectedByDefault() { + if (this.isProtectedByDefault && !this.isEditing) { + this.setVariableProtected(); + } + }, + trackVariableValidationErrors() { + const property = this.getTrackingErrorProperty(); + if (!this.validationErrorEventProperty && property) { + this.track(EVENT_ACTION, { property }); + this.validationErrorEventProperty = property; + } + }, + getTrackingErrorProperty() { + let property; + if (this.variable.value?.length && !property) { + if (this.displayMaskedError && this.maskableRegex?.length) { + const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); + const regex = new RegExp(supportedChars, 'g'); + property = this.variable.value.replace(regex, ''); + } + if (this.containsVariableReference) { + property = '$'; + } + } + + return property; + }, + resetValidationErrorEvents() { + this.validationErrorEventProperty = ''; + }, + }, + defaultScope: allEnvironments.text, + variableOptions, +}; +</script> + +<template> + <gl-modal + ref="modal" + :modal-id="$options.modalId" + :title="modalActionText" + static + lazy + @hidden="resetModalHandler" + @shown="onShow" + > + <form> + <gl-form-combobox + v-model="variable.key" + :token-list="$options.tokenList" + :label-text="__('Key')" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + /> + + <gl-form-group + :label="__('Value')" + label-for="ci-variable-value" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" + > + <gl-form-textarea + id="ci-variable-value" + ref="valueField" + v-model="variable.value" + :state="variableValidationState" + rows="3" + max-rows="10" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + class="gl-font-monospace!" + spellcheck="false" + /> + <p + v-if="variable.raw" + class="gl-mt-2 gl-mb-0 text-secondary" + data-testid="raw-variable-tip" + > + {{ __('Variable value will be evaluated as raw string.') }} + </p> + </gl-form-group> + + <div class="gl-display-flex"> + <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="gl-w-half gl-mr-5"> + <gl-form-select + id="ci-variable-type" + v-model="variable.variableType" + :options="$options.variableOptions" + /> + </gl-form-group> + + <template v-if="!hideEnvironmentScope"> + <gl-form-group + label-for="ci-variable-env" + class="gl-w-half" + data-testid="environment-scope" + > + <template #label> + {{ __('Environment scope') }} + <gl-link + :title="$options.environmentScopeLinkTitle" + :href="environmentScopeLink" + target="_blank" + data-testid="environment-scope-link" + > + <gl-icon name="question" :size="12" /> + </gl-link> + </template> + <ci-environments-dropdown + v-if="areScopedVariablesAvailable" + :selected-environment-scope="variable.environmentScope" + :environments="joinedEnvironments" + @select-environment="setEnvironmentScope" + @create-environment-scope="createEnvironmentScope" + /> + + <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly /> + </gl-form-group> + </template> + </div> + + <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> + <gl-form-checkbox + v-model="variable.protected" + class="gl-mb-0" + data-testid="ci-variable-protected-checkbox" + :data-is-protected-checked="variable.protected" + > + {{ __('Protect variable') }} + <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> + <gl-icon name="question" :size="12" /> + </gl-link> + <p class="gl-mt-2 text-secondary"> + {{ __('Export variable to pipelines running on protected branches and tags only.') }} + </p> + </gl-form-checkbox> + <gl-form-checkbox + ref="masked-ci-variable" + v-model="variable.masked" + data-testid="ci-variable-masked-checkbox" + > + {{ __('Mask variable') }} + <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> + <gl-icon name="question" :size="12" /> + </gl-link> + <p class="gl-mt-2 text-secondary"> + {{ __('Variable will be masked in job logs.') }} + <span + :class="{ + 'bold text-plain': displayMaskedError, + }" + > + {{ __('Requires values to meet regular expression requirements.') }}</span + > + <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ + __('More information') + }}</gl-link> + </p> + </gl-form-checkbox> + <gl-form-checkbox + ref="expanded-ci-variable" + :checked="isExpanded" + data-testid="ci-variable-expanded-checkbox" + @change="setVariableRaw" + > + {{ __('Expand variable reference') }} + <gl-link target="_blank" :href="containsVariableReferenceLink"> + <gl-icon name="question" :size="12" /> + </gl-link> + <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> + <gl-sprintf :message="$options.expandedVariablesNote"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-form-checkbox> + </gl-form-group> + </form> + <gl-collapse :visible="isTipVisible"> + <gl-alert + :title="__('Deploying to AWS is easy with GitLab')" + variant="tip" + data-testid="aws-guidance-tip" + @dismiss="dismissTip" + > + <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap"> + <div> + <p> + <gl-sprintf :message="$options.awsTipMessage"> + <template #deployLink="{ content }"> + <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> + </template> + <template #commandsLink="{ content }"> + <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p> + <gl-button + :href="awsTipLearnLink" + target="_blank" + category="secondary" + variant="confirm" + class="gl-overflow-wrap-break" + >{{ __('Learn more about deploying to AWS') }}</gl-button + > + </p> + </div> + <img + class="gl-mt-3" + :alt="__('Amazon Web Services Logo')" + :src="awsLogoSvgPath" + height="32" + /> + </div> + </gl-alert> + </gl-collapse> + <gl-alert + v-if="containsVariableReference" + :title="__('Value might contain a variable reference')" + :dismissible="false" + variant="warning" + data-testid="contains-variable-reference" + > + <gl-sprintf :message="$options.containsVariableReferenceMessage"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #docsLink="{ content }"> + <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <template #modal-footer> + <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> + <gl-button + v-if="isEditing" + ref="deleteCiVariable" + variant="danger" + category="secondary" + data-qa-selector="ci_variable_delete_button" + @click="deleteVarAndClose" + >{{ __('Delete variable') }}</gl-button + > + <gl-button + ref="updateOrAddVariable" + :disabled="!canSubmit" + variant="confirm" + category="primary" + data-testid="ciUpdateOrAddVariableBtn" + data-qa-selector="ci_variable_save_button" + @click="updateOrAddVariable" + >{{ modalActionText }} + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue new file mode 100644 index 00000000000..3c6114b38ce --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -0,0 +1,108 @@ +<script> +import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants'; +import CiVariableTable from './ci_variable_table.vue'; +import CiVariableModal from './ci_variable_modal.vue'; + +export default { + components: { + CiVariableTable, + CiVariableModal, + }, + props: { + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, + entity: { + type: String, + required: false, + default: '', + }, + environments: { + type: Array, + required: false, + default: () => [], + }, + hideEnvironmentScope: { + type: Boolean, + required: false, + default: false, + }, + isLoading: { + type: Boolean, + required: false, + }, + maxVariableLimit: { + type: Number, + required: false, + default: 0, + }, + variables: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedVariable: {}, + mode: null, + }; + }, + computed: { + showModal() { + return VARIABLE_ACTIONS.includes(this.mode); + }, + }, + methods: { + addVariable(variable) { + this.$emit('add-variable', variable); + }, + deleteVariable(variable) { + this.$emit('delete-variable', variable); + }, + updateVariable(variable) { + this.$emit('update-variable', variable); + }, + hideModal() { + this.mode = null; + }, + setSelectedVariable(variable = null) { + if (!variable) { + this.selectedVariable = {}; + this.mode = ADD_VARIABLE_ACTION; + } else { + this.selectedVariable = variable; + this.mode = EDIT_VARIABLE_ACTION; + } + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-12"> + <ci-variable-table + :entity="entity" + :is-loading="isLoading" + :max-variable-limit="maxVariableLimit" + :variables="variables" + @set-selected-variable="setSelectedVariable" + /> + <ci-variable-modal + v-if="showModal" + :are-scoped-variables-available="areScopedVariablesAvailable" + :environments="environments" + :hide-environment-scope="hideEnvironmentScope" + :variables="variables" + :mode="mode" + :selected-variable="selectedVariable" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @hideModal="hideModal" + @update-variable="updateVariable" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue new file mode 100644 index 00000000000..6e39bda0b07 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -0,0 +1,242 @@ +<script> +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + environmentFetchErrorText, + genericMutationErrorText, + variableFetchErrorText, +} from '../constants'; +import CiVariableSettings from './ci_variable_settings.vue'; + +export default { + components: { + CiVariableSettings, + }, + inject: ['endpoint'], + props: { + areScopedVariablesAvailable: { + required: true, + type: Boolean, + }, + componentName: { + required: true, + type: String, + }, + entity: { + required: false, + type: String, + default: '', + }, + fullPath: { + required: false, + type: String, + default: null, + }, + hideEnvironmentScope: { + type: Boolean, + required: false, + default: false, + }, + id: { + required: false, + type: String, + default: null, + }, + mutationData: { + required: true, + type: Object, + validator: (obj) => { + const hasValidKeys = Object.keys(obj).includes( + ADD_MUTATION_ACTION, + UPDATE_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + ); + + const hasValidValues = Object.values(obj).reduce((acc, val) => { + return acc && typeof val === 'object'; + }, true); + + return hasValidKeys && hasValidValues; + }, + }, + refetchAfterMutation: { + required: false, + type: Boolean, + default: false, + }, + queryData: { + required: true, + type: Object, + validator: (obj) => { + const { ciVariables, environments } = obj; + const hasCiVariablesKey = Boolean(ciVariables); + let hasCorrectEnvData = true; + + const hasCorrectVariablesData = + typeof ciVariables?.lookup === 'function' && typeof ciVariables.query === 'object'; + + if (environments) { + hasCorrectEnvData = + typeof environments?.lookup === 'function' && typeof environments.query === 'object'; + } + + return hasCiVariablesKey && hasCorrectVariablesData && hasCorrectEnvData; + }, + }, + }, + data() { + return { + ciVariables: [], + hasNextPage: false, + isInitialLoading: true, + isLoadingMoreItems: false, + loadingCounter: 0, + maxVariableLimit: 0, + pageInfo: {}, + }; + }, + apollo: { + ciVariables: { + query() { + return this.queryData.ciVariables.query; + }, + variables() { + return { + fullPath: this.fullPath || undefined, + }; + }, + update(data) { + return this.queryData.ciVariables.lookup(data)?.nodes || []; + }, + result({ data }) { + this.maxVariableLimit = this.queryData.ciVariables.lookup(data)?.limit || 0; + + this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo; + this.hasNextPage = this.pageInfo?.hasNextPage || false; + + // Because graphQL has a limit of 100 items, + // we batch load all the variables by making successive queries + // to keep the same UX. As a safeguard, we make sure that we cannot go over + // 20 consecutive API calls, which means 2000 variables loaded maximum. + if (!this.hasNextPage) { + this.isLoadingMoreItems = false; + } else if (this.loadingCounter < 20) { + this.hasNextPage = false; + this.fetchMoreVariables(); + this.loadingCounter += 1; + } else { + createAlert({ message: this.$options.tooManyCallsError }); + reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {}); + } + }, + error() { + this.isLoadingMoreItems = false; + this.hasNextPage = false; + createAlert({ message: variableFetchErrorText }); + }, + watchLoading(flag) { + if (!flag) { + this.isInitialLoading = false; + } + }, + }, + environments: { + query() { + return this.queryData?.environments?.query || {}; + }, + skip() { + return !this.queryData?.environments?.query; + }, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return mapEnvironmentNames(this.queryData.environments.lookup(data)?.nodes); + }, + error() { + createAlert({ message: environmentFetchErrorText }); + }, + }, + }, + computed: { + isLoading() { + return ( + (this.$apollo.queries.ciVariables.loading && this.isInitialLoading) || + this.$apollo.queries.environments.loading || + this.isLoadingMoreItems + ); + }, + }, + methods: { + addVariable(variable) { + this.variableMutation(ADD_MUTATION_ACTION, variable); + }, + deleteVariable(variable) { + this.variableMutation(DELETE_MUTATION_ACTION, variable); + }, + fetchMoreVariables() { + this.isLoadingMoreItems = true; + + this.$apollo.queries.ciVariables.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + }, + }); + }, + updateVariable(variable) { + this.variableMutation(UPDATE_MUTATION_ACTION, variable); + }, + async variableMutation(mutationAction, variable) { + try { + const currentMutation = this.mutationData[mutationAction]; + + const { data } = await this.$apollo.mutate({ + mutation: currentMutation, + variables: { + endpoint: this.endpoint, + fullPath: this.fullPath || undefined, + id: this.id || undefined, + variable, + }, + }); + + if (data.ciVariableMutation?.errors?.length) { + const { errors } = data.ciVariableMutation; + createAlert({ message: errors[0] }); + } else if (this.refetchAfterMutation) { + // The writing to cache for admin variable is not working + // because there is no ID in the cache at the top level. + // We therefore need to manually refetch. + this.$apollo.queries.ciVariables.refetch(); + } + } catch (e) { + createAlert({ message: genericMutationErrorText }); + } + }, + }, + i18n: { + tooManyCallsError: __('Maximum number of variables loaded (2000)'), + }, +}; +</script> + +<template> + <ci-variable-settings + :are-scoped-variables-available="areScopedVariablesAvailable" + :entity="entity" + :hide-environment-scope="hideEnvironmentScope" + :is-loading="isLoading" + :variables="ciVariables" + :max-variable-limit="maxVariableLimit" + :environments="environments" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @update-variable="updateVariable" + /> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue new file mode 100644 index 00000000000..345a8def49d --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue @@ -0,0 +1,298 @@ +<script> +import { + GlAlert, + GlButton, + GlLoadingIcon, + GlModalDirective, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + ADD_CI_VARIABLE_MODAL_ID, + DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, + EXCEEDS_VARIABLE_LIMIT_TEXT, + MAXIMUM_VARIABLE_LIMIT_REACHED, + variableText, +} from '../constants'; +import { convertEnvironmentScope } from '../utils'; + +export default { + modalId: ADD_CI_VARIABLE_MODAL_ID, + fields: [ + { + key: 'variableType', + label: s__('CiVariables|Type'), + thClass: 'gl-w-10p', + }, + { + key: 'key', + label: s__('CiVariables|Key'), + tdClass: 'text-plain', + sortable: true, + }, + { + key: 'value', + label: s__('CiVariables|Value'), + thClass: 'gl-w-15p', + }, + { + key: 'options', + label: s__('CiVariables|Options'), + thClass: 'gl-w-10p', + }, + { + key: 'environmentScope', + label: s__('CiVariables|Environments'), + }, + { + key: 'actions', + label: '', + tdClass: 'text-right', + thClass: 'gl-w-5p', + }, + ], + components: { + GlAlert, + GlButton, + GlLoadingIcon, + GlTable, + }, + directives: { + GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + entity: { + type: String, + required: false, + default: '', + }, + isLoading: { + type: Boolean, + required: true, + }, + maxVariableLimit: { + type: Number, + required: true, + }, + variables: { + type: Array, + required: true, + }, + }, + data() { + return { + areValuesHidden: true, + }; + }, + computed: { + exceedsVariableLimit() { + return this.maxVariableLimit > 0 && this.variables.length >= this.maxVariableLimit; + }, + exceedsVariableLimitText() { + if (this.exceedsVariableLimit && this.entity) { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { + entity: this.entity, + currentVariableCount: this.variables.length, + maxVariableLimit: this.maxVariableLimit, + }); + } + + return DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT; + }, + showAlert() { + return !this.isLoading && this.exceedsVariableLimit; + }, + valuesButtonText() { + return this.areValuesHidden ? __('Reveal values') : __('Hide values'); + }, + isTableEmpty() { + return !this.variables || this.variables.length === 0; + }, + fields() { + return this.$options.fields; + }, + variablesWithOptions() { + return this.variables?.map((item, index) => ({ + ...item, + options: this.getOptions(item), + index, + })); + }, + }, + methods: { + convertEnvironmentScopeValue(env) { + return convertEnvironmentScope(env); + }, + generateTypeText(item) { + return variableText[item.variableType]; + }, + toggleHiddenState() { + this.areValuesHidden = !this.areValuesHidden; + }, + setSelectedVariable(index = -1) { + this.$emit('set-selected-variable', this.variables[index] ?? null); + }, + getOptions(item) { + const options = []; + if (item.protected) { + options.push(s__('CiVariables|Protected')); + } + if (item.masked) { + options.push(s__('CiVariables|Masked')); + } + if (!item.raw) { + options.push(s__('CiVariables|Expanded')); + } + return options.join(', '); + }, + }, + maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED, +}; +</script> + +<template> + <div class="ci-variable-table" data-testid="ci-variable-table"> + <gl-loading-icon v-if="isLoading" /> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> + <gl-table + v-if="!isLoading" + :fields="fields" + :items="variablesWithOptions" + tbody-tr-class="js-ci-variable-row" + data-qa-selector="ci_variable_table_content" + sort-by="key" + sort-direction="asc" + stacked="lg" + table-class="text-secondary" + fixed + show-empty + sort-icon-left + no-sort-reset + > + <template #table-colgroup="scope"> + <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> + </template> + <template #cell(variableType)="{ item }"> + {{ generateTypeText(item) }} + </template> + <template #cell(key)="{ item }"> + <div + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + > + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + >{{ item.key }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :title="__('Copy key')" + :data-clipboard-text="item.key" + :aria-label="__('Copy to clipboard')" + /> + </div> + </template> + <template #cell(value)="{ item }"> + <div + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + > + <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span> + <span + v-else + :id="`ci-variable-value-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + data-testid="revealedValue" + >{{ item.value }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :title="__('Copy value')" + :data-clipboard-text="item.value" + :aria-label="__('Copy to clipboard')" + /> + </div> + </template> + <template #cell(options)="{ item }"> + <span data-testid="ci-variable-table-row-options">{{ item.options }}</span> + </template> + <template #cell(environmentScope)="{ item }"> + <div + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + > + <span + :id="`ci-variable-env-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-word-break-word" + >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + class="gl-my-n3 gl-ml-2" + :title="__('Copy environment')" + :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)" + :aria-label="__('Copy to clipboard')" + /> + </div> + </template> + <template #cell(actions)="{ item }"> + <gl-button + v-gl-modal-directive="$options.modalId" + icon="pencil" + :aria-label="__('Edit')" + data-qa-selector="edit_ci_variable_button" + @click="setSelectedVariable(item.index)" + /> + </template> + <template #empty> + <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0"> + {{ __('There are no variables yet.') }} + </p> + </template> + </gl-table> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> + <div class="ci-variable-actions gl-display-flex gl-mt-5"> + <gl-button + v-gl-modal-directive="$options.modalId" + class="gl-mr-3" + data-qa-selector="add_ci_variable_button" + variant="confirm" + category="primary" + :aria-label="__('Add')" + :disabled="exceedsVariableLimit" + @click="setSelectedVariable()" + >{{ __('Add variable') }}</gl-button + > + <gl-button + v-if="!isTableEmpty" + data-qa-selector="reveal_ci_variable_value_button" + @click="toggleHiddenState" + >{{ valuesButtonText }}</gl-button + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js new file mode 100644 index 00000000000..828d0724d93 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -0,0 +1,106 @@ +import { __, s__ } from '~/locale'; + +export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; + +// This const will be deprecated once we remove VueX from the section +export const displayText = { + variableText: __('Variable'), + fileText: __('File'), + allEnvironmentsText: __('All (default)'), +}; + +export const variableTypes = { + envType: 'ENV_VAR', + fileType: 'FILE', +}; + +// Once REST is removed, we won't need `types` +export const types = { + variableType: 'env_var', + fileType: 'file', +}; + +export const allEnvironments = { + type: '*', + text: __('All (default)'), +}; + +// Once REST is removed, we won't need `types` key +export const variableText = { + [types.variableType]: __('Variable'), + [types.fileType]: __('File'), + [variableTypes.envType]: __('Variable'), + [variableTypes.fileType]: __('File'), +}; + +export const variableOptions = [ + { value: variableTypes.envType, text: variableText[variableTypes.envType] }, + { value: variableTypes.fileType, text: variableText[variableTypes.fileType] }, +]; + +export const defaultVariableState = { + environmentScope: allEnvironments.type, + key: '', + masked: false, + protected: false, + raw: false, + value: '', + variableType: variableTypes.envType, +}; + +// eslint-disable-next-line @gitlab/require-i18n-strings +export const groupString = 'Group'; +// eslint-disable-next-line @gitlab/require-i18n-strings +export const instanceString = 'Instance'; +// eslint-disable-next-line @gitlab/require-i18n-strings +export const projectString = 'Project'; + +export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed'; +export const AWS_TIP_MESSAGE = __( + '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', +); + +export const EVENT_LABEL = 'ci_variable_modal'; +export const EVENT_ACTION = 'validation_error'; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; +export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY]; + +export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( + 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', +); + +export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); +export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.', +); +export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', +); +export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__( + 'CiVariables|Maximum number of variables reached.', +); + +export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; +export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; +export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION]; + +export const GRAPHQL_PROJECT_TYPE = 'Project'; +export const GRAPHQL_GROUP_TYPE = 'Group'; + +export const ADD_MUTATION_ACTION = 'add'; +export const UPDATE_MUTATION_ACTION = 'update'; +export const DELETE_MUTATION_ACTION = 'delete'; + +export const EXPANDED_VARIABLES_NOTE = __( + '%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.', +); + +export const environmentFetchErrorText = __( + 'There was an error fetching the environments information.', +); +export const genericMutationErrorText = __('Something went wrong on our end. Please try again.'); +export const variableFetchErrorText = __('There was an error fetching the variables.'); diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql new file mode 100644 index 00000000000..a28ca4eebc9 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql @@ -0,0 +1,7 @@ +fragment BaseCiVariable on CiVariable { + __typename + id + key + value + variableType +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql new file mode 100644 index 00000000000..d6f3ddf086f --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) { + ciVariableMutation: addAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql new file mode 100644 index 00000000000..c00c8fb2a26 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) { + ciVariableMutation: deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql new file mode 100644 index 00000000000..d7b7cb77291 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql @@ -0,0 +1,16 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) { + ciVariableMutation: updateAdminVariable(variable: $variable, endpoint: $endpoint) @client { + ciVariables { + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + protected + masked + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql new file mode 100644 index 00000000000..45109762e80 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql @@ -0,0 +1,3 @@ +mutation addProjectEnvironment($environment: CiEnvironment, $fullPath: ID!) { + addProjectEnvironment(environment: $environment, fullPath: $fullPath) @client +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql new file mode 100644 index 00000000000..0dbb6c891fd --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -0,0 +1,26 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { + ciVariableMutation: addGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql new file mode 100644 index 00000000000..b5d007237c8 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -0,0 +1,26 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { + ciVariableMutation: deleteGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql new file mode 100644 index 00000000000..4ffc091b490 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -0,0 +1,26 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { + ciVariableMutation: updateGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql new file mode 100644 index 00000000000..67a02be3dc1 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql @@ -0,0 +1,26 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPath: ID!, $id: ID!) { + ciVariableMutation: addProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql new file mode 100644 index 00000000000..4420404a7b4 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql @@ -0,0 +1,31 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteProjectVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $id: ID! +) { + ciVariableMutation: deleteProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql new file mode 100644 index 00000000000..107746a19e9 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql @@ -0,0 +1,31 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateProjectVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $id: ID! +) { + ciVariableMutation: updateProjectVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + id: $id + ) @client { + project { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiProjectVariable { + environmentScope + masked + protected + raw + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql new file mode 100644 index 00000000000..538502fdd3b --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -0,0 +1,23 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { + group(fullPath: $fullPath) { + id + ciVariables(after: $after, first: $first) { + limit + pageInfo { + ...PageInfo + } + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + raw + } + } + } + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql new file mode 100644 index 00000000000..921e0ca25b9 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql @@ -0,0 +1,11 @@ +query getProjectEnvironments($fullPath: ID!) { + project(fullPath: $fullPath) { + id + environments { + nodes { + id + name + } + } + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql new file mode 100644 index 00000000000..af0cd2d0b2c --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -0,0 +1,21 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciVariables(after: $after, first: $first) { + limit + pageInfo { + ...PageInfo + } + nodes { + ...BaseCiVariable + environmentScope + masked + protected + raw + } + } + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql new file mode 100644 index 00000000000..b8dd6f5f562 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql @@ -0,0 +1,18 @@ +#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getVariables($after: String, $first: Int = 100) { + ciVariables(after: $after, first: $first) { + pageInfo { + ...PageInfo + } + nodes { + ...BaseCiVariable + ... on CiInstanceVariable { + masked + protected + raw + } + } + } +} diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js new file mode 100644 index 00000000000..10203383ba0 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js @@ -0,0 +1,242 @@ +import axios from 'axios'; +import { + convertObjectPropsToCamelCase, + convertObjectPropsToSnakeCase, +} from '~/lib/utils/common_utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { + GRAPHQL_GROUP_TYPE, + GRAPHQL_PROJECT_TYPE, + groupString, + instanceString, + projectString, +} from '../constants'; +import getProjectVariables from './queries/project_variables.query.graphql'; +import getGroupVariables from './queries/group_variables.query.graphql'; +import getAdminVariables from './queries/variables.query.graphql'; + +const prepareVariableForApi = ({ variable, destroy = false }) => { + return { + ...convertObjectPropsToSnakeCase(variable), + id: getIdFromGraphQLId(variable?.id), + variable_type: variable.variableType.toLowerCase(), + secret_value: variable.value, + _destroy: destroy, + }; +}; + +const mapVariableTypes = (variables = [], kind) => { + return variables.map((ciVar) => { + return { + __typename: `Ci${kind}Variable`, + ...convertObjectPropsToCamelCase(ciVar), + id: convertToGraphQLId('Ci::Variable', ciVar.id), + variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType, + }; + }); +}; + +const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => { + return { + errors, + project: { + __typename: GRAPHQL_PROJECT_TYPE, + id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id), + ciVariables: { + __typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + nodes: mapVariableTypes(data.variables, projectString), + }, + }, + }; +}; + +const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => { + return { + errors, + group: { + __typename: GRAPHQL_GROUP_TYPE, + id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id), + ciVariables: { + __typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + nodes: mapVariableTypes(data.variables, groupString), + }, + }, + }; +}; + +const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { + return { + errors, + ciVariables: { + __typename: `Ci${instanceString}VariableConnection`, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + nodes: mapVariableTypes(data.variables, instanceString), + }, + }; +}; + +async function callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy = false }) { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + + const graphqlData = prepareProjectGraphQLResponse({ data, id }); + + cache.writeQuery({ + query: getProjectVariables, + variables: { + fullPath, + after: null, + }, + data: graphqlData, + }); + return graphqlData; + } catch (e) { + return prepareProjectGraphQLResponse({ + data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }), + id, + errors: [...e.response.data], + }); + } +} + +const callGroupEndpoint = async ({ endpoint, fullPath, variable, id, cache, destroy = false }) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + + const graphqlData = prepareGroupGraphQLResponse({ data, id }); + + cache.writeQuery({ + query: getGroupVariables, + data: graphqlData, + }); + + return graphqlData; + } catch (e) { + return prepareGroupGraphQLResponse({ + data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }), + id, + errors: [...e.response.data], + }); + } +}; + +const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + + const graphqlData = prepareAdminGraphQLResponse({ data }); + + cache.writeQuery({ + query: getAdminVariables, + data: graphqlData, + }); + + return graphqlData; + } catch (e) { + return prepareAdminGraphQLResponse({ + data: cache.readQuery({ query: getAdminVariables }), + errors: [...e.response.data], + }); + } +}; + +export const resolvers = { + Mutation: { + addProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, id, cache }); + }, + updateProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, id, cache }); + }, + deleteProjectVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callProjectEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true }); + }, + addGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, id, cache }); + }, + updateGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, id, cache }); + }, + deleteGroupVariable: async (_, { endpoint, fullPath, variable, id }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, id, cache, destroy: true }); + }, + addAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache }); + }, + updateAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache }); + }, + deleteAdminVariable: async (_, { endpoint, variable }, { cache }) => { + return callAdminEndpoint({ endpoint, variable, cache, destroy: true }); + }, + }, +}; + +export const mergeVariables = (existing, incoming, { args }) => { + if (!existing || !args?.after) { + return incoming; + } + + const { nodes, ...rest } = incoming; + const result = rest; + result.nodes = [...existing.nodes, ...nodes]; + + return result; +}; + +export const cacheConfig = { + cacheConfig: { + typePolicies: { + Query: { + fields: { + ciVariables: { + keyArgs: false, + merge: mergeVariables, + }, + }, + }, + Project: { + fields: { + ciVariables: { + keyArgs: ['fullPath', 'endpoint', 'id'], + merge: mergeVariables, + }, + }, + }, + Group: { + fields: { + ciVariables: { + keyArgs: ['fullPath'], + merge: mergeVariables, + }, + }, + }, + }, + }, +}; diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js new file mode 100644 index 00000000000..174a59aba42 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/index.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import CiAdminVariables from './components/ci_admin_variables.vue'; +import CiGroupVariables from './components/ci_group_variables.vue'; +import CiProjectVariables from './components/ci_project_variables.vue'; +import { cacheConfig, resolvers } from './graphql/settings'; + +const mountCiVariableListApp = (containerEl) => { + const { + awsLogoSvgPath, + awsTipCommandsLink, + awsTipDeployLink, + awsTipLearnLink, + containsVariableReferenceLink, + endpoint, + environmentScopeLink, + groupId, + groupPath, + isGroup, + isProject, + maskedEnvironmentVariablesLink, + maskableRegex, + projectFullPath, + projectId, + protectedByDefault, + protectedEnvironmentVariablesLink, + } = containerEl.dataset; + + const parsedIsProject = parseBoolean(isProject); + const parsedIsGroup = parseBoolean(isGroup); + const isProtectedByDefault = parseBoolean(protectedByDefault); + + let component = CiAdminVariables; + + if (parsedIsGroup) { + component = CiGroupVariables; + } else if (parsedIsProject) { + component = CiProjectVariables; + } + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, cacheConfig), + }); + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + awsLogoSvgPath, + awsTipCommandsLink, + awsTipDeployLink, + awsTipLearnLink, + containsVariableReferenceLink, + endpoint, + environmentScopeLink, + groupId, + groupPath, + isGroup: parsedIsGroup, + isProject: parsedIsProject, + isProtectedByDefault, + maskedEnvironmentVariablesLink, + maskableRegex, + projectFullPath, + projectId, + protectedEnvironmentVariablesLink, + }, + render(createElement) { + return createElement(component); + }, + }); +}; + +export default (containerId = 'js-ci-variables') => { + const el = document.getElementById(containerId); + + if (!el) return; + + mountCiVariableListApp(el); +}; diff --git a/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js new file mode 100644 index 00000000000..fdbefd8c313 --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js @@ -0,0 +1,25 @@ +import $ from 'jquery'; +import VariableList from './ci_variable_list'; + +// Used for the variable list on scheduled pipeline edit page +export default function setupNativeFormVariableList({ container, formField = 'variables' }) { + const $container = $(container); + + const variableList = new VariableList({ + container: $container, + formField, + }); + variableList.init(); + + // Clear out the names in the empty last row so it + // doesn't get submitted and throw validation errors + $container.closest('form').on('submit trigger-submit', () => { + const $lastRow = $container.find('.js-row').last(); + + const isTouched = variableList.checkIfRowTouched($lastRow); + if (!isTouched) { + $lastRow.find('input, textarea').attr('name', ''); + $lastRow.find('select').attr('name', ''); + } + }); +} diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js new file mode 100644 index 00000000000..eeca69274ce --- /dev/null +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/browser'; +import { uniq } from 'lodash'; +import { allEnvironments } from './constants'; + +/** + * This function takes a list of variable, environments and + * new environments added through the scope dropdown + * and create a new Array that concatenate the environment list + * with the environment scopes find in the variable list. This is + * useful for variable settings so that we can render a list of all + * environment scopes available based on the list of envs, the ones the user + * added explictly and what is found under each variable. + * @param {Array} variables + * @param {Array} environments + * @returns {Array} - Array of environments + */ + +export const createJoinedEnvironments = ( + variables = [], + environments = [], + newEnvironments = [], +) => { + const scopesFromVariables = variables.map((variable) => variable.environmentScope); + return uniq([...environments, ...newEnvironments, ...scopesFromVariables]).sort(); +}; + +/** + * This function job is to convert the * wildcard to text when applicable + * in the UI. It uses a constants to compare the incoming value to that + * of the * and then apply the corresponding label if applicable. If there + * is no scope, then we return the default value as well. + * @param {String} scope + * @returns {String} - Converted value if applicable + */ + +export const convertEnvironmentScope = (environmentScope = '') => { + if (environmentScope === allEnvironments.type || !environmentScope) { + return allEnvironments.text; + } + + return environmentScope; +}; + +/** + * Gives us an array of all the environments by name + * @param {Array} nodes + * @return {Array<String>} - Array of environments strings + */ +export const mapEnvironmentNames = (nodes = []) => { + return nodes.map((env) => env.name); +}; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue index 255e3cb31f1..891c40482d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue @@ -2,7 +2,6 @@ import { EDITOR_READY_EVENT } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SOURCE_EDITOR_DEBOUNCE } from '../../constants'; export default { @@ -15,7 +14,6 @@ export default { components: { SourceEditor, }, - mixins: [glFeatureFlagMixin()], inject: ['ciConfigPath'], inheritAttrs: false, methods: { @@ -23,10 +21,8 @@ export default { this.$emit('updateCiConfig', content); }, registerCiSchema({ detail: { instance } }) { - if (this.glFeatures.schemaLinting) { - instance.use({ definition: CiSchemaExtension }); - instance.registerCiSchema(); - } + instance.use({ definition: CiSchemaExtension }); + instance.registerCiSchema(); }, }, readyEvent: EDITOR_READY_EVENT, 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> diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue new file mode 100644 index 00000000000..060527f2662 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue @@ -0,0 +1,86 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { DEBOUNCE_REFS_SEARCH_MS } from '../constants'; +import { formatListBoxItems, searchByFullNameInListboxOptions } from '../utils/format_refs'; + +export default { + components: { + GlCollapsibleListbox, + }, + inject: ['projectRefsEndpoint'], + props: { + value: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + isLoading: false, + searchTerm: '', + listBoxItems: [], + }; + }, + computed: { + lowerCasedSearchTerm() { + return this.searchTerm.toLowerCase(); + }, + refShortName() { + return this.value.shortName; + }, + }, + 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.listBoxItems = formatListBoxItems(Branches, Tags); + }) + .catch((e) => { + this.$emit('loadingError', e); + }) + .finally(() => { + this.isLoading = false; + }); + }, + debouncedLoadRefs: debounce(function debouncedLoadRefs() { + this.loadRefs(); + }, DEBOUNCE_REFS_SEARCH_MS), + setRefSelected(refFullName) { + const ref = searchByFullNameInListboxOptions(refFullName, this.listBoxItems); + this.$emit('input', ref); + }, + setSearchTerm(searchQuery) { + this.searchTerm = searchQuery?.trim(); + this.debouncedLoadRefs(); + }, + }, +}; +</script> +<template> + <gl-collapsible-listbox + class="gl-w-full gl-font-monospace" + :items="listBoxItems" + :searchable="true" + :searching="isLoading" + :search-placeholder="__('Search refs')" + :selected="value.fullName" + toggle-class="gl-flex-direction-column gl-align-items-stretch!" + :toggle-text="refShortName" + @search="setSearchTerm" + @select="setRefSelected" + @shown.once="loadRefs" + /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_new/constants.js b/app/assets/javascripts/ci/pipeline_new/constants.js new file mode 100644 index 00000000000..43f7634083b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/constants.js @@ -0,0 +1,14 @@ +import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; +export const DEBOUNCE_REFS_SEARCH_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; +export const CONFIG_VARIABLES_TIMEOUT = 5000; +export const BRANCH_REF_TYPE = 'branch'; +export const TAG_REF_TYPE = 'tag'; + +// must match pipeline/chain/validate/after_config.rb +export const CC_VALIDATION_REQUIRED_ERROR = __( + 'Credit card required to be on file in order to create a pipeline', +); diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql new file mode 100644 index 00000000000..a76e8f6b95b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql @@ -0,0 +1,9 @@ +mutation createPipeline($endpoint: String, $ref: String, $variablesAttributes: Array) { + createPipeline(endpoint: $endpoint, ref: $ref, variablesAttributes: $variablesAttributes) + @client { + id + errors + totalWarnings + warnings + } +} diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql new file mode 100644 index 00000000000..648cd8b66b5 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql @@ -0,0 +1,11 @@ +query ciConfigVariables($fullPath: ID!, $ref: String!) { + project(fullPath: $fullPath) { + id + ciConfigVariables(sha: $ref) { + description + key + value + valueOptions + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js new file mode 100644 index 00000000000..7b0f58e8cf9 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js @@ -0,0 +1,29 @@ +import axios from '~/lib/utils/axios_utils'; + +export const resolvers = { + Mutation: { + createPipeline: (_, { endpoint, ref, variablesAttributes }) => { + return axios + .post(endpoint, { ref, variables_attributes: variablesAttributes }) + .then((response) => { + const { id } = response.data; + return { + id, + errors: [], + totalWarnings: 0, + warnings: [], + }; + }) + .catch((err) => { + const { errors = [], totalWarnings = 0, warnings = [] } = err.response.data; + + return { + id: null, + errors, + totalWarnings, + warnings, + }; + }); + }, + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_new/index.js b/app/assets/javascripts/ci/pipeline_new/index.js new file mode 100644 index 00000000000..71c76aeab36 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/index.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineNewForm from './components/pipeline_new_form.vue'; +import { resolvers } from './graphql/resolvers'; + +const mountPipelineNewForm = (el) => { + const { + // provide/inject + projectRefsEndpoint, + + // props + defaultBranch, + fileParam, + maxWarnings, + pipelinesPath, + projectId, + projectPath, + refParam, + settingsLink, + varParam, + } = el.dataset; + + const variableParams = JSON.parse(varParam); + const fileParams = JSON.parse(fileParam); + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + projectRefsEndpoint, + }, + render(createElement) { + return createElement(PipelineNewForm, { + props: { + defaultBranch, + fileParams, + maxWarnings: Number(maxWarnings), + pipelinesPath, + projectId, + projectPath, + refParam, + settingsLink, + variableParams, + }, + }); + }, + }); +}; + +export default () => { + const el = document.getElementById('js-new-pipeline'); + + mountPipelineNewForm(el); +}; diff --git a/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js new file mode 100644 index 00000000000..57ce3d13a9a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js @@ -0,0 +1,13 @@ +// We need to filter out blank variables +// and filter out variables that have no key +// before sending to the API to create a pipeline. + +export default (variables) => { + return variables + .filter(({ key }) => key !== '') + .map(({ variable_type, key, value }) => ({ + variable_type, + key, + secret_value: value, + })); +}; diff --git a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js new file mode 100644 index 00000000000..e6d26b32d47 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js @@ -0,0 +1,55 @@ +import { __ } from '~/locale'; +import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants'; + +function convertToListBoxItems(items) { + return items.map(({ shortName, fullName }) => ({ text: shortName, value: fullName })); +} + +export function formatRefs(refs, type) { + let fullName; + + return refs.map((ref) => { + if (type === BRANCH_REF_TYPE) { + fullName = `refs/heads/${ref}`; + } else if (type === TAG_REF_TYPE) { + fullName = `refs/tags/${ref}`; + } + + return { + shortName: ref, + fullName, + }; + }); +} + +export const formatListBoxItems = (branches, tags) => { + const finalResults = []; + + if (branches.length > 0) { + finalResults.push({ + text: __('Branches'), + options: convertToListBoxItems(formatRefs(branches, BRANCH_REF_TYPE)), + }); + } + + if (tags.length > 0) { + finalResults.push({ + text: __('Tags'), + options: convertToListBoxItems(formatRefs(tags, TAG_REF_TYPE)), + }); + } + + return finalResults; +}; + +export const searchByFullNameInListboxOptions = (fullName, listBox) => { + const optionsToSearch = + listBox.length > 1 ? listBox[0].options.concat(listBox[1].options) : listBox[0]?.options; + + const foundOption = optionsToSearch.find(({ value }) => value === fullName); + + return { + shortName: foundOption.text, + fullName: foundOption.value, + }; +}; diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index fe16cb7a92e..d03de91ea07 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -1,14 +1,25 @@ <script> -import { GlAlert, GlBadge, GlButton, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { + GlAlert, + GlBadge, + GlButton, + GlLoadingIcon, + GlTabs, + GlTab, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; import { queryToObject } from '~/lib/utils/url_utility'; import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; +import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; +import PipelineScheduleEmptyState from './pipeline_schedules_empty_state.vue'; export default { i18n: { @@ -16,11 +27,15 @@ export default { scheduleDeleteError: s__( 'PipelineSchedules|There was a problem deleting the pipeline schedule.', ), + schedulePlayError: s__('PipelineSchedules|There was a problem playing the pipeline schedule.'), takeOwnershipError: s__( 'PipelineSchedules|There was a problem taking ownership of the pipeline schedule.', ), newSchedule: s__('PipelineSchedules|New schedule'), deleteSuccess: s__('PipelineSchedules|Pipeline schedule successfully deleted.'), + playSuccess: s__( + 'PipelineSchedules|Successfully scheduled a pipeline to run. Go to the %{linkStart}Pipelines page%{linkEnd} for details. ', + ), }, components: { DeletePipelineScheduleModal, @@ -30,13 +45,19 @@ export default { GlLoadingIcon, GlTabs, GlTab, + GlSprintf, + GlLink, PipelineSchedulesTable, TakeOwnershipModal, + PipelineScheduleEmptyState, }, inject: { fullPath: { default: '', }, + pipelinesPath: { + default: '', + }, }, apollo: { schedules: { @@ -68,6 +89,7 @@ export default { }, scope, hasError: false, + playSuccess: false, errorMessage: '', scheduleId: null, showDeleteModal: false, @@ -185,6 +207,27 @@ export default { this.reportError(this.$options.i18n.takeOwnershipError); } }, + async playPipelineSchedule(id) { + try { + const { + data: { + pipelineSchedulePlay: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: playPipelineScheduleMutation, + variables: { id }, + }); + + if (errors.length > 0) { + throw new Error(); + } else { + this.playSuccess = true; + } + } catch { + this.playSuccess = false; + this.reportError(this.$options.i18n.schedulePlayError); + } + }, fetchPipelineSchedulesByStatus(scope) { this.scope = scope; this.$apollo.queries.schedules.refetch(); @@ -195,62 +238,72 @@ export default { <template> <div> - <gl-alert v-if="hasError" class="gl-mb-2" variant="danger" @dismiss="hasError = false"> + <gl-alert v-if="hasError" class="gl-my-3" variant="danger" @dismiss="hasError = false"> {{ errorMessage }} </gl-alert> - <template v-else> - <gl-tabs - sync-active-tab-with-query-params - query-param-name="scope" - nav-class="gl-flex-grow-1 gl-align-items-center" + <gl-alert v-if="playSuccess" class="gl-my-3" variant="info" @dismiss="playSuccess = false"> + <gl-sprintf :message="$options.i18n.playSuccess"> + <template #link="{ content }"> + <gl-link :href="pipelinesPath" class="gl-text-decoration-none!">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + + <gl-tabs + v-if="isLoading || count > 0" + sync-active-tab-with-query-params + query-param-name="scope" + nav-class="gl-flex-grow-1 gl-align-items-center" + > + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="tab.attrs" + :query-param-value="tab.scope" + @click="fetchPipelineSchedulesByStatus(tab.scope)" > - <gl-tab - v-for="tab in tabs" - :key="tab.text" - :title-link-attributes="tab.attrs" - :query-param-value="tab.scope" - @click="fetchPipelineSchedulesByStatus(tab.scope)" - > - <template #title> - <span>{{ tab.text }}</span> + <template #title> + <span>{{ tab.text }}</span> - <template v-if="tab.showBadge"> - <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" /> + <template v-if="tab.showBadge"> + <gl-loading-icon v-if="tab.scope === scope && isLoading" class="gl-ml-2" /> - <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge"> - {{ tab.count }} - </gl-badge> - </template> + <gl-badge v-else-if="tab.count" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> </template> + </template> - <gl-loading-icon v-if="isLoading" size="lg" /> - <pipeline-schedules-table - v-else - :schedules="schedules.list" - @showTakeOwnershipModal="setTakeOwnershipModal" - @showDeleteModal="setDeleteModal" - /> - </gl-tab> + <gl-loading-icon v-if="isLoading" size="lg" /> + <pipeline-schedules-table + v-else + :schedules="schedules.list" + @showTakeOwnershipModal="setTakeOwnershipModal" + @showDeleteModal="setDeleteModal" + @playPipelineSchedule="playPipelineSchedule" + /> + </gl-tab> - <template #tabs-end> - <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> - {{ $options.i18n.newSchedule }} - </gl-button> - </template> - </gl-tabs> + <template #tabs-end> + <gl-button variant="confirm" class="gl-ml-auto" data-testid="new-schedule-button"> + {{ $options.i18n.newSchedule }} + </gl-button> + </template> + </gl-tabs> + + <pipeline-schedule-empty-state v-else-if="!isLoading && count === 0" /> - <take-ownership-modal - :visible="showTakeOwnershipModal" - @takeOwnership="takeOwnership" - @hideModal="hideModal" - /> + <take-ownership-modal + :visible="showTakeOwnershipModal" + @takeOwnership="takeOwnership" + @hideModal="hideModal" + /> - <delete-pipeline-schedule-modal - :visible="showDeleteModal" - @deleteSchedule="deleteSchedule" - @hideModal="hideModal" - /> - </template> + <delete-pipeline-schedule-modal + :visible="showDeleteModal" + @deleteSchedule="deleteSchedule" + @hideModal="hideModal" + /> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue new file mode 100644 index 00000000000..f633ba053ee --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -0,0 +1,63 @@ +<script> +import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export default { + i18n: { + pipelineSchedules: s__('PipelineSchedules|Pipeline schedules'), + description: s__( + 'PipelineSchedules|A scheduled pipeline starts automatically at regular intervals, like daily or weekly. The pipeline: ', + ), + learnMore: s__( + 'PipelineSchedules|Learn more in the %{linkStart}scheduled pipelines documentation.%{linkEnd}', + ), + listElements: [ + s__('PipelineSchedules|Runs for a specific branch or tag.'), + s__('PipelineSchedules|Can have custom CI/CD variables.'), + s__('PipelineSchedules|Runs with the same project permissions as the schedule owner.'), + ], + createNew: s__('PipelineSchedules|Create a new pipeline schedule'), + }, + components: { + GlEmptyState, + GlLink, + GlSprintf, + }, + computed: { + scheduleSvgPath() { + return `data:image/svg+xml;utf8,${encodeURIComponent(scheduleSvg)}`; + }, + schedulesHelpPath() { + return helpPagePath('ci/pipelines/schedules'); + }, + }, +}; +</script> +<template> + <gl-empty-state + :svg-path="scheduleSvgPath" + :primary-button-text="$options.i18n.createNew" + primary-button-link="#" + > + <template #title> + <h3> + {{ $options.i18n.pipelineSchedules }} + </h3> + </template> + <template #description> + <p class="gl-mb-0">{{ $options.i18n.description }}</p> + <ul class="gl-list-style-position-inside" data-testid="pipeline-schedules-characteristics"> + <li v-for="(el, index) in $options.i18n.listElements" :key="index">{{ el }}</li> + </ul> + <p> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link :href="schedulesHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index a4ef7827f73..367b1812a27 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -71,7 +71,7 @@ export default { timezone: this.cronTimezone, formCiVariables: {}, // TODO: Add the GraphQL query to help populate the predefined variables - // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131 + // app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue#131 predefinedValueOptions: {}, }; }, diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index 8656e5d3536..45b4f618e17 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -44,7 +44,14 @@ export default { <template> <div class="gl-display-flex gl-justify-content-end"> <gl-button-group> - <gl-button v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" icon="play" /> + <gl-button + v-if="canPlay" + v-gl-tooltip + :title="$options.i18n.playTooltip" + icon="play" + data-testid="play-pipeline-schedule-btn" + @click="$emit('playPipelineSchedule', schedule.id)" + /> <gl-button v-if="canTakeOwnership" v-gl-tooltip diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 216796b357c..56461165588 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -1,9 +1,9 @@ <script> -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; export default { components: { - CiBadge, + CiBadgeLink, }, props: { schedule: { @@ -24,7 +24,11 @@ export default { <template> <div> - <ci-badge v-if="hasPipeline" :status="lastPipelineStatus" class="gl-vertical-align-middle" /> + <ci-badge-link + v-if="hasPipeline" + :status="lastPipelineStatus" + class="gl-vertical-align-middle" + /> <span v-else data-testid="pipeline-schedule-status-text"> {{ s__('PipelineSchedules|None') }} </span> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue index 1b97a35a51e..e8cfc5b29f3 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue @@ -96,6 +96,7 @@ export default { :schedule="item" @showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)" @showDeleteModal="$emit('showDeleteModal', $event)" + @playPipelineSchedule="$emit('playPipelineSchedule', $event)" /> </template> </gl-table-lite> diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql new file mode 100644 index 00000000000..4892f41b93f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql @@ -0,0 +1,6 @@ +mutation playPipelineSchedule($id: CiPipelineScheduleID!) { + pipelineSchedulePlay(input: { id: $id }) { + clientMutationId + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js index 4c06fa321e5..8bca4f85e9f 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -18,7 +18,7 @@ export default () => { return false; } - const { fullPath } = containerEl.dataset; + const { fullPath, pipelinesPath } = containerEl.dataset; return new Vue({ el: containerEl, @@ -26,6 +26,7 @@ export default () => { apolloProvider, provide: { fullPath, + pipelinesPath, }, render(createElement) { return createElement(PipelineSchedules); diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index efa7909c913..e359344ab77 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -3,7 +3,7 @@ import { GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { durationTimeFormatted } from '~/lib/utils/datetime_utility'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; @@ -11,7 +11,7 @@ import LinkCell from './cells/link_cell.vue'; export default { components: { - CiBadge, + CiBadgeLink, GlTableLite, LinkCell, RunnerTags, @@ -80,7 +80,7 @@ export default { fixed > <template #cell(status)="{ item = {} }"> - <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + <ci-badge-link v-if="item.detailedStatus" :status="item.detailedStatus" /> </template> <template #cell(job)="{ item = {} }"> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index 2e50dc13d2d..e0a6f4b1e67 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -23,6 +23,8 @@ export default { RunnerSingleStat, RunnerUpgradeStatusStats: () => import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), + RunnerPerformanceStat: () => + import('ee_component/ci/runner/components/stat/runner_performance_stat.vue'), }, props: { scope: { @@ -95,6 +97,8 @@ export default { :scope="scope" :variables="variables" /> + + <runner-performance-stat class="gl-px-5" /> </div> </runner-count> </template> diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index edfc22f644b..075dbb06190 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -8,7 +8,7 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, nodes { id detailedStatus { - # fields for `<ci-badge>` + # fields for `<ci-badge-link>` id detailsPath group diff --git a/app/assets/javascripts/ci/runner/project_runners/index.js b/app/assets/javascripts/ci/runner/project_runners/index.js new file mode 100644 index 00000000000..3be2b4a7422 --- /dev/null +++ b/app/assets/javascripts/ci/runner/project_runners/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ProjectRunnersApp from './project_runners_app.vue'; + +export const initProjectRunners = (selector = '#js-project-runners') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { projectFullPath } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(ProjectRunnersApp, { + props: { + projectFullPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue new file mode 100644 index 00000000000..c7bf5e521a1 --- /dev/null +++ b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue @@ -0,0 +1,19 @@ +<script> +export default { + props: { + projectFullPath: { + required: true, + type: String, + }, + }, +}; +</script> +<template> + <div> + <!-- + Under development + Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/33803 + Feature rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/386573 + --> + </div> +</template> |