From 9f07461dac40206a80e5e080e845fe156a5c31d6 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 26 Jan 2018 00:25:26 -0600 Subject: Refactor CI variable list code for usage with CI/CD settings page secret variables Part of https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4110 --- app/assets/javascripts/behaviors/secret_values.js | 8 +- .../ci_variable_list/ci_variable_list.js | 205 +++++++++++++++++++++ .../ci_variable_list/native_form_variable_list.js | 26 +++ app/assets/javascripts/commons/polyfills.js | 2 + .../pipeline_schedule_form_bundle.js | 7 +- .../setup_pipeline_variable_list.js | 73 -------- app/assets/javascripts/toggle_buttons.js | 8 +- app/assets/stylesheets/framework.scss | 1 + app/assets/stylesheets/framework/buttons.scss | 5 + .../stylesheets/framework/ci_variable_list.scss | 88 +++++++++ app/assets/stylesheets/framework/variables.scss | 4 +- .../stylesheets/pages/pipeline_schedules.scss | 81 -------- app/views/ci/variables/_variable_row.html.haml | 49 +++++ .../projects/pipeline_schedules/_form.html.haml | 16 +- .../pipeline_schedules/_variable_row.html.haml | 17 -- ...r-ci-variable-list-for-future-usage-in-4110.yml | 5 + spec/features/projects/pipeline_schedules_spec.rb | 30 +-- .../ci_variable_list/ci_variable_list_spec.js | 163 ++++++++++++++++ .../native_form_variable_list_spec.js | 30 +++ spec/javascripts/fixtures/pipeline_schedules.rb | 43 +++++ .../setup_pipeline_variable_list_spec.js | 145 --------------- 21 files changed, 660 insertions(+), 346 deletions(-) create mode 100644 app/assets/javascripts/ci_variable_list/ci_variable_list.js create mode 100644 app/assets/javascripts/ci_variable_list/native_form_variable_list.js delete mode 100644 app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js create mode 100644 app/assets/stylesheets/framework/ci_variable_list.scss create mode 100644 app/views/ci/variables/_variable_row.html.haml delete mode 100644 app/views/projects/pipeline_schedules/_variable_row.html.haml create mode 100644 changelogs/unreleased/refactor-ci-variable-list-for-future-usage-in-4110.yml create mode 100644 spec/javascripts/ci_variable_list/ci_variable_list_spec.js create mode 100644 spec/javascripts/ci_variable_list/native_form_variable_list_spec.js create mode 100644 spec/javascripts/fixtures/pipeline_schedules.rb delete mode 100644 spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js diff --git a/app/assets/javascripts/behaviors/secret_values.js b/app/assets/javascripts/behaviors/secret_values.js index 7f70fce913a..0d6e0dbefcc 100644 --- a/app/assets/javascripts/behaviors/secret_values.js +++ b/app/assets/javascripts/behaviors/secret_values.js @@ -15,10 +15,12 @@ export default class SecretValues { init() { this.revealButton = this.container.querySelector('.js-secret-value-reveal-button'); - const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus); - this.updateDom(isRevealed); + if (this.revealButton) { + const isRevealed = convertPermissionToBoolean(this.revealButton.dataset.secretRevealStatus); + this.updateDom(isRevealed); - this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this)); + this.revealButton.addEventListener('click', this.onRevealButtonClicked.bind(this)); + } } onRevealButtonClicked() { diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js new file mode 100644 index 00000000000..e46478ddb98 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -0,0 +1,205 @@ +import $ from 'jquery'; +import { convertPermissionToBoolean } 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, + }; +} + +export default class VariableList { + constructor({ + container, + formField, + }) { + this.$container = $(container); + this.formField = formField; + this.environmentDropdownMap = new WeakMap(); + + this.inputMap = { + id: { + selector: '.js-ci-variable-input-id', + default: '', + }, + key: { + selector: '.js-ci-variable-input-key', + default: '', + }, + value: { + selector: '.js-ci-variable-input-value', + default: '', + }, + protected: { + selector: '.js-ci-variable-input-protected', + default: 'true', + }, + environment: { + // 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-ce/issues/42458 + selector: `input[name="${this.formField}[variables_attributes][][environment]"]`, + 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); + } + }); + + // Always make sure there is an empty last row + this.$container.on('input trigger-change', inputSelector, () => { + const $lastRow = this.$container.find('.js-row').last(); + + if (this.checkIfRowTouched($lastRow)) { + this.insertRow($lastRow); + } + }); + } + + initRow(rowEl) { + const $row = $(rowEl); + + setupToggleButtons($row[0]); + + 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]`, + 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.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); + }); + + this.initRow($rowClone); + + $row.after($rowClone); + } + + removeRow($row) { + const isPersisted = convertPermissionToBoolean($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(); + } + } + + checkIfRowTouched($row) { + return Object.keys(this.inputMap).some((name) => { + const entry = this.inputMap[name]; + const $el = $row.find(entry.selector); + return $el.length && $el.val() !== entry.default; + }); + } + + 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.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_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js new file mode 100644 index 00000000000..d54ea7df1c3 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js @@ -0,0 +1,26 @@ +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', ''); + } + }); +} diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index ff9e4485916..46232726510 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -8,6 +8,8 @@ import 'core-js/fn/promise'; import 'core-js/fn/string/code-point-at'; import 'core-js/fn/string/from-code-point'; import 'core-js/fn/symbol'; +import 'core-js/es6/map'; +import 'core-js/es6/weak-map'; // Browser polyfills import 'classlist-polyfill'; diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js index f1cf6e92ef5..0b1a81bae13 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js @@ -4,7 +4,7 @@ import GlFieldErrors from '../gl_field_errors'; import intervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; import TargetBranchDropdown from './components/target_branch_dropdown'; -import { setupPipelineVariableList } from './setup_pipeline_variable_list'; +import setupNativeFormVariableList from '../ci_variable_list/native_form_variable_list'; Vue.use(Translate); @@ -42,5 +42,8 @@ document.addEventListener('DOMContentLoaded', () => { gl.targetBranchDropdown = new TargetBranchDropdown(); gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement); - setupPipelineVariableList($('.js-pipeline-variable-list')); + setupNativeFormVariableList({ + container: $('.js-ci-variable-list-section'), + formField: 'schedule', + }); }); diff --git a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js b/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js deleted file mode 100644 index 9e0e5cacb11..00000000000 --- a/app/assets/javascripts/pipeline_schedules/setup_pipeline_variable_list.js +++ /dev/null @@ -1,73 +0,0 @@ -import { convertPermissionToBoolean } from '../lib/utils/common_utils'; - -function insertRow($row) { - const $rowClone = $row.clone(); - $rowClone.removeAttr('data-is-persisted'); - $rowClone.find('input, textarea').val(''); - $row.after($rowClone); -} - -function removeRow($row) { - const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted')); - - if (isPersisted) { - $row.hide(); - $row - .find('.js-destroy-input') - .val(1); - } else { - $row.remove(); - } -} - -function checkIfRowTouched($row) { - return $row.find('.js-user-input').toArray().some(el => $(el).val().length > 0); -} - -function setupPipelineVariableList(parent = document) { - const $parent = $(parent); - - $parent.on('click', '.js-row-remove-button', (e) => { - const $row = $(e.currentTarget).closest('.js-row'); - removeRow($row); - - e.preventDefault(); - }); - - // Remove any empty rows except the last r - $parent.on('blur', '.js-user-input', (e) => { - const $row = $(e.currentTarget).closest('.js-row'); - - const isTouched = checkIfRowTouched($row); - if ($row.is(':not(:last-child)') && !isTouched) { - removeRow($row); - } - }); - - // Always make sure there is an empty last row - $parent.on('input', '.js-user-input', () => { - const $lastRow = $parent.find('.js-row').last(); - - const isTouched = checkIfRowTouched($lastRow); - if (isTouched) { - insertRow($lastRow); - } - }); - - // Clear out the empty last row so it - // doesn't get submitted and throw validation errors - $parent.closest('form').on('submit', () => { - const $lastRow = $parent.find('.js-row').last(); - - const isTouched = checkIfRowTouched($lastRow); - if (!isTouched) { - $lastRow.find('input, textarea').attr('name', ''); - } - }); -} - -export { - setupPipelineVariableList, - insertRow, - removeRow, -}; diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js index 974dc3ee052..2d680d0f0dc 100644 --- a/app/assets/javascripts/toggle_buttons.js +++ b/app/assets/javascripts/toggle_buttons.js @@ -13,7 +13,7 @@ import { convertPermissionToBoolean } from './lib/utils/common_utils'; ``` */ -function updatetoggle(toggle, isOn) { +function updateToggle(toggle, isOn) { toggle.classList.toggle('is-checked', isOn); } @@ -21,7 +21,7 @@ function onToggleClicked(toggle, input, clickCallback) { const previousIsOn = convertPermissionToBoolean(input.value); // Visually change the toggle and start loading - updatetoggle(toggle, !previousIsOn); + updateToggle(toggle, !previousIsOn); toggle.setAttribute('disabled', true); toggle.classList.toggle('is-loading', true); @@ -32,7 +32,7 @@ function onToggleClicked(toggle, input, clickCallback) { }) .catch(() => { // Revert the visuals if something goes wrong - updatetoggle(toggle, previousIsOn); + updateToggle(toggle, previousIsOn); }) .then(() => { // Remove the loading indicator in any case @@ -54,7 +54,7 @@ export default function setupToggleButtons(container, clickCallback = () => {}) const isOn = convertPermissionToBoolean(input.value); // Get the visible toggle in sync with the hidden input - updatetoggle(toggle, isOn); + updateToggle(toggle, isOn); toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback)); }); diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index cff47ea76ec..c4aad24e9c1 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -60,3 +60,4 @@ @import "framework/memory_graph"; @import "framework/responsive_tables"; @import "framework/stacked-progress-bar"; +@import "framework/ci_variable_list"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index d0b0c69b18f..c4b046a6d68 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -176,6 +176,11 @@ &.btn-remove { @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); } + + &.btn-primary, + &.btn-info { + @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); + } } &.btn-gray { diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss new file mode 100644 index 00000000000..8f654ab363c --- /dev/null +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -0,0 +1,88 @@ +.ci-variable-list { + margin-left: 0; + margin-bottom: 0; + padding-left: 0; + list-style: none; + clear: both; +} + +.ci-variable-row { + display: flex; + align-items: flex-end; + + &:not(:last-child) { + margin-bottom: $gl-btn-padding; + + @media (max-width: $screen-xs-max) { + margin-bottom: 3 * $gl-btn-padding; + } + } + + &:last-child { + .ci-variable-body-item:last-child { + margin-right: $ci-variable-remove-button-width; + + @media (max-width: $screen-xs-max) { + margin-right: 0; + } + } + + .ci-variable-row-remove-button { + display: none; + } + + @media (max-width: $screen-xs-max) { + .ci-variable-row-body { + margin-right: $ci-variable-remove-button-width; + } + } + } +} + +.ci-variable-row-body { + display: flex; + width: 100%; + + @media (max-width: $screen-xs-max) { + display: block; + } +} + +.ci-variable-body-item { + flex: 1; + + &:not(:last-child) { + margin-right: $gl-btn-padding; + + @media (max-width: $screen-xs-max) { + margin-right: 0; + margin-bottom: $gl-btn-padding; + } + } +} + +.ci-variable-protected-item { + flex: 0 1 auto; + display: flex; + align-items: center; +} + +.ci-variable-row-remove-button { + @include transition(color); + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + width: $ci-variable-remove-button-width; + height: $input-height; + padding: 0; + background: transparent; + border: 0; + color: $gl-text-color-secondary; + + &:hover, + &:focus { + outline: none; + color: $gl-text-color; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index f76c6866463..1cc22f5658d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -668,9 +668,9 @@ $pipeline-dropdown-line-height: 20px; $pipeline-dropdown-status-icon-size: 18px; /* -Pipeline Schedules +CI variable lists */ -$pipeline-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); +$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding}); /* diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss index b698a4f9afa..bc7fa8a26d9 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -78,84 +78,3 @@ margin-right: 3px; } } - -.pipeline-variable-list { - margin-left: 0; - margin-bottom: 0; - padding-left: 0; - list-style: none; - clear: both; -} - -.pipeline-variable-row { - display: flex; - align-items: flex-end; - - &:not(:last-child) { - margin-bottom: $gl-btn-padding; - } - - @media (max-width: $screen-sm-max) { - padding-right: $gl-col-padding; - } - - &:last-child { - .pipeline-variable-row-remove-button { - display: none; - } - - @media (max-width: $screen-sm-max) { - .pipeline-variable-value-input { - margin-right: $pipeline-variable-remove-button-width; - } - } - - @media (max-width: $screen-xs-max) { - .pipeline-variable-row-body { - margin-right: $pipeline-variable-remove-button-width; - } - } - } -} - -.pipeline-variable-row-body { - display: flex; - width: calc(75% - #{$gl-col-padding}); - padding-left: $gl-col-padding; - - @media (max-width: $screen-sm-max) { - width: 100%; - } - - @media (max-width: $screen-xs-max) { - display: block; - } -} - -.pipeline-variable-key-input { - margin-right: $gl-btn-padding; - - @media (max-width: $screen-xs-max) { - margin-bottom: $gl-btn-padding; - } -} - -.pipeline-variable-row-remove-button { - @include transition(color); - flex-shrink: 0; - display: flex; - justify-content: center; - align-items: center; - width: $pipeline-variable-remove-button-width; - height: $input-height; - padding: 0; - background: transparent; - border: 0; - color: $gl-text-color-secondary; - - &:hover, - &:focus { - outline: none; - color: $gl-text-color; - } -} diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml new file mode 100644 index 00000000000..495a55660cb --- /dev/null +++ b/app/views/ci/variables/_variable_row.html.haml @@ -0,0 +1,49 @@ +- form_field = local_assigns.fetch(:form_field, nil) +- variable = local_assigns.fetch(:variable, nil) +- only_key_value = local_assigns.fetch(:only_key_value, false) + +- id = variable&.id +- key = variable&.key +- value = variable&.value +- is_protected = variable && !only_key_value ? variable.protected : true + +- id_input_name = "#{form_field}[variables_attributes][][id]" +- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" +- key_input_name = "#{form_field}[variables_attributes][][key]" +- value_input_name = "#{form_field}[variables_attributes][][value]" +- protected_input_name = "#{form_field}[variables_attributes][][protected]" + +%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } + .ci-variable-row-body + %input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id } + %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } + %input.js-ci-variable-input-key.ci-variable-body-item.form-control{ type: "text", + name: key_input_name, + value: key, + placeholder: s_('CiVariables|Input variable key') } + .ci-variable-body-item + .form-control.js-secret-value-placeholder{ class: ('hide' unless id) } + = '*' * 20 + %textarea.js-ci-variable-input-value.js-secret-value.form-control{ class: ('hide' if id), + rows: 1, + name: value_input_name, + placeholder: s_('CiVariables|Input variable value') } + = value + - unless only_key_value + .ci-variable-body-item.ci-variable-protected-item + .append-right-default + = s_("CiVariable|Protected") + %button{ type: 'button', + class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}", + "aria-label": s_("CiVariable|Toggle protected") } + %input{ type: "hidden", + class: 'js-ci-variable-input-protected js-project-feature-toggle-input', + name: protected_input_name, + value: is_protected } + %span.toggle-icon + = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') + -# EE-specific start + -# EE-specific end + %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = icon('minus-circle') diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 857ae00d0ab..ff440e99042 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -22,14 +22,20 @@ = f.label :ref, _('Target Branch'), class: 'label-light' = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true - .form-group + .form-group.js-ci-variable-list-section .col-md-9 %label.label-light #{ s_('PipelineSchedules|Variables') } - %ul.js-pipeline-variable-list.pipeline-variable-list - - @schedule.variables.each do |variable| - = render 'variable_row', id: variable.id, key: variable.key, value: variable.value - = render 'variable_row' + %ul.ci-variable-list + - @schedule.variables.each do |variable| + = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true + = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true + - if @schedule.variables.size > 0 + %button.btn.btn-info.btn-inverted.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } } + - if @schedule.variables.size == 0 + = n_('Hide value', 'Hide values', @schedule.variables.size) + - else + = n_('Reveal value', 'Reveal values', @schedule.variables.size) .form-group .col-md-9 = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-light' diff --git a/app/views/projects/pipeline_schedules/_variable_row.html.haml b/app/views/projects/pipeline_schedules/_variable_row.html.haml deleted file mode 100644 index 564cb5d1ca9..00000000000 --- a/app/views/projects/pipeline_schedules/_variable_row.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -- id = local_assigns.fetch(:id, nil) -- key = local_assigns.fetch(:key, "") -- value = local_assigns.fetch(:value, "") -%li.js-row.pipeline-variable-row{ data: { is_persisted: "#{!id.nil?}" } } - .pipeline-variable-row-body - %input{ type: "hidden", name: "schedule[variables_attributes][][id]", value: id } - %input.js-destroy-input{ type: "hidden", name: "schedule[variables_attributes][][_destroy]" } - %input.js-user-input.pipeline-variable-key-input.form-control{ type: "text", - name: "schedule[variables_attributes][][key]", - value: key, - placeholder: s_('PipelineSchedules|Input variable key') } - %textarea.js-user-input.pipeline-variable-value-input.form-control{ rows: 1, - name: "schedule[variables_attributes][][value]", - placeholder: s_('PipelineSchedules|Input variable value') } - = value - %button.js-row-remove-button.pipeline-variable-row-remove-button{ 'aria-label': s_('PipelineSchedules|Remove variable row') } - %i.fa.fa-minus-circle{ 'aria-hidden': "true" } diff --git a/changelogs/unreleased/refactor-ci-variable-list-for-future-usage-in-4110.yml b/changelogs/unreleased/refactor-ci-variable-list-for-future-usage-in-4110.yml new file mode 100644 index 00000000000..d43675e175d --- /dev/null +++ b/changelogs/unreleased/refactor-ci-variable-list-for-future-usage-in-4110.yml @@ -0,0 +1,5 @@ +--- +title: Hide variable values on pipeline schedule edit page +merge_request: 16729 +author: +type: changed diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index fa2f7a1fd78..65e24862d43 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -168,11 +168,11 @@ feature 'Pipeline Schedules', :js do scenario 'user sees the new variable in edit window' do find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.pipeline-variable-list') do - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('AAA') - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('AAA123') - expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-key-input").value).to eq('BBB') - expect(find(".pipeline-variable-row:nth-child(2) .pipeline-variable-value-input").value).to eq('BBB123') + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123') end end end @@ -185,16 +185,18 @@ feature 'Pipeline Schedules', :js do visit_pipelines_schedules find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - all('[name="schedule[variables_attributes][][key]"]')[0].set('foo') - all('[name="schedule[variables_attributes][][value]"]')[0].set('bar') + + find('.js-ci-variable-list-section .js-secret-value-reveal-button').click + first('.js-ci-variable-input-key').set('foo') + first('.js-ci-variable-input-value').set('bar') click_button 'Save pipeline schedule' end scenario 'user sees the updated variable in edit window' do find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.pipeline-variable-list') do - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('foo') - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('bar') + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar') end end end @@ -207,15 +209,15 @@ feature 'Pipeline Schedules', :js do visit_pipelines_schedules find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - find('.pipeline-variable-list .pipeline-variable-row-remove-button').click + find('.ci-variable-list .ci-variable-row-remove-button').click click_button 'Save pipeline schedule' end scenario 'user does not see the removed variable in edit window' do find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.pipeline-variable-list') do - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-key-input").value).to eq('') - expect(find(".pipeline-variable-row:nth-child(1) .pipeline-variable-value-input").value).to eq('') + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('') end end end diff --git a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js new file mode 100644 index 00000000000..0170ab458d4 --- /dev/null +++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js @@ -0,0 +1,163 @@ +import VariableList from '~/ci_variable_list/ci_variable_list'; +import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; + +describe('VariableList', () => { + preloadFixtures('pipeline_schedules/edit.html.raw'); + preloadFixtures('pipeline_schedules/edit_with_variables.html.raw'); + + let $wrapper; + let variableList; + + describe('with only key/value inputs', () => { + describe('with no variables', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + it('should remove the row when clicking the remove button', () => { + $wrapper.find('.js-row-remove-button').trigger('click'); + + expect($wrapper.find('.js-row').length).toBe(0); + }); + + it('should add another row when editing the last rows key input', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + expect($keyInput.val()).toBe(''); + }); + + it('should add another row when editing the last rows value textarea', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-value') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); + expect($valueInput.val()).toBe(''); + }); + + it('should remove empty row after blurring', () => { + const $row = $wrapper.find('.js-row'); + $row.find('.js-ci-variable-input-key') + .val('foo') + .trigger('input'); + + expect($wrapper.find('.js-row').length).toBe(2); + + $row.find('.js-ci-variable-input-key') + .val('') + .trigger('input') + .trigger('blur'); + + expect($wrapper.find('.js-row').length).toBe(1); + }); + }); + + describe('with persisted variables', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit_with_variables.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); + + variableList = new VariableList({ + container: $wrapper, + formField: 'schedule', + }); + variableList.init(); + }); + + it('should have "Reveal values" button initially when there are already variables', () => { + expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); + }); + + it('should reveal hidden values', () => { + const $row = $wrapper.find('.js-row:first-child'); + const $inputValue = $row.find('.js-ci-variable-input-value'); + const $placeholder = $row.find('.js-secret-value-placeholder'); + + expect($placeholder.hasClass('hide')).toBe(false); + expect($inputValue.hasClass('hide')).toBe(true); + + // Reveal values + $wrapper.find('.js-secret-value-reveal-button').click(); + + expect($placeholder.hasClass('hide')).toBe(true); + expect($inputValue.hasClass('hide')).toBe(false); + }); + }); + }); + + describe('with all inputs(key, value, protected)', () => { + beforeEach(() => { + // This markup will be replaced with a fixture when we can render the + // CI/CD settings page with the new dynamic variable list in https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4110 + $wrapper = $(`
+ + +
`); + + variableList = new VariableList({ + container: $wrapper, + formField: 'variables', + }); + variableList.init(); + }); + + it('should add another row when editing the last rows protected checkbox', (done) => { + const $row = $wrapper.find('.js-row:last-child'); + $row.find('.ci-variable-protected-item .js-project-feature-toggle').click(); + + getSetTimeoutPromise() + .then(() => { + expect($wrapper.find('.js-row').length).toBe(2); + + // Check for the correct default in the new row + const $protectedInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-protected'); + expect($protectedInput.val()).toBe('true'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js new file mode 100644 index 00000000000..eb508a7f059 --- /dev/null +++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js @@ -0,0 +1,30 @@ +import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; + +describe('NativeFormVariableList', () => { + preloadFixtures('pipeline_schedules/edit.html.raw'); + + let $wrapper; + + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html.raw'); + $wrapper = $('.js-ci-variable-list-section'); + + setupNativeFormVariableList({ + container: $wrapper, + formField: 'schedule', + }); + }); + + describe('onFormSubmit', () => { + it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { + const $row = $wrapper.find('.js-row'); + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][key]'); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][value]'); + + $wrapper.closest('form').trigger('trigger-submit'); + + expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(''); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe(''); + }); + }); +}); diff --git a/spec/javascripts/fixtures/pipeline_schedules.rb b/spec/javascripts/fixtures/pipeline_schedules.rb new file mode 100644 index 00000000000..56f27ea7df1 --- /dev/null +++ b/spec/javascripts/fixtures/pipeline_schedules.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :public, :repository) } + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: admin) } + let!(:pipeline_schedule_populated) { create(:ci_pipeline_schedule, project: project, owner: admin) } + let!(:pipeline_schedule_variable1) { create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule_populated) } + let!(:pipeline_schedule_variable2) { create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule_populated) } + + render_views + + before(:all) do + clean_frontend_fixtures('pipeline_schedules/') + end + + before do + sign_in(admin) + end + + it 'pipeline_schedules/edit.html.raw' do |example| + get :edit, + namespace_id: project.namespace.to_param, + project_id: project, + id: pipeline_schedule.id + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + + it 'pipeline_schedules/edit_with_variables.html.raw' do |example| + get :edit, + namespace_id: project.namespace.to_param, + project_id: project, + id: pipeline_schedule_populated.id + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js b/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js deleted file mode 100644 index 5b316b319a5..00000000000 --- a/spec/javascripts/pipeline_schedules/setup_pipeline_variable_list_spec.js +++ /dev/null @@ -1,145 +0,0 @@ -import { - setupPipelineVariableList, - insertRow, - removeRow, -} from '~/pipeline_schedules/setup_pipeline_variable_list'; - -describe('Pipeline Variable List', () => { - let $markup; - - describe('insertRow', () => { - it('should insert another row', () => { - $markup = $(`
-
  • - - -
  • -
    `); - - insertRow($markup.find('.js-row')); - - expect($markup.find('.js-row').length).toBe(2); - }); - - it('should clear `data-is-persisted` on cloned row', () => { - $markup = $(`
    -
  • -
    `); - - insertRow($markup.find('.js-row')); - - const $lastRow = $markup.find('.js-row').last(); - expect($lastRow.attr('data-is-persisted')).toBe(undefined); - }); - - it('should clear inputs on cloned row', () => { - $markup = $(`
    -
  • - - -
  • -
    `); - - insertRow($markup.find('.js-row')); - - const $lastRow = $markup.find('.js-row').last(); - expect($lastRow.find('input').val()).toBe(''); - expect($lastRow.find('textarea').val()).toBe(''); - }); - }); - - describe('removeRow', () => { - it('should remove dynamic row', () => { - $markup = $(`
    -
  • - - -
  • -
    `); - - removeRow($markup.find('.js-row')); - - expect($markup.find('.js-row').length).toBe(0); - }); - - it('should hide and mark to destroy with already persisted rows', () => { - $markup = $(`
    -
  • - -
  • -
    `); - - const $row = $markup.find('.js-row'); - removeRow($row); - - expect($row.find('.js-destroy-input').val()).toBe('1'); - expect($markup.find('.js-row').length).toBe(1); - }); - }); - - describe('setupPipelineVariableList', () => { - beforeEach(() => { - $markup = $(`
    -
  • - - - - -
  • -
    `); - - setupPipelineVariableList($markup); - }); - - it('should remove the row when clicking the remove button', () => { - $markup.find('.js-row-remove-button').trigger('click'); - - expect($markup.find('.js-row').length).toBe(0); - }); - - it('should add another row when editing the last rows key input', () => { - const $row = $markup.find('.js-row'); - $row.find('input.js-user-input') - .val('foo') - .trigger('input'); - - expect($markup.find('.js-row').length).toBe(2); - }); - - it('should add another row when editing the last rows value textarea', () => { - const $row = $markup.find('.js-row'); - $row.find('textarea.js-user-input') - .val('foo') - .trigger('input'); - - expect($markup.find('.js-row').length).toBe(2); - }); - - it('should remove empty row after blurring', () => { - const $row = $markup.find('.js-row'); - $row.find('input.js-user-input') - .val('foo') - .trigger('input'); - - expect($markup.find('.js-row').length).toBe(2); - - $row.find('input.js-user-input') - .val('') - .trigger('input') - .trigger('blur'); - - expect($markup.find('.js-row').length).toBe(1); - }); - - it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { - const $row = $markup.find('.js-row'); - expect($row.find('input').attr('name')).toBe('schedule[variables_attributes][][key]'); - expect($row.find('textarea').attr('name')).toBe('schedule[variables_attributes][][value]'); - - $markup.filter('form').submit(); - - expect($row.find('input').attr('name')).toBe(''); - expect($row.find('textarea').attr('name')).toBe(''); - }); - }); -}); -- cgit v1.2.1