summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormfluharty <mfluharty@gitlab.com>2019-02-28 20:18:39 -0700
committermfluharty <mfluharty@gitlab.com>2019-05-28 21:50:59 +0100
commit46b093ecb7da441ba3b480c463d6f43cd81b1918 (patch)
treea742e5f6b01354fe74bb8db20d20f216a7c7c1bb
parent0c5b3d08dedccb5808c6df6560b3e277035691db (diff)
downloadgitlab-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
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js119
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/components/variable.vue83
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/components/variable_form.vue156
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/components/variable_settings.vue112
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/components/variables_table.vue66
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/index.js24
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/actions.js83
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/getters.js2
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/index.js17
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/mutation_types.js16
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/mutations.js95
-rw-r--r--app/assets/javascripts/ci_variable_list_vue/store/state.js16
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js11
-rw-r--r--app/serializers/group_variable_entity.rb1
-rw-r--r--app/serializers/variable_entity.rb1
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--spec/javascripts/ci_variable_list/ajax_variable_list_spec.js223
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');
- });
- });
-});