Commit f6de69a4 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '15398-vue-settings-form' into 'master'

Settings app for Docker Container Retention and Expiration policies

See merge request gitlab-org/gitlab!21240
parents 12124e2e e6a60553
...@@ -142,6 +142,12 @@ const Api = { ...@@ -142,6 +142,12 @@ const Api = {
return axios.get(url); return axios.get(url);
}, },
// Update a single project
updateProject(projectPath, data) {
const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
return axios.put(url, data);
},
/** /**
* Get all projects for a forked relationship to a specified project * Get all projects for a forked relationship to a specified project
* @param {string} projectPath - Path or ID of a project * @param {string} projectPath - Path or ID of a project
......
<script> <script>
import { mapState } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { s__, sprintf } from '~/locale'; import { GlLoadingIcon } from '@gitlab/ui';
import SettingsForm from './settings_form.vue';
export default { export default {
components: {}, components: {
GlLoadingIcon,
SettingsForm,
},
computed: { computed: {
...mapState({ ...mapState({
helpPagePath: 'helpPagePath', isLoading: 'isLoading',
}), }),
helpText() {
return sprintf(
s__(
'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}',
),
{
helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
helpLinkEnd: '</a>',
}, },
false, mounted() {
); this.fetchSettings();
}, },
methods: {
...mapActions(['fetchSettings']),
}, },
}; };
</script> </script>
...@@ -28,16 +25,19 @@ export default { ...@@ -28,16 +25,19 @@ export default {
<template> <template>
<div> <div>
<p> <p>
{{ s__('PackageRegistry|Tag retention policies are designed to:') }} {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }}
</p> </p>
<ul> <ul>
<li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li> <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li>
<li> <li>
{{ {{
s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.") s__(
"ContainerRegistry|Automatically remove extra images that aren't designed to be kept.",
)
}} }}
</li> </li>
</ul> </ul>
<p ref="help-link" v-html="helpText"></p> <gl-loading-icon v-if="isLoading" ref="loading-icon" />
<settings-form v-else ref="settings-form" />
</div> </div>
</template> </template>
<script>
import { mapActions } from 'vuex';
import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import { NAME_REGEX_LENGTH } from '../constants';
import { mapComputed } from '~/vuex_shared/bindings';
export default {
components: {
GlFormGroup,
GlToggle,
GlFormSelect,
GlFormTextarea,
GlButton,
},
labelsConfig: {
cols: 3,
align: 'right',
},
computed: {
...mapComputed('settings', 'updateSettings', [
'enabled',
'cadence',
'older_than',
'keep_n',
'name_regex',
]),
policyEnabledText() {
return this.enabled ? __('enabled') : __('disabled');
},
toggleDescriptionText() {
return sprintf(
s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'),
{
toggleStatus: `<strong>${this.policyEnabledText}</strong>`,
},
false,
);
},
regexHelpText() {
return sprintf(
s__(
'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported',
),
{
codeStart: '<code>',
codeEnd: '</code>',
},
false,
);
},
nameRegexPlaceholder() {
return '.*';
},
nameRegexState() {
return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
},
formIsValid() {
return this.nameRegexState === false;
},
},
methods: {
...mapActions(['resetSettings', 'saveSettings']),
},
};
</script>
<template>
<div class="card">
<form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings">
<div class="card-header">
{{ s__('ContainerRegistry|Tag expiration policy') }}
</div>
<div class="card-body">
<gl-form-group
id="expiration-policy-toggle-group"
:label-cols="$options.labelsConfig.cols"
:label-align="$options.labelsConfig.align"
label-for="expiration-policy-toggle"
:label="s__('ContainerRegistry|Expiration policy:')"
>
<div class="d-flex align-items-start">
<gl-toggle id="expiration-policy-toggle" v-model="enabled" />
<span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span>
</div>
</gl-form-group>
<gl-form-group
id="expiration-policy-interval-group"
:label-cols="$options.labelsConfig.cols"
:label-align="$options.labelsConfig.align"
label-for="expiration-policy-interval"
:label="s__('ContainerRegistry|Expiration interval:')"
>
<gl-form-select id="expiration-policy-interval" v-model="older_than">
<option value="1">{{ __('Option 1') }}</option>
<option value="2">{{ __('Option 2') }}</option>
</gl-form-select>
</gl-form-group>
<gl-form-group
id="expiration-policy-schedule-group"
:label-cols="$options.labelsConfig.cols"
:label-align="$options.labelsConfig.align"
label-for="expiration-policy-schedule"
:label="s__('ContainerRegistry|Expiration schedule:')"
>
<gl-form-select id="expiration-policy-schedule" v-model="cadence">
<option value="1">{{ __('Option 1') }}</option>
<option value="2">{{ __('Option 2') }}</option>
</gl-form-select>
</gl-form-group>
<gl-form-group
id="expiration-policy-latest-group"
:label-cols="$options.labelsConfig.cols"
:label-align="$options.labelsConfig.align"
label-for="expiration-policy-latest"
:label="s__('ContainerRegistry|Expiration latest:')"
>
<gl-form-select id="expiration-policy-latest" v-model="keep_n">
<option value="1">{{ __('Option 1') }}</option>
<option value="2">{{ __('Option 2') }}</option>
</gl-form-select>
</gl-form-group>
<gl-form-group
id="expiration-policy-name-matching-group"
:label-cols="$options.labelsConfig.cols"
:label-align="$options.labelsConfig.align"
label-for="expiration-policy-name-matching"
:label="s__('ContainerRegistry|Expire Docker tags with name matching:')"
:state="nameRegexState"
:invalid-feedback="
s__('ContainerRegistry|The value of this input should be less than 255 characters')
"
>
<gl-form-textarea
id="expiration-policy-name-matching"
v-model="name_regex"
:placeholder="nameRegexPlaceholder"
:state="nameRegexState"
trim
/>
<template #description>
<span ref="regex-description" v-html="regexHelpText"></span>
</template>
</gl-form-group>
</div>
<div class="card-footer text-right">
<gl-button ref="cancel-button" type="reset">{{ __('Cancel') }}</gl-button>
<gl-button ref="save-button" type="submit" :disabled="formIsValid" variant="success">
{{ __('Save Expiration Policy') }}
</gl-button>
</div>
</form>
</div>
</template>
import { s__ } from '~/locale';
export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the expiration policy.',
);
export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while updating the expiration policy.',
);
export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
'ContainerRegistry|Expiration policy successfully saved.',
);
export const NAME_REGEX_LENGTH = 255;
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import store from './stores/'; import store from './store/';
import RegistrySettingsApp from './components/registry_settings_app.vue'; import RegistrySettingsApp from './components/registry_settings_app.vue';
Vue.use(Translate); Vue.use(Translate);
......
import Api from '~/api';
import createFlash from '~/flash';
import {
FETCH_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../constants';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data);
export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE);
export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE);
export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS);
export const fetchSettings = ({ dispatch, state }) => {
dispatch('toggleLoading');
return Api.project(state.projectId)
.then(({ tag_expiration_policies }) =>
dispatch('receiveSettingsSuccess', tag_expiration_policies),
)
.catch(() => dispatch('receiveSettingsError'))
.finally(() => dispatch('toggleLoading'));
};
export const saveSettings = ({ dispatch, state }) => {
dispatch('toggleLoading');
return Api.updateProject(state.projectId, { tag_expiration_policies: state.settings })
.then(({ tag_expiration_policies }) => {
dispatch('receiveSettingsSuccess', tag_expiration_policies);
createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE);
})
.catch(() => dispatch('updateSettingsError'))
.finally(() => dispatch('toggleLoading'));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const UPDATE_SETTINGS = 'UPDATE_SETTINGS';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_SETTINGS = 'SET_SETTINGS';
export const RESET_SETTINGS = 'RESET_SETTINGS';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, initialState) {
state.projectId = initialState.projectId;
},
[types.UPDATE_SETTINGS](state, settings) {
state.settings = { ...state.settings, ...settings };
},
[types.SET_SETTINGS](state, settings) {
state.settings = settings;
state.original = Object.freeze(settings);
},
[types.RESET_SETTINGS](state) {
state.settings = { ...state.original };
},
[types.TOGGLE_LOADING](state) {
state.isLoading = !state.isLoading;
},
};
export default () => ({
/*
* Project Id used to build the API call
*/
projectId: '',
/*
* Boolean to determine if the UI is loading data from the API
*/
isLoading: false,
/*
* This contains the data shown and manipulated in the UI
* Has the following structure:
* {
* enabled: Boolean
* cadence: String,
* older_than: String,
* keep_n: String,
* name_regex: String
* }
*/
settings: {},
/*
* Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel'
*/
original: {},
});
import * as types from './mutation_types';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
// to avoid eslint error until more actions are added to the store
export default () => {};
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
// to avoid eslint error until more actions are added to the store
export default () => {};
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, initialState) {
state.helpPagePath = initialState.helpPagePath;
state.registrySettingsEndpoint = initialState.registrySettingsEndpoint;
},
};
export default () => ({
/*
* Help page path to generate the link
*/
helpPagePath: '',
/*
* Settings endpoint to call to fetch and update the settings
*/
registrySettingsEndpoint: '',
});
export const mapComputed = (root, updateFn, list) => {
const result = {};
list.forEach(key => {
result[key] = {
get() {
return this.$store.state[root][key];
},
set(value) {
this.$store.dispatch(updateFn, { [key]: value });
},
};
});
return result;
};
export default () => {};
...@@ -22,6 +22,12 @@ ...@@ -22,6 +22,12 @@
} }
} }
@each $index, $size in $type-scale {
#{'.lh-#{$index}'} {
line-height: $size;
}
}
.border-width-1px { border-width: 1px; } .border-width-1px { border-width: 1px; }
.border-style-dashed { border-style: dashed; } .border-style-dashed { border-style: dashed; }
.border-style-solid { border-style: solid; } .border-style-solid { border-style: solid; }
......
#js-registry-settings{ data: { registry_settings_endpoint: '', #js-registry-settings{ data: { project_id: @project.id, } }
help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } }
...@@ -63,10 +63,11 @@ ...@@ -63,10 +63,11 @@
%section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) } %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) }
.settings-header .settings-header
%h4 %h4
= _("Container Registry tag expiration policies") = _("Container Registry tag expiration policy")
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.js-settings-toggle{ type: 'button' } %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand') = expanded ? _('Collapse') : _('Expand')
%p %p
= _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.")
.settings-content .settings-content
= render 'projects/registry/settings/index' = render 'projects/registry/settings/index'
...@@ -4746,7 +4746,7 @@ msgstr "" ...@@ -4746,7 +4746,7 @@ msgstr ""
msgid "Container Registry" msgid "Container Registry"
msgstr "" msgstr ""
msgid "Container Registry tag expiration policies" msgid "Container Registry tag expiration policy"
msgstr "" msgstr ""
msgid "Container Scanning" msgid "Container Scanning"
...@@ -4758,6 +4758,9 @@ msgstr "" ...@@ -4758,6 +4758,9 @@ msgstr ""
msgid "Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for AutoDevOps to work." msgid "Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for AutoDevOps to work."
msgstr "" msgstr ""
msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr ""
msgid "ContainerRegistry|Container Registry" msgid "ContainerRegistry|Container Registry"
msgstr "" msgstr ""
...@@ -4773,12 +4776,36 @@ msgstr "" ...@@ -4773,12 +4776,36 @@ msgstr ""
msgid "ContainerRegistry|Docker connection error" msgid "ContainerRegistry|Docker connection error"
msgstr "" msgstr ""
msgid "ContainerRegistry|Docker tag expiration policy is %{toggleStatus}"
msgstr ""
msgid "ContainerRegistry|Expiration interval:"
msgstr ""
msgid "ContainerRegistry|Expiration latest:"
msgstr ""
msgid "ContainerRegistry|Expiration policy successfully saved."
msgstr ""
msgid "ContainerRegistry|Expiration policy:"
msgstr ""
msgid "ContainerRegistry|Expiration schedule:"
msgstr ""
msgid "ContainerRegistry|Expire Docker tags with name matching:"
msgstr ""
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr "" msgstr ""
msgid "ContainerRegistry|Image ID" msgid "ContainerRegistry|Image ID"
msgstr "" msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr ""
msgid "ContainerRegistry|Last Updated" msgid "ContainerRegistry|Last Updated"
msgstr "" msgstr ""
...@@ -4799,12 +4826,27 @@ msgstr[1] "" ...@@ -4799,12 +4826,27 @@ msgstr[1] ""
msgid "ContainerRegistry|Size" msgid "ContainerRegistry|Size"
msgstr "" msgstr ""
msgid "ContainerRegistry|Something went wrong while fetching the expiration policy."
msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr ""
msgid "ContainerRegistry|Tag" msgid "ContainerRegistry|Tag"
msgstr "" msgstr ""
msgid "ContainerRegistry|Tag expiration policy"
msgstr ""
msgid "ContainerRegistry|Tag expiration policy is designed to:"
msgstr ""
msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator." msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator."
msgstr "" msgstr ""
msgid "ContainerRegistry|The value of this input should be less than 255 characters"
msgstr ""
msgid "ContainerRegistry|There are no container images available in this group" msgid "ContainerRegistry|There are no container images available in this group"
msgstr "" msgstr ""
...@@ -4817,6 +4859,9 @@ msgstr "" ...@@ -4817,6 +4859,9 @@ msgstr ""
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr "" msgstr ""
msgid "ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported"
msgstr ""
msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}" msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr "" msgstr ""
...@@ -7295,7 +7340,7 @@ msgstr "" ...@@ -7295,7 +7340,7 @@ msgstr ""
msgid "Expiration date" msgid "Expiration date"
msgstr "" msgstr ""
msgid "Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD." msgid "Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD."
msgstr "" msgstr ""
msgid "Expired" msgid "Expired"
...@@ -12416,6 +12461,12 @@ msgstr "" ...@@ -12416,6 +12461,12 @@ msgstr ""
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses." msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses."
msgstr "" msgstr ""
msgid "Option 1"
msgstr ""
msgid "Option 2"
msgstr ""
msgid "Optional" msgid "Optional"
msgstr "" msgstr ""
...@@ -12476,9 +12527,6 @@ msgstr "" ...@@ -12476,9 +12527,6 @@ msgstr ""
msgid "Package was removed" msgid "Package was removed"
msgstr "" msgstr ""
msgid "PackageRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr ""
msgid "PackageRegistry|Copy Maven XML" msgid "PackageRegistry|Copy Maven XML"
msgstr "" msgstr ""
...@@ -12518,9 +12566,6 @@ msgstr "" ...@@ -12518,9 +12566,6 @@ msgstr ""
msgid "PackageRegistry|Installation" msgid "PackageRegistry|Installation"
msgstr "" msgstr ""
msgid "PackageRegistry|Keep and protect the images that matter most."
msgstr ""
msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab." msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab."
msgstr "" msgstr ""
...@@ -12533,18 +12578,12 @@ msgstr "" ...@@ -12533,18 +12578,12 @@ msgstr ""
msgid "PackageRegistry|Package installation" msgid "PackageRegistry|Package installation"
msgstr "" msgstr ""
msgid "PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}"
msgstr ""
msgid "PackageRegistry|Registry Setup" msgid "PackageRegistry|Registry Setup"
msgstr "" msgstr ""
msgid "PackageRegistry|Remove package" msgid "PackageRegistry|Remove package"
msgstr "" msgstr ""
msgid "PackageRegistry|Tag retention policies are designed to:"
msgstr ""
msgid "PackageRegistry|There are no packages yet" msgid "PackageRegistry|There are no packages yet"
msgstr "" msgstr ""
...@@ -15522,6 +15561,9 @@ msgstr "" ...@@ -15522,6 +15561,9 @@ msgstr ""
msgid "Save Changes" msgid "Save Changes"
msgstr "" msgstr ""
msgid "Save Expiration Policy"
msgstr ""
msgid "Save anyway" msgid "Save anyway"
msgstr "" msgstr ""
......
...@@ -151,6 +151,21 @@ describe('Api', () => { ...@@ -151,6 +151,21 @@ describe('Api', () => {
}); });
}); });
describe('updateProject', () => {
it('update a project with the given payload', done => {
const projectPath = 'foo';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`;
mock.onPut(expectedUrl).reply(200, { foo: 'bar' });
Api.updateProject(projectPath, { foo: 'bar' })
.then(({ data }) => {
expect(data.foo).toBe('bar');
done();
})
.catch(done.fail);
});
});
describe('projectUsers', () => { describe('projectUsers', () => {
it('fetches all users of a particular project', done => { it('fetches all users of a particular project', done => {
const query = 'dummy query'; const query = 'dummy query';
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry List renders 1`] = ` exports[`Registry Settings App renders 1`] = `
<div> <div>
<p> <p>
Tag retention policies are designed to: Tag expiration policy is designed to:
</p> </p>
...@@ -20,14 +20,6 @@ exports[`Registry List renders 1`] = ` ...@@ -20,14 +20,6 @@ exports[`Registry List renders 1`] = `
</li> </li>
</ul> </ul>
<p> <settingsform-stub />
Read more about the
<a
href="foo"
target="_blank"
>
Container Registry tag retention policies
</a>
</p>
</div> </div>
`; `;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Settings Form renders 1`] = `
<div
class="card"
>
<form>
<div
class="card-header"
>
Tag expiration policy
</div>
<div
class="card-body"
>
<glformgroup-stub
id="expiration-policy-toggle-group"
label="Expiration policy:"
label-align="right"
label-cols="3"
label-for="expiration-policy-toggle"
>
<div
class="d-flex align-items-start"
>
<gltoggle-stub
id="expiration-policy-toggle"
labeloff="Toggle Status: OFF"
labelon="Toggle Status: ON"
/>
<span
class="mb-2 ml-1 lh-2"
>
Docker tag expiration policy is
<strong>
disabled
</strong>
</span>
</div>
</glformgroup-stub>
<glformgroup-stub
id="expiration-policy-interval-group"
label="Expiration interval:"
label-align="right"
label-cols="3"
label-for="expiration-policy-interval"
>
<glformselect-stub
id="expiration-policy-interval"
>
<option
value="1"
>
Option 1
</option>
<option
value="2"
>
Option 2
</option>
</glformselect-stub>
</glformgroup-stub>
<glformgroup-stub
id="expiration-policy-schedule-group"
label="Expiration schedule:"
label-align="right"
label-cols="3"
label-for="expiration-policy-schedule"
>
<glformselect-stub
id="expiration-policy-schedule"
>
<option
value="1"
>
Option 1
</option>
<option
value="2"
>
Option 2
</option>
</glformselect-stub>
</glformgroup-stub>
<glformgroup-stub
id="expiration-policy-latest-group"
label="Expiration latest:"
label-align="right"
label-cols="3"
label-for="expiration-policy-latest"
>
<glformselect-stub
id="expiration-policy-latest"
>
<option
value="1"
>
Option 1
</option>
<option
value="2"
>
Option 2
</option>
</glformselect-stub>
</glformgroup-stub>
<glformgroup-stub
id="expiration-policy-name-matching-group"
invalid-feedback="The value of this input should be less than 255 characters"
label="Expire Docker tags with name matching:"
label-align="right"
label-cols="3"
label-for="expiration-policy-name-matching"
>
<glformtextarea-stub
id="expiration-policy-name-matching"
placeholder=".*"
trim=""
value=""
/>
</glformgroup-stub>
</div>
<div
class="card-footer text-right"
>
<glbutton-stub
type="reset"
>
Cancel
</glbutton-stub>
<glbutton-stub
type="submit"
variant="success"
>
Save Expiration Policy
</glbutton-stub>
</div>
</form>
</div>
`;
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import component from '~/registry/settings/components/registry_settings_app.vue'; import component from '~/registry/settings/components/registry_settings_app.vue';
import { createStore } from '~/registry/settings/stores/'; import { createStore } from '~/registry/settings/store/';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
describe('Registry List', () => { describe('Registry Settings App', () => {
let wrapper; let wrapper;
let store; let store;
let fetchSpy;
const helpPagePath = 'foo'; const findSettingsComponent = () => wrapper.find({ ref: 'settings-form' });
const findHelpLink = () => wrapper.find({ ref: 'help-link' }).find('a'); const findLoadingComponent = () => wrapper.find({ ref: 'loading-icon' });
const mountComponent = (options = {}) => const mountComponent = (options = {}) => {
shallowMount(component, { fetchSpy = jest.fn();
wrapper = shallowMount(component, {
sync: false, sync: false,
store, store,
methods: {
fetchSettings: fetchSpy,
},
...options, ...options,
}); });
};
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
store.dispatch('setInitialState', { helpPagePath }); mountComponent();
wrapper = mountComponent();
}); });
afterEach(() => { afterEach(() => {
...@@ -34,7 +39,18 @@ describe('Registry List', () => { ...@@ -34,7 +39,18 @@ describe('Registry List', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('renders an help link dependant on the helphPagePath', () => { it('call the store function to load the data on mount', () => {
expect(findHelpLink().attributes('href')).toBe(helpPagePath); expect(fetchSpy).toHaveBeenCalled();
});
it('renders a loader if isLoading is true', () => {
store.dispatch('toggleLoading');
return wrapper.vm.$nextTick().then(() => {
expect(findLoadingComponent().exists()).toBe(true);
expect(findSettingsComponent().exists()).toBe(false);
});
});
it('renders the setting form', () => {
expect(findSettingsComponent().exists()).toBe(true);
}); });
}); });
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import component from '~/registry/settings/components/settings_form.vue';
import { createStore } from '~/registry/settings/store/';
import { NAME_REGEX_LENGTH } from '~/registry/settings/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Settings Form', () => {
let wrapper;
let store;
let saveSpy;
let resetSpy;
const helpPagePath = 'foo';
const findFormGroup = name => wrapper.find(`#expiration-policy-${name}-group`);
const findFormElements = (name, father = wrapper) => father.find(`#expiration-policy-${name}`);
const findCancelButton = () => wrapper.find({ ref: 'cancel-button' });
const findSaveButton = () => wrapper.find({ ref: 'save-button' });
const findForm = () => wrapper.find({ ref: 'form-element' });
const mountComponent = (options = {}) => {
saveSpy = jest.fn();
resetSpy = jest.fn();
wrapper = shallowMount(component, {
sync: false,
store,
methods: {
saveSettings: saveSpy,
resetSettings: resetSpy,
},
...options,
});
};
beforeEach(() => {
store = createStore();
store.dispatch('setInitialState', { helpPagePath });
mountComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe.each`
elementName | modelName | value
${'toggle'} | ${'enabled'} | ${true}
${'interval'} | ${'older_than'} | ${'foo'}
${'schedule'} | ${'cadence'} | ${'foo'}
${'latest'} | ${'keep_n'} | ${'foo'}
${'name-matching'} | ${'name_regex'} | ${'foo'}
`('%s form element', ({ elementName, modelName, value }) => {
let formGroup;
beforeEach(() => {
formGroup = findFormGroup(elementName);
});
it(`${elementName} form group exist in the dom`, () => {
expect(formGroup.exists()).toBe(true);
});
it(`${elementName} form group has a label-for property`, () => {
expect(formGroup.attributes('label-for')).toBe(`expiration-policy-${elementName}`);
});
it(`${elementName} form group has a label-cols property`, () => {
expect(formGroup.attributes('label-cols')).toBe(`${wrapper.vm.$options.labelsConfig.cols}`);
});
it(`${elementName} form group has a label-align property`, () => {
expect(formGroup.attributes('label-align')).toBe(`${wrapper.vm.$options.labelsConfig.align}`);
});
it(`${elementName} form group contains an input element`, () => {
expect(findFormElements(elementName, formGroup).exists()).toBe(true);
});
it(`${elementName} form element change updated ${modelName} with ${value}`, () => {
const element = findFormElements(elementName, formGroup);
element.vm.$emit('input', value);
expect(wrapper.vm[modelName]).toBe(value);
});
});
describe('form actions', () => {
let form;
beforeEach(() => {
form = findForm();
});
it('cancel has type reset', () => {
expect(findCancelButton().attributes('type')).toBe('reset');
});
it('form reset event call the appropriate function', () => {
form.trigger('reset');
expect(resetSpy).toHaveBeenCalled();
});
it('save has type submit', () => {
expect(findSaveButton().attributes('type')).toBe('submit');
});
it('form submit event call the appropriate function', () => {
form.trigger('submit');
expect(saveSpy).toHaveBeenCalled();
});
});
describe('form validation', () => {
describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
beforeEach(() => {
store.dispatch('updateSettings', { name_regex: invalidString });
});
it('save btn is disabled', () => {
expect(findSaveButton().attributes('disabled')).toBeTruthy();
});
it('nameRegexState is false', () => {
expect(wrapper.vm.nameRegexState).toBe(false);
});
});
it('if the user did not type validation is null', () => {
store.dispatch('updateSettings', { name_regex: null });
expect(wrapper.vm.nameRegexState).toBe(null);
return wrapper.vm.$nextTick().then(() => {
expect(findSaveButton().attributes('disabled')).toBeFalsy();
});
});
it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => {
store.dispatch('updateSettings', { name_regex: 'abc' });
expect(wrapper.vm.nameRegexState).toBe(true);
});
});
describe('help text', () => {
it('toggleDescriptionText text reflects enabled property', () => {
const toggleHelpText = findFormGroup('toggle').find('span');
expect(toggleHelpText.html()).toContain('disabled');
wrapper.vm.enabled = true;
return wrapper.vm.$nextTick().then(() => {
expect(toggleHelpText.html()).toContain('enabled');
});
});
});
});
import Api from '~/api';
import createFlash from '~/flash';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/registry/settings/store/actions';
import * as types from '~/registry/settings/store/mutation_types';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
FETCH_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '~/registry/settings/constants';
jest.mock('~/flash');
describe('Actions Registry Store', () => {
describe.each`
actionName | mutationName | payload
${'setInitialState'} | ${types.SET_INITIAL_STATE} | ${'foo'}
${'updateSettings'} | ${types.UPDATE_SETTINGS} | ${'foo'}
${'receiveSettingsSuccess'} | ${types.SET_SETTINGS} | ${'foo'}
${'toggleLoading'} | ${types.TOGGLE_LOADING} | ${undefined}
${'resetSettings'} | ${types.RESET_SETTINGS} | ${undefined}
`('%s action invokes %s mutation with payload %s', ({ actionName, mutationName, payload }) => {
it('should set the initial state', done => {
testAction(actions[actionName], payload, {}, [{ type: mutationName, payload }], [], done);
});
});
describe.each`
actionName | message
${'receiveSettingsError'} | ${FETCH_SETTINGS_ERROR_MESSAGE}
${'updateSettingsError'} | ${UPDATE_SETTINGS_ERROR_MESSAGE}
`('%s action', ({ actionName, message }) => {
it(`should call createFlash with ${message}`, done => {
testAction(actions[actionName], null, null, [], [], () => {
expect(createFlash).toHaveBeenCalledWith(message);
done();
});
});
});
describe('fetchSettings', () => {
const state = {
projectId: 'bar',
};
const payload = {
tag_expiration_policies: 'foo',
};
it('should fetch the data from the API', done => {
Api.project = jest.fn().mockResolvedValue(payload);
testAction(
actions.fetchSettings,
null,
state,
[],
[
{ type: 'toggleLoading' },
{ type: 'receiveSettingsSuccess', payload: payload.tag_expiration_policies },
{ type: 'toggleLoading' },
],
done,
);
});
it('should call receiveSettingsError on error', done => {
Api.project = jest.fn().mockRejectedValue();
testAction(
actions.fetchSettings,
null,
state,
[],
[{ type: 'toggleLoading' }, { type: 'receiveSettingsError' }, { type: 'toggleLoading' }],
done,
);
});
});
describe('saveSettings', () => {
const state = {
projectId: 'bar',
settings: 'baz',
};
const payload = {
tag_expiration_policies: 'foo',
};
it('should fetch the data from the API', done => {
Api.updateProject = jest.fn().mockResolvedValue(payload);
testAction(
actions.saveSettings,
null,
state,
[],
[
{ type: 'toggleLoading' },
{ type: 'receiveSettingsSuccess', payload: payload.tag_expiration_policies },
{ type: 'toggleLoading' },
],
() => {
expect(createFlash).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE);
done();
},
);
});
it('should call receiveSettingsError on error', done => {
Api.updateProject = jest.fn().mockRejectedValue();
testAction(
actions.saveSettings,
null,
state,
[],
[{ type: 'toggleLoading' }, { type: 'updateSettingsError' }, { type: 'toggleLoading' }],
done,
);
});
});
});
import mutations from '~/registry/settings/store/mutations';
import * as types from '~/registry/settings/store/mutation_types';
import createState from '~/registry/settings/store/state';
describe('Mutations Registry Store', () => {
let mockState;
beforeEach(() => {
mockState = createState();
});
describe('SET_INITIAL_STATE', () => {
it('should set the initial state', () => {
const payload = { helpPagePath: 'foo', projectId: 'bar' };
const expectedState = { ...mockState, ...payload };
mutations[types.SET_INITIAL_STATE](mockState, payload);
expect(mockState.projectId).toEqual(expectedState.projectId);
});
});
describe('UPDATE_SETTINGS', () => {
it('should update the settings', () => {
mockState.settings = { foo: 'bar' };
const payload = { foo: 'baz' };
const expectedState = { ...mockState, settings: payload };
mutations[types.UPDATE_SETTINGS](mockState, payload);
expect(mockState.settings).toEqual(expectedState.settings);
});
});
describe('SET_SETTINGS', () => {
it('should set the settings and original', () => {
const payload = { foo: 'baz' };
const expectedState = { ...mockState, settings: payload };
mutations[types.SET_SETTINGS](mockState, payload);
expect(mockState.settings).toEqual(expectedState.settings);
expect(mockState.original).toEqual(expectedState.settings);
});
});
describe('RESET_SETTINGS', () => {
it('should copy original over settings', () => {
mockState.settings = { foo: 'bar' };
mockState.original = { foo: 'baz' };
mutations[types.RESET_SETTINGS](mockState);
expect(mockState.settings).toEqual(mockState.original);
});
});
describe('TOGGLE_LOADING', () => {
it('should toggle the loading', () => {
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(true);
});
});
});
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/registry/settings/stores/actions';
import * as types from '~/registry/settings/stores/mutation_types';
jest.mock('~/flash.js');
describe('Actions Registry Store', () => {
describe('setInitialState', () => {
it('should set the initial state', done => {
testAction(
actions.setInitialState,
'foo',
{},
[{ type: types.SET_INITIAL_STATE, payload: 'foo' }],
[],
done,
);
});
});
});
import mutations from '~/registry/settings/stores/mutations';
import * as types from '~/registry/settings/stores/mutation_types';
import createState from '~/registry/settings/stores/state';
describe('Mutations Registry Store', () => {
let mockState;
beforeEach(() => {
mockState = createState();
});
describe('SET_INITIAL_STATE', () => {
it('should set the initial state', () => {
const payload = { helpPagePath: 'foo', registrySettingsEndpoint: 'bar' };
const expectedState = { ...mockState, ...payload };
mutations[types.SET_INITIAL_STATE](mockState, payload);
expect(mockState.endpoint).toEqual(expectedState.endpoint);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mapComputed } from '~/vuex_shared/bindings';
describe('Binding utils', () => {
describe('mapComputed', () => {
const dummyComponent = {
computed: {
...mapComputed('foo', 'bar', ['baz']),
},
render() {
return null;
},
};
it('returns an object with keys equal to the last fn parameter ', () => {
const keyList = ['foo1', 'foo2'];
const result = mapComputed('foo', 'bar', keyList);
expect(Object.keys(result)).toEqual(keyList);
});
it('returned object has set and get function', () => {
const result = mapComputed('foo', 'bar', ['baz']);
expect(result.baz.set).toBeDefined();
expect(result.baz.get).toBeDefined();
});
it('set function invokes $store.dispatch', () => {
const context = shallowMount(dummyComponent, {
mocks: {
$store: {
dispatch: jest.fn(),
},
},
});
context.vm.baz = 'a';
expect(context.vm.$store.dispatch).toHaveBeenCalledWith('bar', { baz: 'a' });
});
it('get function returns $store.state[root][key]', () => {
const context = shallowMount(dummyComponent, {
mocks: {
$store: {
state: {
foo: {
baz: 1,
},
},
},
},
});
expect(context.vm.baz).toBe(1);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment