diff options
author | mfluharty <mfluharty@gitlab.com> | 2019-02-28 20:18:39 -0700 |
---|---|---|
committer | mfluharty <mfluharty@gitlab.com> | 2019-05-28 21:50:59 +0100 |
commit | 46b093ecb7da441ba3b480c463d6f43cd81b1918 (patch) | |
tree | a742e5f6b01354fe74bb8db20d20f216a7c7c1bb | |
parent | 0c5b3d08dedccb5808c6df6560b3e277035691db (diff) | |
download | gitlab-ce-13784-validate-variables-for-masking-enhanced.tar.gz |
Move variable settings to Vue table and modal13784-validate-variables-for-masking-enhanced
Convert existing variable settings to vue app
Show variables in a table
Add, edit, and delete variables via modal
18 files changed, 677 insertions, 361 deletions
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js deleted file mode 100644 index 592e1fd1c31..00000000000 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ /dev/null @@ -1,119 +0,0 @@ -import _ from 'underscore'; -import axios from '../lib/utils/axios_utils'; -import { s__ } from '../locale'; -import Flash from '../flash'; -import { parseBoolean } from '../lib/utils/common_utils'; -import statusCodes from '../lib/utils/http_status'; -import VariableList from './ci_variable_list'; - -function generateErrorBoxContent(errors) { - const errorList = [].concat(errors).map( - errorString => ` - <li> - ${_.escape(errorString)} - </li> - `, - ); - - return ` - <p> - ${s__('CiVariable|Validation failed')} - </p> - <ul> - ${errorList.join('')} - </ul> - `; -} - -// Used for the variable list on CI/CD projects/groups settings page -export default class AjaxVariableList { - constructor({ container, saveButton, errorBox, formField = 'variables', saveEndpoint }) { - this.container = container; - this.saveButton = saveButton; - this.errorBox = errorBox; - this.saveEndpoint = saveEndpoint; - - this.variableList = new VariableList({ - container: this.container, - formField, - }); - - this.bindEvents(); - this.variableList.init(); - } - - bindEvents() { - this.saveButton.addEventListener('click', this.onSaveClicked.bind(this)); - } - - onSaveClicked() { - const loadingIcon = this.saveButton.querySelector('.js-ci-variables-save-loading-icon'); - loadingIcon.classList.toggle('hide', false); - this.errorBox.classList.toggle('hide', true); - // We use this to prevent a user from changing a key before we have a chance - // to match it up in `updateRowsWithPersistedVariables` - this.variableList.toggleEnableRow(false); - - return axios - .patch( - this.saveEndpoint, - { - variables_attributes: this.variableList.getAllData(), - }, - { - // We want to be able to process the `res.data` from a 400 error response - // and print the validation messages such as duplicate variable keys - validateStatus: status => - (status >= statusCodes.OK && status < statusCodes.MULTIPLE_CHOICES) || - status === statusCodes.BAD_REQUEST, - }, - ) - .then(res => { - loadingIcon.classList.toggle('hide', true); - this.variableList.toggleEnableRow(true); - - if (res.status === statusCodes.OK && res.data) { - this.updateRowsWithPersistedVariables(res.data.variables); - this.variableList.hideValues(); - } else if (res.status === statusCodes.BAD_REQUEST) { - // Validation failed - this.errorBox.innerHTML = generateErrorBoxContent(res.data); - this.errorBox.classList.toggle('hide', false); - } - }) - .catch(() => { - loadingIcon.classList.toggle('hide', true); - this.variableList.toggleEnableRow(true); - Flash(s__('CiVariable|Error occurred while saving variables')); - }); - } - - updateRowsWithPersistedVariables(persistedVariables = []) { - const persistedVariableMap = [].concat(persistedVariables).reduce( - (variableMap, variable) => ({ - ...variableMap, - [variable.key]: variable, - }), - {}, - ); - - this.container.querySelectorAll('.js-row').forEach(row => { - // If we submitted a row that was destroyed, remove it so we don't try - // to destroy it again which would cause a BE error - const destroyInput = row.querySelector('.js-ci-variable-input-destroy'); - if (parseBoolean(destroyInput.value)) { - row.remove(); - // Update the ID input so any future edits and `_destroy` will apply on the BE - } else { - const key = row.querySelector('.js-ci-variable-input-key').value; - const persistedVariable = persistedVariableMap[key]; - - if (persistedVariable) { - // eslint-disable-next-line no-param-reassign - row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id; - row.setAttribute('data-is-persisted', 'true'); - } - } - }); - } -} diff --git a/app/assets/javascripts/ci_variable_list_vue/components/variable.vue b/app/assets/javascripts/ci_variable_list_vue/components/variable.vue new file mode 100644 index 00000000000..558f1ead26c --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/components/variable.vue @@ -0,0 +1,83 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + EnvironmentScope: () => + import('ee_component/ci_variable_list_vue/components/environment_scope.vue'), + }, + props: { + variable: { + type: Object, + required: true, + }, + valueVisible: { + type: Boolean, + required: true, + }, + setModalVariable: { + type: Function, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-15"> + <div role="rowheader" class="table-mobile-header">{{ s__('Variables|Type') }}</div> + <div class="table-mobile-content"> + <div class="type">{{ variable.variable_type }}</div> + </div> + </div> + <div class="table-section section-20"> + <div role="rowheader" class="table-mobile-header">{{ s__('Variables|Key') }}</div> + <div class="table-mobile-content"> + <div class="key">{{ variable.key }}</div> + </div> + </div> + <div class="table-section section-20"> + <div role="rowheader" class="table-mobile-header">{{ s__('Variables|Value') }}</div> + <div class="table-mobile-content"> + <div class="value">{{ valueVisible ? variable.value : '**********' }}</div> + </div> + </div> + <div class="table-section section-15"> + <div role="rowheader" class="table-mobile-header">{{ s__('Variables|Protected') }}</div> + <div class="table-mobile-content"> + <div class="protected">{{ variable.protected ? 'Yes' : 'No' }}</div> + </div> + </div> + <div class="table-section section-15"> + <div role="rowheader" class="table-mobile-header">{{ s__('Variables|Masked') }}</div> + <div class="table-mobile-content"> + <div class="masked">{{ variable.masked ? 'Yes' : 'No' }}</div> + </div> + </div> + <environment-scope :variable="variable" /> + <div class="table-section section-15 table-button-footer deploy-key-actions"> + <div class="table-action-buttons"> + <button + class="btn btn-default" + type="button" + data-target="#edit-variable-modal" + data-toggle="modal" + @click="setModalVariable(variable)" + > + <icon :size="16" :aria-label="__('Edit')" name="pencil" /> + </button> + <button + class="btn btn-danger btn-inverted" + type="button" + data-target="#delete-variable-modal" + data-toggle="modal" + @click="setModalVariable(variable)" + > + <icon :size="16" :aria-label="__('Delete')" name="remove" /> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list_vue/components/variable_form.vue b/app/assets/javascripts/ci_variable_list_vue/components/variable_form.vue new file mode 100644 index 00000000000..50b54d6c6ed --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/components/variable_form.vue @@ -0,0 +1,156 @@ +<script> +export default { + components: { + EnvironmentScopeSelector: () => + import('ee_component/ci_variable_list_vue/components/environment_scope_selector.vue'), + }, + props: { + variable: { + type: Object, + required: true, + }, + updateModalVariable: { + type: Function, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '/help/ci/variables/README#masked-variables', + }, + }, + computed: { + type: { + get() { + return this.variable.variable_type || ''; + }, + set(variableType) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + variable_type: variableType, + }); + }, + }, + key: { + get() { + return this.variable.key || ''; + }, + set(key) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + key, + }); + }, + }, + value: { + get() { + return this.variable.value || ''; + }, + set(value) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + value, + }); + }, + }, + scope: { + get() { + return this.variable.scope || ''; + }, + set(scope) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + scope, + }); + }, + }, + protectedFlag: { + get() { + return this.variable.protected || ''; + }, + set(protectedFlag) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + protected: protectedFlag, + }); + }, + }, + maskedFlag: { + get() { + return this.variable.masked || ''; + }, + set(masked) { + const variable = this.variable || {}; + this.updateModalVariable({ + ...variable, + masked, + }); + }, + }, + warnAboutMaskability: { + get() { + // Eight or more alphanumeric characters plus underscores + const regex = /^\w{8,}$/; + const maskedChecked = this.variable.masked; + const variableValue = this.variable.value; + return maskedChecked && !regex.test(variableValue); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-show-field-errors"> + <div class="row"> + <div class="form-group col-md-12"> + <label for="variable-type" class="label-bold">{{ s__('Variables|Type') }}</label> + <input id="variable-type" v-model="type" type="text" class="form-control" required /> + </div> + <div class="form-group col-md-6"> + <label for="variable-key" class="label-bold">{{ s__('Variables|Key') }}</label> + <input id="variable-key" v-model="key" type="text" class="form-control" required /> + </div> + <div class="form-group col-md-6"> + <label for="variable-value" class="label-bold">{{ s__('Variables|Value') }}</label> + <input + id="variable-value" + v-model="value" + type="text" + :class="{ + 'form-control': true, + 'gl-field-error-outline': warnAboutMaskability, + }" + required + /> + <span v-if="warnAboutMaskability" class="gl-field-error"> + Cannot use Masked Variable with current value + <a v-if="helpPath" :href="helpPath" target="_blank"> + <i aria-hidden="true" data-hidden="true" class="fa fa-question-circle"> </i> + </a> + </span> + </div> + </div> + <environment-scope-selector :scope="scope" /> + <div class="form-group"> + <label class="label-bold">{{ s__('Variables|Flags') }}</label> + <fieldset> + <label class="form-group"> + <input id="variable-protected" v-model="protectedFlag" type="checkbox" /> + {{ s__('Variables|Protect variable') }} + </label> + </fieldset> + <fieldset> + <label class="form-group"> + <input id="variable-masked" v-model="maskedFlag" type="checkbox" /> + {{ s__('Variables|Mask variable') }} + </label> + </fieldset> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list_vue/components/variable_settings.vue b/app/assets/javascripts/ci_variable_list_vue/components/variable_settings.vue new file mode 100644 index 00000000000..5a94efee3d4 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/components/variable_settings.vue @@ -0,0 +1,112 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import store from '../store'; +import VariablesTable from './variables_table.vue'; +import VariableForm from './variable_form.vue'; + +export default { + store, + components: { + VariablesTable, + VariableForm, + GlLoadingIcon, + GlModal, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters(['hasVariables']), + ...mapState(['variables', 'valuesVisible', 'isLoading', 'modalVariable']), + }, + created() { + this.setEndpoint(this.endpoint); + }, + mounted() { + this.fetchVariables(); + }, + methods: { + ...mapActions([ + 'setEndpoint', + 'fetchVariables', + 'toggleValuesVisibility', + 'setModalVariable', + 'updateModalVariable', + 'addVariable', + 'updateVariable', + 'deleteVariable', + ]), + }, +}; +</script> + +<template> + <div class="variables-settings col-lg-12"> + <gl-modal + id="add-variable-modal" + :header-title-text="s__('Variables|Add variable')" + :footer-primary-button-text="s__('Variables|Add variable')" + footer-primary-button-variant="success" + @submit="addVariable" + > + <variable-form :variable="modalVariable" :update-modal-variable="updateModalVariable" /> + </gl-modal> + <gl-modal + id="edit-variable-modal" + :header-title-text="s__('Variables|Edit variable')" + :footer-primary-button-text="s__('Variables|Save variable')" + footer-primary-button-variant="success" + @submit="updateVariable" + > + <variable-form :variable="modalVariable" :update-modal-variable="updateModalVariable" /> + </gl-modal> + <gl-modal + id="delete-variable-modal" + :header-title-text="s__('Variables|Delete variable?')" + :footer-primary-button-text="s__('Variables|Delete variable')" + footer-primary-button-variant="danger" + @submit="deleteVariable" + > + Are you sure you want to delete {{ modalVariable.key }}? + </gl-modal> + <div class="content-block"> + <gl-loading-icon + v-if="isLoading && !hasVariables" + :label="s__('Variables|Loading variables')" + :size="2" + /> + <template v-else-if="hasVariables"> + <h5>Variables</h5> + <variables-table + :values-visible="valuesVisible" + :variables="variables" + :endpoint="endpoint" + :set-modal-variable="setModalVariable" + /> + </template> + <div class="controls prepend-top-default"> + <button + type="button" + class="btn prepend-top-default append-right-10 js-secret-value-reveal-button" + @click="toggleValuesVisibility()" + > + {{ valuesVisible ? 'Hide values' : 'Reveal values' }} + </button> + <button + type="button" + class="btn btn-success btn-inverted prepend-top-default js-ci-variables-save-button" + data-target="#add-variable-modal" + data-toggle="modal" + @click="setModalVariable({})" + > + Add variable + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list_vue/components/variables_table.vue b/app/assets/javascripts/ci_variable_list_vue/components/variables_table.vue new file mode 100644 index 00000000000..495ff47e948 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/components/variables_table.vue @@ -0,0 +1,66 @@ +<script> +import Variable from './variable.vue'; + +export default { + components: { + Variable, + EnvironmentScopeHeader: () => + import('ee_component/ci_variable_list_vue/components/environment_scope_header.vue'), + }, + props: { + variables: { + type: Array, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + valuesVisible: { + type: Boolean, + required: true, + }, + setModalVariable: { + type: Function, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <template v-if="variables.length > 0"> + <div class="table-responsive variables-list"> + <div role="row" class="gl-responsive-table-row table-row-header"> + <div role="rowheader" class="table-section section-15"> + {{ s__('Variables|Type') }} + </div> + <div role="rowheader" class="table-section section-20"> + {{ s__('Variables|Key') }} + </div> + <div role="rowheader" class="table-section section-20"> + {{ s__('Variables|Value') }} + </div> + <div role="rowheader" class="table-section section-15"> + {{ s__('Variables|Protected') }} + </div> + <div role="rowheader" class="table-section section-15"> + {{ s__('Variables|Masked') }} + </div> + <environment-scope-header /> + </div> + <variable + v-for="variable in variables" + :key="variable.id" + :variable="variable" + :value-visible="valuesVisible" + :set-modal-variable="setModalVariable" + /> + </div> + </template> + <div v-else class="settings-message text-center"> + {{ s__('Variables|No variables found. Create one with the button below.') }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list_vue/index.js b/app/assets/javascripts/ci_variable_list_vue/index.js new file mode 100644 index 00000000000..1dffd30ab74 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import variableListApp from './components/variable_settings.vue'; + +export default () => + new Vue({ + el: document.getElementById('js-ci-variable-list-section'), + components: { + variableListApp, + }, + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + projectId: this.$options.el.dataset.project_id, + }; + }, + render(createElement) { + return createElement('variable-list-app', { + props: { + endpoint: this.endpoint, + projectId: this.projectId, + }, + }); + }, + }); diff --git a/app/assets/javascripts/ci_variable_list_vue/store/actions.js b/app/assets/javascripts/ci_variable_list_vue/store/actions.js new file mode 100644 index 00000000000..40b5f38b130 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/actions.js @@ -0,0 +1,83 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); +export const toggleValuesVisibility = ({ commit }) => commit(types.TOGGLE_VALUES_VISIBILITY); +export const setModalVariable = ({ commit }, variable) => + commit(types.SET_MODAL_VARIABLE, variable); +export const updateModalVariable = ({ commit }, variable) => + commit(types.UPDATE_MODAL_VARIABLE, variable); + +export const requestVariables = ({ commit }) => commit(types.REQUEST_VARIABLES); +export const receiveVariablesSuccess = ({ commit }, data) => + commit(types.RECEIVE_VARIABLES_SUCCESS, data); +export const receiveVariablesError = ({ commit }, error) => + commit(types.RECEIVE_VARIABLES_ERROR, error); + +export const fetchVariables = ({ state, dispatch }) => { + dispatch('requestVariables'); + axios + .get(state.endpoint) + .then(({ data }) => dispatch('receiveVariablesSuccess', data)) + .catch(error => { + dispatch('receiveVariablesError', error); + createFlash('There was an error'); + }); +}; + +export const requestDeleteVariable = ({ commit }) => commit(types.REQUEST_DELETE_VARIABLE); +export const receiveDeleteVariableSuccess = ({ commit }, variable) => + commit(types.RECEIVE_DELETE_VARIABLE_SUCCESS, variable); +export const receiveDeleteVariableError = ({ commit }, error) => + commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error); + +export const deleteVariable = ({ state, dispatch }) => { + const variableToDelete = Object.assign(state.modalVariable, { _destroy: true }); + dispatch('requestDeleteVariable'); + axios + .patch(`${state.endpoint}`, { variables_attributes: [variableToDelete] }) + .then(({ data }) => dispatch('receiveDeleteVariableSuccess', data)) + .catch(error => { + dispatch('receiveDeleteVariableError', error); + createFlash('There was an error'); + }); +}; + +export const requestAddVariable = ({ commit }) => commit(types.REQUEST_ADD_VARIABLE); +export const receiveAddVariableSuccess = ({ commit }, variable) => + commit(types.RECEIVE_ADD_VARIABLE_SUCCESS, variable); +export const receiveAddVariableError = ({ commit }, error) => + commit(types.RECEIVE_ADD_VARIABLE_ERROR, error); + +export const addVariable = ({ state, dispatch }) => { + const newVariable = state.modalVariable; + newVariable.secret_value = newVariable.value; + dispatch('requestAddVariable'); + axios + .patch(state.endpoint, { variables_attributes: [newVariable] }) + .then(({ data }) => dispatch('receiveAddVariableSuccess', data)) + .catch(error => { + dispatch('receiveAddVariableError', error); + createFlash('There was an error'); + }); +}; + +export const requestUpdateVariable = ({ commit }) => commit(types.REQUEST_UPDATE_VARIABLE); +export const receiveUpdateVariableSuccess = ({ commit }, variable) => + commit(types.RECEIVE_UPDATE_VARIABLE_SUCCESS, variable); +export const receiveUpdateVariableError = ({ commit }, error) => + commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error); + +export const updateVariable = ({ state, dispatch }) => { + const updatedVariable = state.modalVariable; + updatedVariable.secret_value = updatedVariable.value; + dispatch('requestUpdateVariable'); + axios + .patch(`${state.endpoint}`, { variables_attributes: [updatedVariable] }) + .then(({ data }) => dispatch('receiveUpdateVariableSuccess', data)) + .catch(error => { + dispatch('receiveUpdateVariableError', error); + createFlash('There was an error'); + }); +}; diff --git a/app/assets/javascripts/ci_variable_list_vue/store/getters.js b/app/assets/javascripts/ci_variable_list_vue/store/getters.js new file mode 100644 index 00000000000..94939814aff --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/getters.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const hasVariables = state => Object.keys(state.variables).length; diff --git a/app/assets/javascripts/ci_variable_list_vue/store/index.js b/app/assets/javascripts/ci_variable_list_vue/store/index.js new file mode 100644 index 00000000000..b570f2495f6 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state, + }); +export default createStore(); diff --git a/app/assets/javascripts/ci_variable_list_vue/store/mutation_types.js b/app/assets/javascripts/ci_variable_list_vue/store/mutation_types.js new file mode 100644 index 00000000000..50b1dfdfb42 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/mutation_types.js @@ -0,0 +1,16 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const REQUEST_VARIABLES = 'REQUEST_VARIABLES'; +export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS'; +export const RECEIVE_VARIABLES_ERROR = 'RECEIVE_VARIABLES_ERROR'; +export const TOGGLE_VALUES_VISIBILITY = 'TOGGLE_VALUES_VISIBILITY'; +export const SET_MODAL_VARIABLE = 'SET_MODAL_VARIABLE'; +export const UPDATE_MODAL_VARIABLE = 'UPDATE_MODAL_VARIABLE'; +export const REQUEST_DELETE_VARIABLE = 'REQUEST_DELETE_VARIABLE'; +export const RECEIVE_DELETE_VARIABLE_SUCCESS = 'RECEIVE_DELETE_VARIABLE_SUCCESS'; +export const RECEIVE_DELETE_VARIABLE_ERROR = 'RECEIVE_DELETE_VARIABLE_ERROR'; +export const REQUEST_ADD_VARIABLE = 'REQUEST_ADD_VARIABLE'; +export const RECEIVE_ADD_VARIABLE_SUCCESS = 'RECEIVE_ADD_VARIABLE_SUCCESS'; +export const RECEIVE_ADD_VARIABLE_ERROR = 'RECEIVE_ADD_VARIABLE_ERROR'; +export const REQUEST_UPDATE_VARIABLE = 'REQUEST_UPDATE_VARIABLE'; +export const RECEIVE_UPDATE_VARIABLE_SUCCESS = 'RECEIVE_UPDATE_VARIABLE_SUCCESS'; +export const RECEIVE_UPDATE_VARIABLE_ERROR = 'RECEIVE_UPDATE_VARIABLE_ERROR'; diff --git a/app/assets/javascripts/ci_variable_list_vue/store/mutations.js b/app/assets/javascripts/ci_variable_list_vue/store/mutations.js new file mode 100644 index 00000000000..0187d3e623d --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/mutations.js @@ -0,0 +1,95 @@ +import * as types from './mutation_types'; + +const sortVariables = variables => variables.sort((a, b) => a.id - b.id); + +export default { + [types.SET_ENDPOINT](state, endpoint) { + Object.assign(state, { + endpoint, + }); + }, + [types.REQUEST_VARIABLES](state) { + Object.assign(state, { + isLoading: true, + }); + }, + [types.RECEIVE_VARIABLES_SUCCESS](state, data) { + Object.assign(state, { + isLoading: false, + variables: sortVariables(data.variables), + }); + }, + [types.RECEIVE_VARIABLES_ERROR](state, error) { + Object.assign(state, { + isLoading: false, + error, + }); + }, + [types.TOGGLE_VALUES_VISIBILITY](state) { + Object.assign(state, { + valuesVisible: !state.valuesVisible, + }); + }, + [types.SET_MODAL_VARIABLE](state, variable) { + Object.assign(state, { + modalVariableKey: variable.key, + modalVariable: variable, + }); + }, + [types.UPDATE_MODAL_VARIABLE](state, variable) { + Object.assign(state, { + modalVariable: variable, + }); + }, + [types.REQUEST_DELETE_VARIABLE](state, variable) { + Object.assign(state, { + isDeletingVariable: variable, + }); + }, + [types.RECEIVE_DELETE_VARIABLE_SUCCESS](state, data) { + Object.assign(state, { + isDeletingVariable: false, + variables: sortVariables(data.variables), + }); + }, + [types.RECEIVE_DELETE_VARIABLE_ERROR](state, error) { + Object.assign(state, { + isDeletingVariable: false, + error, + }); + }, + [types.REQUEST_ADD_VARIABLE](state, variable) { + Object.assign(state, { + isAddingVariable: variable, + }); + }, + [types.RECEIVE_ADD_VARIABLE_SUCCESS](state, data) { + Object.assign(state, { + isAddingVariable: false, + variables: sortVariables(data.variables), + }); + }, + [types.RECEIVE_ADD_VARIABLE_ERROR](state, error) { + Object.assign(state, { + isAddingVariable: false, + error, + }); + }, + [types.REQUEST_UPDATE_VARIABLE](state, variable) { + Object.assign(state, { + isUpdatingVariable: variable, + }); + }, + [types.RECEIVE_UPDATE_VARIABLE_SUCCESS](state, data) { + Object.assign(state, { + isUpdatingVariable: false, + variables: sortVariables(data.variables), + }); + }, + [types.RECEIVE_UPDATE_VARIABLE_ERROR](state, error) { + Object.assign(state, { + isUpdatingVariable: false, + error, + }); + }, +}; diff --git a/app/assets/javascripts/ci_variable_list_vue/store/state.js b/app/assets/javascripts/ci_variable_list_vue/store/state.js new file mode 100644 index 00000000000..19a23a52778 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list_vue/store/state.js @@ -0,0 +1,16 @@ +export default { + endpoint: null, + + isLoading: false, + error: null, + + isAddingVariable: false, + isUpdatingVariable: false, + isDeletingVariable: false, + + modalVariableKey: null, + modalVariable: {}, + + variables: [], + valuesVisible: false, +}; diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index ae0a8c74964..d729816bfac 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,16 +1,9 @@ import initSettingsPanels from '~/settings_panels'; -import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; +import initVariableList from '~/ci_variable_list_vue'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels initSettingsPanels(); - const variableListEl = document.querySelector('.js-ci-variable-list-section'); - // eslint-disable-next-line no-new - new AjaxVariableList({ - container: variableListEl, - saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), - errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), - saveEndpoint: variableListEl.dataset.saveEndpoint, - }); + initVariableList(); }); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 15c6fb550c1..05436163616 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,6 +1,6 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; -import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; +import initVariableList from '~/ci_variable_list_vue'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -14,14 +14,7 @@ document.addEventListener('DOMContentLoaded', () => { runnerTokenSecretValue.init(); } - const variableListEl = document.querySelector('.js-ci-variable-list-section'); - // eslint-disable-next-line no-new - new AjaxVariableList({ - container: variableListEl, - saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), - errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), - saveEndpoint: variableListEl.dataset.saveEndpoint, - }); + initVariableList(); // hide extra auto devops settings based checkbox state const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb index 19c5fa26f34..622106458c3 100644 --- a/app/serializers/group_variable_entity.rb +++ b/app/serializers/group_variable_entity.rb @@ -4,6 +4,7 @@ class GroupVariableEntity < Grape::Entity expose :id expose :key expose :value + expose :variable_type expose :protected?, as: :protected expose :masked?, as: :masked diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb index 4d48e13cfca..fc090c29230 100644 --- a/app/serializers/variable_entity.rb +++ b/app/serializers/variable_entity.rb @@ -4,6 +4,7 @@ class VariableEntity < Grape::Entity expose :id expose :key expose :value + expose :variable_type expose :protected?, as: :protected expose :masked?, as: :masked diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 464b9faf282..104058e4929 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -6,7 +6,7 @@ = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } .row - .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } } + .col-lg-12#js-ci-variable-list-section{ data: { endpoint: save_endpoint } } .hide.alert.alert-danger.js-ci-variable-error-box %ul.ci-variable-list diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js deleted file mode 100644 index 2839922fbd3..00000000000 --- a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js +++ /dev/null @@ -1,223 +0,0 @@ -import $ from 'jquery'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; - -const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/variables'; -const HIDE_CLASS = 'hide'; - -describe('AjaxFormVariableList', () => { - preloadFixtures('projects/ci_cd_settings.html'); - preloadFixtures('projects/ci_cd_settings_with_variables.html'); - - let container; - let saveButton; - let errorBox; - - let mock; - let ajaxVariableList; - - beforeEach(() => { - loadFixtures('projects/ci_cd_settings.html'); - container = document.querySelector('.js-ci-variable-list-section'); - - mock = new MockAdapter(axios); - - const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); - saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); - errorBox = container.querySelector('.js-ci-variable-error-box'); - ajaxVariableList = new AjaxFormVariableList({ - container, - formField: 'variables', - saveButton, - errorBox, - saveEndpoint: container.dataset.saveEndpoint, - }); - - spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables').and.callThrough(); - spyOn(ajaxVariableList.variableList, 'toggleEnableRow').and.callThrough(); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('onSaveClicked', () => { - it('shows loading spinner while waiting for the request', done => { - const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon'); - - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false); - - return [200, {}]; - }); - - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); - }) - .then(done) - .catch(done.fail); - }); - - it('calls `updateRowsWithPersistedVariables` with the persisted variables', done => { - const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }]; - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, { - variables: variablesResponse, - }); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith( - variablesResponse, - ); - }) - .then(done) - .catch(done.fail); - }); - - it('hides any previous error box', done => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - }) - .then(done) - .catch(done.fail); - }); - - it('disables remove buttons while waiting for the request', done => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { - expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false); - - return [200, {}]; - }); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true); - }) - .then(done) - .catch(done.fail); - }); - - it('hides secret values', done => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {}); - - const row = container.querySelector('.js-row'); - const valueInput = row.querySelector('.js-ci-variable-input-value'); - const valuePlaceholder = row.querySelector('.js-secret-value-placeholder'); - - valueInput.value = 'bar'; - $(valueInput).trigger('input'); - - expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true); - expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false); - expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true); - }) - .then(done) - .catch(done.fail); - }); - - it('shows error box with validation errors', done => { - const validationError = 'some validation error'; - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false); - expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual( - `Validation failed ${validationError}`, - ); - }) - .then(done) - .catch(done.fail); - }); - - it('shows flash message when request fails', done => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - ajaxVariableList - .onSaveClicked() - .then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('updateRowsWithPersistedVariables', () => { - beforeEach(() => { - loadFixtures('projects/ci_cd_settings_with_variables.html'); - container = document.querySelector('.js-ci-variable-list-section'); - - const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); - saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); - errorBox = container.querySelector('.js-ci-variable-error-box'); - ajaxVariableList = new AjaxFormVariableList({ - container, - formField: 'variables', - saveButton, - errorBox, - saveEndpoint: container.dataset.saveEndpoint, - }); - }); - - it('removes variable that was removed', () => { - expect(container.querySelectorAll('.js-row').length).toBe(3); - - container.querySelector('.js-row-remove-button').click(); - - expect(container.querySelectorAll('.js-row').length).toBe(3); - - ajaxVariableList.updateRowsWithPersistedVariables([]); - - expect(container.querySelectorAll('.js-row').length).toBe(2); - }); - - it('updates new variable row with persisted ID', () => { - const row = container.querySelector('.js-row:last-child'); - const idInput = row.querySelector('.js-ci-variable-input-id'); - const keyInput = row.querySelector('.js-ci-variable-input-key'); - const valueInput = row.querySelector('.js-ci-variable-input-value'); - - keyInput.value = 'foo'; - $(keyInput).trigger('input'); - valueInput.value = 'bar'; - $(valueInput).trigger('input'); - - expect(idInput.value).toEqual(''); - - ajaxVariableList.updateRowsWithPersistedVariables([ - { - id: 3, - key: 'foo', - value: 'bar', - }, - ]); - - expect(idInput.value).toEqual('3'); - expect(row.dataset.isPersisted).toEqual('true'); - }); - }); -}); |