import $ from 'jquery'; import { parseBoolean } from '../lib/utils/common_utils'; import { s__ } from '../locale'; import setupToggleButtons from '../toggle_buttons'; import CreateItemDropdown from '../create_item_dropdown'; import SecretValues from '../behaviors/secret_values'; 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 // gl_dropdown replaces the 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); setupToggleButtons($row[0]); // 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(); } }); } }