Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ci')
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js262
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_admin_variables.vue36
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue81
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue54
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue56
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_autocomplete_tokens.js15
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue502
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue108
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue242
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue298
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js106
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql7
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql26
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql26
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql26
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql26
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql31
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql31
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql23
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql21
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql18
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/settings.js242
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js83
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js25
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/utils.js60
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue474
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue86
-rw-r--r--app/assets/javascripts/ci/pipeline_new/constants.js14
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/mutations/create_pipeline.mutation.graphql9
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql11
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/resolvers.js29
-rw-r--r--app/assets/javascripts/ci/pipeline_new/index.js61
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/filter_variables.js13
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/format_refs.js55
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue147
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue63
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue1
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql6
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js3
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/stat/runner_stats.vue4
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/index.js23
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue19
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>