diff options
Diffstat (limited to 'app/assets/javascripts/ci/ci_variable_list/components')
9 files changed, 1392 insertions, 0 deletions
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> |