diff options
Diffstat (limited to 'app/assets/javascripts/ci_variable_list/components')
6 files changed, 742 insertions, 26 deletions
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 3af89dc4a2c..557a8d6b5ba 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -369,7 +369,7 @@ export default { :href="awsTipLearnLink" target="_blank" category="secondary" - variant="info" + variant="confirm" class="gl-overflow-wrap-break" >{{ __('Learn more about deploying to AWS') }}</gl-button > @@ -416,6 +416,7 @@ export default { :disabled="!canSubmit" variant="confirm" category="primary" + data-testid="ciUpdateOrAddVariableBtn" data-qa-selector="ci_variable_save_button" @click="updateOrAddVariable" >{{ modalActionText }} diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue index 12bc5ad3549..4cc00eb01d9 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue @@ -1,32 +1,9 @@ <script> -import { mapState, mapActions } from 'vuex'; -import CiVariableModal from './ci_variable_modal.vue'; -import CiVariableTable from './ci_variable_table.vue'; - -export default { - components: { - CiVariableModal, - CiVariableTable, - }, - computed: { - ...mapState(['isGroup']), - }, - mounted() { - if (!this.isGroup) { - this.fetchEnvironments(); - } - }, - methods: { - ...mapActions(['fetchEnvironments']), - }, -}; +export default {}; </script> <template> <div class="row"> - <div class="col-lg-12"> - <ci-variable-table /> - <ci-variable-modal /> - </div> + <div class="col-lg-12"></div> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue new file mode 100644 index 00000000000..ecb39f214ec --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue @@ -0,0 +1,81 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'CiEnvironmentsDropdown', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + searchTerm: '', + }; + }, + computed: { + ...mapGetters(['joinedEnvironments']), + composedCreateButtonLabel() { + return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); + }, + shouldRenderCreateButton() { + return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.joinedEnvironments.filter((resultString) => + resultString.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + }, + methods: { + selectEnvironment(selected) { + this.$emit('selectEnvironment', selected); + this.searchTerm = ''; + }, + createClicked() { + this.$emit('createClicked', this.searchTerm); + this.searchTerm = ''; + }, + isSelected(env) { + return this.value === env; + }, + clearSearch() { + this.searchTerm = ''; + }, + }, +}; +</script> +<template> + <gl-dropdown :text="value" @show="clearSearch"> + <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> + <gl-dropdown-item + v-for="environment in filteredResults" + :key="environment" + :is-checked="isSelected(environment)" + is-check-item + @click="selectEnvironment(environment)" + > + {{ environment }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + __('No matching results') + }}</gl-dropdown-item> + <template v-if="shouldRenderCreateButton"> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked"> + {{ composedCreateButtonLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue new file mode 100644 index 00000000000..7dcc5ce42d7 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue @@ -0,0 +1,426 @@ +<script> +import { + GlAlert, + GlButton, + GlCollapse, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormTextarea, + GlIcon, + GlLink, + GlModal, + GlSprintf, +} from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { mapComputed } from '~/vuex_shared/bindings'; +import { + AWS_TOKEN_CONSTANTS, + ADD_CI_VARIABLE_MODAL_ID, + AWS_TIP_DISMISSED_COOKIE_NAME, + AWS_TIP_MESSAGE, + CONTAINS_VARIABLE_REFERENCE_MESSAGE, + ENVIRONMENT_SCOPE_LINK_TITLE, + EVENT_LABEL, + EVENT_ACTION, +} from '../constants'; +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, + components: { + CiEnvironmentsDropdown, + GlAlert, + GlButton, + GlCollapse, + GlFormCheckbox, + GlFormCombobox, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormTextarea, + GlIcon, + GlLink, + GlModal, + GlSprintf, + }, + mixins: [glFeatureFlagsMixin(), trackingMixin], + data() { + return { + isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + validationErrorEventProperty: '', + }; + }, + computed: { + ...mapState([ + 'projectId', + 'environments', + 'typeOptions', + 'variable', + 'variableBeingEdited', + 'isGroup', + 'maskableRegex', + 'selectedEnvironment', + 'isProtectedByDefault', + 'awsLogoSvgPath', + 'awsTipDeployLink', + 'awsTipCommandsLink', + 'awsTipLearnLink', + 'containsVariableReferenceLink', + 'protectedEnvironmentVariablesLink', + 'maskedEnvironmentVariablesLink', + 'environmentScopeLink', + ]), + ...mapComputed( + [ + { key: 'key', updateFn: 'updateVariableKey' }, + { key: 'secret_value', updateFn: 'updateVariableValue' }, + { key: 'variable_type', updateFn: 'updateVariableType' }, + { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, + { key: 'protected_variable', updateFn: 'updateVariableProtected' }, + { key: 'masked', updateFn: 'updateVariableMasked' }, + ], + false, + 'variable', + ), + isTipVisible() { + return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); + }, + canSubmit() { + return ( + this.variableValidationState && + this.variable.key !== '' && + this.variable.secret_value !== '' + ); + }, + canMask() { + const regex = RegExp(this.maskableRegex); + return regex.test(this.variable.secret_value); + }, + containsVariableReference() { + const regex = /\$/; + return regex.test(this.variable.secret_value); + }, + displayMaskedError() { + return !this.canMask && this.variable.masked; + }, + maskedState() { + if (this.displayMaskedError) { + return false; + } + return true; + }, + modalActionText() { + return this.variableBeingEdited ? __('Update variable') : __('Add variable'); + }, + maskedFeedback() { + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + 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.secret_value); + } + + return true; + }, + scopedVariablesAvailable() { + return !this.isGroup || this.glFeatures.groupScopedCiVariables; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); + }, + }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, + methods: { + ...mapActions([ + 'addVariable', + 'updateVariable', + 'resetEditing', + 'displayInputValue', + 'clearModal', + 'deleteVariable', + 'setEnvironmentScope', + 'addWildCardScope', + 'resetSelectedEnvironment', + 'setSelectedEnvironment', + 'setVariableProtected', + ]), + dismissTip() { + setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); + this.isTipDismissed = true; + }, + deleteVarAndClose() { + this.deleteVariable(); + this.hideModal(); + }, + hideModal() { + this.$refs.modal.hide(); + }, + resetModalHandler() { + if (this.variableBeingEdited) { + this.resetEditing(); + } + + this.clearModal(); + this.resetSelectedEnvironment(); + this.resetValidationErrorEvents(); + }, + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(); + } else { + this.addVariable(); + } + this.hideModal(); + }, + setVariableProtectedByDefault() { + if (this.isProtectedByDefault && !this.variableBeingEdited) { + 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.secret_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.secret_value.replace(regex, ''); + } + if (this.containsVariableReference) { + property = '$'; + } + } + + return property; + }, + resetValidationErrorEvents() { + this.validationErrorEventProperty = ''; + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + :modal-id="$options.modalId" + :title="modalActionText" + static + lazy + @hidden="resetModalHandler" + @shown="setVariableProtectedByDefault" + > + <form> + <gl-form-combobox + v-model="key" + :token-list="$options.tokenList" + :label-text="__('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="secret_value" + :state="variableValidationState" + rows="3" + max-rows="6" + data-qa-selector="ci_variable_value_field" + class="gl-font-monospace!" + /> + </gl-form-group> + + <div class="d-flex"> + <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> + <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> + </gl-form-group> + + <gl-form-group label-for="ci-variable-env" class="w-50" 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="scopedVariablesAvailable" + class="w-100" + :value="environment_scope" + @selectEnvironment="setEnvironmentScope" + @createClicked="addWildCardScope" + /> + + <gl-form-input v-else v-model="environment_scope" class="w-100" readonly /> + </gl-form-group> + </div> + + <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> + <gl-form-checkbox + v-model="protected_variable" + class="mb-0" + data-testid="ci-variable-protected-checkbox" + > + {{ __('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="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 gl-mb-0 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-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"> + <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="info" + 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="variableBeingEdited" + 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_variable_list/components/legacy_ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue new file mode 100644 index 00000000000..9acc9fbffb6 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue @@ -0,0 +1,32 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import LegacyCiVariableModal from './legacy_ci_variable_modal.vue'; +import LegacyCiVariableTable from './legacy_ci_variable_table.vue'; + +export default { + components: { + LegacyCiVariableModal, + LegacyCiVariableTable, + }, + computed: { + ...mapState(['isGroup']), + }, + mounted() { + if (!this.isGroup) { + this.fetchEnvironments(); + } + }, + methods: { + ...mapActions(['fetchEnvironments']), + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-12"> + <legacy-ci-variable-table /> + <legacy-ci-variable-modal /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue new file mode 100644 index 00000000000..f078234829a --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue @@ -0,0 +1,199 @@ +<script> +import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { s__, __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import CiVariablePopover from './ci_variable_popover.vue'; + +export default { + modalId: ADD_CI_VARIABLE_MODAL_ID, + trueIcon: 'mobile-issue-close', + falseIcon: 'close', + iconSize: 16, + fields: [ + { + key: 'variable_type', + label: s__('CiVariables|Type'), + customStyle: { width: '70px' }, + }, + { + key: 'key', + label: s__('CiVariables|Key'), + tdClass: 'text-plain', + sortable: true, + customStyle: { width: '40%' }, + }, + { + key: 'value', + label: s__('CiVariables|Value'), + customStyle: { width: '40%' }, + }, + { + key: 'protected', + label: s__('CiVariables|Protected'), + customStyle: { width: '100px' }, + }, + { + key: 'masked', + label: s__('CiVariables|Masked'), + customStyle: { width: '100px' }, + }, + { + key: 'environment_scope', + label: s__('CiVariables|Environments'), + customStyle: { width: '20%' }, + }, + { + key: 'actions', + label: '', + tdClass: 'text-right', + customStyle: { width: '35px' }, + }, + ], + components: { + CiVariablePopover, + GlButton, + GlIcon, + GlTable, + TooltipOnTruncate, + }, + directives: { + GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']), + valuesButtonText() { + return this.valuesHidden ? __('Reveal values') : __('Hide values'); + }, + isTableEmpty() { + return !this.variables || this.variables.length === 0; + }, + fields() { + return this.$options.fields; + }, + }, + mounted() { + this.fetchVariables(); + }, + methods: { + ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']), + }, +}; +</script> + +<template> + <div class="ci-variable-table" data-testid="ci-variable-table"> + <gl-table + :fields="fields" + :items="variables" + 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(key)="{ item }"> + <div class="gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="item.key" truncate-target="child"> + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.key }}</span + > + </tooltip-on-truncate> + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :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-center"> + <span v-if="valuesHidden">*********************</span> + <span + v-else + :id="`ci-variable-value-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.value }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy value')" + :data-clipboard-text="item.value" + :aria-label="__('Copy to clipboard')" + /> + </div> + </template> + <template #cell(protected)="{ item }"> + <gl-icon v-if="item.protected" :size="$options.iconSize" :name="$options.trueIcon" /> + <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> + </template> + <template #cell(masked)="{ item }"> + <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" /> + <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" /> + </template> + <template #cell(environment_scope)="{ item }"> + <div class="gl-display-flex"> + <span + :id="`ci-variable-env-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.environment_scope }}</span + > + <ci-variable-popover + :target="`ci-variable-env-${item.id}`" + :value="item.environment_scope" + :tooltip-text="__('Copy environment')" + /> + </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="editVariable(item)" + /> + </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> + <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" + >{{ __('Add variable') }}</gl-button + > + <gl-button + v-if="!isTableEmpty" + data-qa-selector="reveal_ci_variable_value_button" + @click="toggleValues(!valuesHidden)" + >{{ valuesButtonText }}</gl-button + > + </div> + </div> +</template> |