Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-02-22 00:09:11 +00:00
parent a6c2be7cd2
commit ab85af0f31
18 changed files with 657 additions and 45 deletions

View File

@ -0,0 +1,168 @@
<script>
import { __ } from '~/locale';
import { mapActions, mapState } from 'vuex';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import {
GlModal,
GlFormSelect,
GlFormGroup,
GlFormInput,
GlFormCheckbox,
GlLink,
GlIcon,
} from '@gitlab/ui';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
components: {
GlModal,
GlFormSelect,
GlFormGroup,
GlFormInput,
GlFormCheckbox,
GlLink,
GlIcon,
},
computed: {
...mapState([
'projectId',
'environments',
'typeOptions',
'variable',
'variableBeingEdited',
'isGroup',
'maskableRegex',
]),
canSubmit() {
return this.variableData.key !== '' && this.variableData.secret_value !== '';
},
canMask() {
const regex = RegExp(this.maskableRegex);
return regex.test(this.variableData.secret_value);
},
variableData() {
return this.variableBeingEdited || this.variable;
},
modalActionText() {
return this.variableBeingEdited ? __('Update Variable') : __('Add variable');
},
primaryAction() {
return {
text: this.modalActionText,
attributes: { variant: 'success', disabled: !this.canSubmit },
};
},
cancelAction() {
return {
text: __('Cancel'),
};
},
},
methods: {
...mapActions([
'addVariable',
'updateVariable',
'resetEditing',
'displayInputValue',
'clearModal',
]),
updateOrAddVariable() {
if (this.variableBeingEdited) {
this.updateVariable(this.variableBeingEdited);
} else {
this.addVariable();
}
},
resetModalHandler() {
if (this.variableBeingEdited) {
this.resetEditing();
} else {
this.clearModal();
}
},
},
};
</script>
<template>
<gl-modal
:modal-id="$options.modalId"
:title="modalActionText"
:action-primary="primaryAction"
:action-cancel="cancelAction"
@ok="updateOrAddVariable"
@hidden="resetModalHandler"
>
<form>
<gl-form-group label="Type" label-for="ci-variable-type">
<gl-form-select
id="ci-variable-type"
v-model="variableData.variable_type"
:options="typeOptions"
/>
</gl-form-group>
<div class="d-flex">
<gl-form-group label="Key" label-for="ci-variable-key" class="w-50 append-right-15">
<gl-form-input
id="ci-variable-key"
v-model="variableData.key"
type="text"
data-qa-selector="variable_key"
/>
</gl-form-group>
<gl-form-group label="Value" label-for="ci-variable-value" class="w-50">
<gl-form-input
id="ci-variable-value"
v-model="variableData.secret_value"
type="text"
data-qa-selector="variable_value"
/>
</gl-form-group>
</div>
<gl-form-group v-if="!isGroup" label="Environment scope" label-for="ci-variable-env">
<gl-form-select
id="ci-variable-env"
v-model="variableData.environment_scope"
:options="environments"
/>
</gl-form-group>
<gl-form-group label="Flags" label-for="ci-variable-flags">
<gl-form-checkbox v-model="variableData.protected" class="mb-0">
{{ __('Protect variable') }}
<gl-link href="/help/ci/variables/README#protected-environment-variables">
<gl-icon name="question" :size="12" />
</gl-link>
<p class="prepend-top-4 clgray">
{{ __('Allow variables to run on protected branches and tags.') }}
</p>
</gl-form-checkbox>
<gl-form-checkbox
ref="masked-ci-variable"
v-model="variableData.masked"
:disabled="!canMask"
data-qa-selector="variable_masked"
>
{{ __('Mask variable') }}
<gl-link href="/help/ci/variables/README#masked-variables">
<gl-icon name="question" :size="12" />
</gl-link>
<p class="prepend-top-4 append-bottom-0 clgray">
{{
__(
'Variables will be masked in job logs. Requires values to meet regular expression requirements.',
)
}}
<gl-link href="/help/ci/variables/README#masked-variables">{{
__('More information')
}}</gl-link>
</p>
</gl-form-checkbox>
</gl-form-group>
</form>
</gl-modal>
</template>

View File

@ -0,0 +1,32 @@
<script>
import CiVariableModal from './ci_variable_modal.vue';
import CiVariableTable from './ci_variable_table.vue';
import { mapState, mapActions } from 'vuex';
export default {
components: {
CiVariableModal,
CiVariableTable,
},
computed: {
...mapState(['isGroup']),
},
mounted() {
if (!this.isGroup) {
this.fetchEnvironments();
}
},
methods: {
...mapActions(['fetchEnvironments']),
},
};
</script>
<template>
<div class="row">
<div class="col-lg-12">
<ci-variable-table />
<ci-variable-modal />
</div>
</div>
</template>

View File

@ -0,0 +1,130 @@
<script>
import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { mapState, mapActions } from 'vuex';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
fields: [
{
key: 'variable_type',
label: s__('CiVariables|Type'),
},
{
key: 'key',
label: s__('CiVariables|Key'),
},
{
key: 'value',
label: s__('CiVariables|Value'),
tdClass: 'qa-ci-variable-input-value',
},
{
key: 'protected',
label: s__('CiVariables|Protected'),
},
{
key: 'masked',
label: s__('CiVariables|Masked'),
},
{
key: 'environment_scope',
label: s__('CiVariables|Environment Scope'),
},
{
key: 'actions',
label: '',
},
],
components: {
GlTable,
GlButton,
GlIcon,
},
directives: {
GlModalDirective,
},
computed: {
...mapState(['variables', 'valuesHidden', 'isGroup', 'isLoading', 'isDeleting']),
valuesButtonText() {
return this.valuesHidden ? __('Reveal values') : __('Hide values');
},
tableIsNotEmpty() {
return this.variables && this.variables.length > 0;
},
fields() {
if (this.isGroup) {
return this.$options.fields.filter(field => field.key !== 'environment_scope');
}
return this.$options.fields;
},
},
mounted() {
this.fetchVariables();
},
methods: {
...mapActions(['fetchVariables', 'deleteVariable', 'toggleValues', 'editVariable']),
},
};
</script>
<template>
<div class="ci-variable-table">
<gl-table
:fields="fields"
:items="variables"
responsive
show-empty
tbody-tr-class="js-ci-variable-row"
>
<template #cell(value)="data">
<span v-if="valuesHidden">*****************</span>
<span v-else>{{ data.value }}</span>
</template>
<template #cell(actions)="data">
<gl-button
ref="edit-ci-variable"
v-gl-modal-directive="$options.modalId"
@click="editVariable(data.item)"
>
<gl-icon name="pencil" />
</gl-button>
<gl-button
ref="delete-ci-variable"
category="secondary"
variant="danger"
@click="deleteVariable(data.item)"
>
<gl-icon name="remove" />
</gl-button>
</template>
<template #empty>
<p ref="empty-variables" class="settings-message text-center empty-variables">
{{
__(
'There are currently no variables, add a variable with the Add Variable button below.',
)
}}
</p>
</template>
</gl-table>
<div class="ci-variable-actions d-flex justify-content-end">
<gl-button
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
data-qa-selector="reveal_ci_variable_value"
class="append-right-8"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-button
>
<gl-button
ref="add-ci-variable"
v-gl-modal-directive="$options.modalId"
data-qa-selector="add_ci_variable"
variant="success"
>{{ __('Add Variable') }}</gl-button
>
</div>
</div>
</template>

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';

View File

@ -0,0 +1,25 @@
import Vue from 'vue';
import CiVariableSettings from './components/ci_variable_settings.vue';
import createStore from './store';
import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const el = document.getElementById('js-ci-project-variables');
const { endpoint, projectId, group, maskableRegex } = el.dataset;
const isGroup = parseBoolean(group);
const store = createStore({
endpoint,
projectId,
isGroup,
maskableRegex,
});
return new Vue({
el,
store,
render(createElement) {
return createElement(CiVariableSettings);
},
});
};

View File

@ -1,4 +1,5 @@
import { __ } from '~/locale';
import { cloneDeep } from 'lodash';
const variableType = 'env_var';
const fileType = 'file';
@ -24,9 +25,9 @@ export const prepareDataForDisplay = variables => {
};
export const prepareDataForApi = (variable, destroy = false) => {
const variableCopy = variable;
variableCopy.protected.toString();
variableCopy.masked.toString();
const variableCopy = cloneDeep(variable);
variableCopy.protected = variableCopy.protected.toString();
variableCopy.masked = variableCopy.masked.toString();
variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type);
if (variableCopy.environment_scope === __('All environments')) {

View File

@ -1,17 +1,22 @@
import initSettingsPanels from '~/settings_panels';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import initVariableList from '~/ci_variable_list';
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,
maskableRegex: variableListEl.dataset.maskableRegex,
});
if (gon.features.newVariablesUi) {
initVariableList();
} else {
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,
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
});

View File

@ -2,6 +2,7 @@ import initSettingsPanels from '~/settings_panels';
import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@ -15,15 +16,19 @@ 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,
maskableRegex: variableListEl.dataset.maskableRegex,
});
if (gon.features.newVariablesUi) {
initVariableList();
} else {
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,
maskableRegex: variableListEl.dataset.maskableRegex,
});
}
// hide extra auto devops settings based checkbox state
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');

View File

@ -130,6 +130,10 @@
border-radius: $border-radius-base;
}
.empty-variables {
padding: 20px 0;
}
.warning-title {
color: $orange-500;
}
@ -370,3 +374,10 @@
.push-pull-table {
margin-top: 1em;
}
.ci-variable-table {
table tr th {
background-color: transparent;
border: 0;
}
}

View File

@ -8,8 +8,8 @@ Discuss your architecture design in an issue before writing code. This helps dec
## Be consistent
There are multiple ways of writing code to accomplish the same results. We should be as consistent as possible in how we write code across our codebases. This will make it more easier us to maintain our code across GitLab.
There are multiple ways of writing code to accomplish the same results. We should be as consistent as possible in how we write code across our codebases. This will make it easier for us to maintain our code across GitLab.
## Improve code [iteratively](https://about.gitlab.com/handbook/values/#iteration)
Whenever you see with existing code that does not follow our current style guide, update it proactively. You don't need to fix everything, but each merge request should iteratively improve our codebase, and reduce technical debt where possible.
Whenever you see existing code that does not follow our current style guide, update it proactively. You dont need to fix everything, but each merge request should iteratively improve our codebase, and reduce technical debt where possible.

View File

@ -1057,6 +1057,9 @@ msgstr ""
msgid "Add README"
msgstr ""
msgid "Add Variable"
msgstr ""
msgid "Add Zoom meeting"
msgstr ""
@ -1189,6 +1192,9 @@ msgstr ""
msgid "Add users to group"
msgstr ""
msgid "Add variable"
msgstr ""
msgid "Add webhook"
msgstr ""
@ -1638,6 +1644,9 @@ msgstr ""
msgid "Allow users to request access (if visibility is public or internal)"
msgstr ""
msgid "Allow variables to run on protected branches and tags."
msgstr ""
msgid "Allowed email domain restriction only permitted for top-level groups"
msgstr ""
@ -3702,6 +3711,9 @@ msgstr ""
msgid "CiVariables|Cannot use Masked Variable with current value"
msgstr ""
msgid "CiVariables|Environment Scope"
msgstr ""
msgid "CiVariables|Input variable key"
msgstr ""
@ -3714,6 +3726,9 @@ msgstr ""
msgid "CiVariables|Masked"
msgstr ""
msgid "CiVariables|Protected"
msgstr ""
msgid "CiVariables|Remove variable row"
msgstr ""
@ -11822,6 +11837,9 @@ msgstr ""
msgid "Marks this issue as related to %{issue_ref}."
msgstr ""
msgid "Mask variable"
msgstr ""
msgid "Match not found; try refining your search query."
msgstr ""
@ -15491,6 +15509,9 @@ msgstr ""
msgid "Prompt users to upload SSH keys"
msgstr ""
msgid "Protect variable"
msgstr ""
msgid "Protected"
msgstr ""
@ -19372,6 +19393,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
msgid "There are currently no variables, add a variable with the Add Variable button below."
msgstr ""
msgid "There are no GPG keys associated with this account."
msgstr ""
@ -20831,6 +20855,9 @@ msgstr ""
msgid "Update"
msgstr ""
msgid "Update Variable"
msgstr ""
msgid "Update all"
msgstr ""
@ -21443,6 +21470,9 @@ msgstr ""
msgid "Variables"
msgstr ""
msgid "Variables will be masked in job logs. Requires values to meet regular expression requirements."
msgstr ""
msgid "Various container registry settings."
msgstr ""

View File

@ -0,0 +1,93 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Ci variable modal', () => {
let wrapper;
let store;
const createComponent = () => {
store = createStore();
wrapper = shallowMount(CiVariableModal, {
localVue,
store,
});
};
const findModal = () => wrapper.find(GlModal);
beforeEach(() => {
createComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
});
it('button is disabled when no key/value pair are present', () => {
expect(findModal().props('actionPrimary').attributes.disabled).toBeTruthy();
});
it('masked checkbox is disabled when value does not meet regex requirements', () => {
expect(wrapper.find({ ref: 'masked-ci-variable' }).attributes('disabled')).toBeTruthy();
});
describe('Adding a new variable', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
store.state.variable = variable;
});
it('button is enabled when key/value pair are present', () => {
expect(findModal().props('actionPrimary').attributes.disabled).toBeFalsy();
});
it('masked checkbox is enabled when value meets regex requirements', () => {
store.state.maskableRegex = '^[a-zA-Z0-9_+=/@:-]{8,}$';
return wrapper.vm.$nextTick(() => {
expect(wrapper.find({ ref: 'masked-ci-variable' }).attributes('disabled')).toBeFalsy();
});
});
it('Add variable button dispatches addVariable action', () => {
findModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith('addVariable');
});
it('Clears the modal state once modal is hidden', () => {
findModal().vm.$emit('hidden');
expect(store.dispatch).toHaveBeenCalledWith('clearModal');
});
});
describe('Editing a variable', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
store.state.variableBeingEdited = variable;
});
it('button text is Update variable when updating', () => {
expect(wrapper.vm.modalActionText).toBe('Update Variable');
});
it('Update variable button dispatches updateVariable with correct variable', () => {
findModal().vm.$emit('ok');
expect(store.dispatch).toHaveBeenCalledWith(
'updateVariable',
store.state.variableBeingEdited,
);
});
it('Resets the editing state once modal is hidden', () => {
findModal().vm.$emit('hidden');
expect(store.dispatch).toHaveBeenCalledWith('resetEditing');
});
});
});

View File

@ -0,0 +1,39 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import CiVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
import createStore from '~/ci_variable_list/store';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Ci variable table', () => {
let wrapper;
let store;
let isGroup;
const createComponent = groupState => {
store = createStore();
store.state.isGroup = groupState;
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(CiVariableSettings, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
});
it('dispatches fetchEnvironments when mounted', () => {
isGroup = false;
createComponent(isGroup);
expect(store.dispatch).toHaveBeenCalledWith('fetchEnvironments');
});
it('does not dispatch fetchenvironments when in group context', () => {
isGroup = true;
createComponent(isGroup);
expect(store.dispatch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,89 @@
import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import { GlTable } from '@gitlab/ui';
import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Ci variable table', () => {
let wrapper;
let store;
const createComponent = () => {
store = createStore();
store.state.isGroup = true;
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = mount(CiVariableTable, {
localVue,
store,
});
};
const findDeleteButton = () => wrapper.find({ ref: 'delete-ci-variable' });
const findRevealButton = () => wrapper.find({ ref: 'secret-value-reveal-button' });
const findEditButton = () => wrapper.find({ ref: 'edit-ci-variable' });
const findEmptyVariablesPlaceholder = () => wrapper.find({ ref: 'empty-variables' });
const findTable = () => wrapper.find(GlTable);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('dispatches fetchVariables when mounted', () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchVariables');
});
it('fields prop does not contain environment_scope if group', () => {
expect(findTable().props('fields')).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'environment_scope',
label: 'Environment Scope',
}),
]),
);
});
describe('Renders correct data', () => {
it('displays empty message when variables are not present', () => {
expect(findEmptyVariablesPlaceholder().exists()).toBe(true);
});
it('displays correct amount of variables present and no empty message', () => {
store.state.variables = mockData.mockVariables;
return wrapper.vm.$nextTick(() => {
expect(wrapper.findAll('.js-ci-variable-row').length).toBe(1);
expect(findEmptyVariablesPlaceholder().exists()).toBe(false);
});
});
});
describe('Table click actions', () => {
beforeEach(() => {
store.state.variables = mockData.mockVariables;
});
it('dispatches deleteVariable with correct variable to delete', () => {
findDeleteButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('deleteVariable', mockData.mockVariables[0]);
});
it('reveals secret values when button is clicked', () => {
findRevealButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('toggleValues', false);
});
it('dispatches editVariable with correct variable to edit', () => {
findEditButton().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('editVariable', mockData.mockVariables[0]);
});
});
});

View File

@ -9,24 +9,6 @@ export default {
value: 'test_val',
variable_type: 'Variable',
},
{
environment_scope: 'All environments',
id: 114,
key: 'test_var_2',
masked: false,
protected: false,
value: 'test_val_2',
variable_type: 'Variable',
},
{
environment_scope: 'All environments',
id: 115,
key: 'test_var_3',
masked: false,
protected: false,
value: 'test_val_3',
variable_type: 'Variable',
},
],
mockVariablesApi: [

View File

@ -17,8 +17,8 @@ describe('CI variables store utils', () => {
environment_scope: '*',
id: 113,
key: 'test_var',
masked: false,
protected: false,
masked: 'false',
protected: 'false',
value: 'test_val',
variable_type: 'env_var',
});
@ -27,8 +27,8 @@ describe('CI variables store utils', () => {
environment_scope: '*',
id: 114,
key: 'test_var_2',
masked: false,
protected: false,
masked: 'false',
protected: 'false',
value: 'test_val_2',
variable_type: 'file',
});