Commit 7f204eaa authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '214476-wire-graphql-in-components' into 'master'

Enable GraphQL and vue apollo in the cleanup policy for tags form

See merge request gitlab-org/gitlab!43417
parents b70c06d4 2520e00b
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { isEqual } from 'lodash';
import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
import SettingsForm from './settings_form.vue';
......@@ -19,21 +19,39 @@ export default {
GlSprintf,
GlLink,
},
inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'],
i18n: {
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
FETCH_SETTINGS_ERROR_MESSAGE,
},
apollo: {
containerExpirationPolicy: {
query: expirationPolicyQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update: data => data.project?.containerExpirationPolicy,
result({ data }) {
this.workingCopy = { ...data.project?.containerExpirationPolicy };
},
error(e) {
this.fetchSettingsError = e;
},
},
},
data() {
return {
fetchSettingsError: false,
containerExpirationPolicy: null,
workingCopy: {},
};
},
computed: {
...mapState(['isAdmin', 'adminSettingsPath']),
...mapGetters({ isDisabled: 'getIsDisabled' }),
showSettingForm() {
return !this.isDisabled && !this.fetchSettingsError;
isDisabled() {
return !(this.containerExpirationPolicy || this.enableHistoricEntries);
},
showDisabledFormMessage() {
return this.isDisabled && !this.fetchSettingsError;
......@@ -41,21 +59,27 @@ export default {
unavailableFeatureMessage() {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
},
mounted() {
this.fetchSettings().catch(() => {
this.fetchSettingsError = true;
});
isEdited() {
return !isEqual(this.containerExpirationPolicy, this.workingCopy);
},
},
methods: {
...mapActions(['fetchSettings']),
restoreOriginal() {
this.workingCopy = { ...this.containerExpirationPolicy };
},
},
};
</script>
<template>
<div>
<settings-form v-if="showSettingForm" />
<settings-form
v-if="containerExpirationPolicy"
v-model="workingCopy"
:is-loading="$apollo.queries.containerExpirationPolicy.loading"
:is-edited="isEdited"
@reset="restoreOriginal"
/>
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
......
<script>
import { get } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlCard, GlButton } from '@gitlab/ui';
import Tracking from '~/tracking';
import { mapComputed } from '~/vuex_shared/bindings';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../../shared/constants';
import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
import { formOptionsGenerator } from '~/registry/shared/utils';
import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql';
import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update';
export default {
components: {
GlCard,
GlButton,
GlLoadingIcon,
ExpirationPolicyFields,
},
mixins: [Tracking.mixin()],
inject: ['projectPath'],
props: {
value: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
isEdited: {
type: Boolean,
required: false,
default: false,
},
},
labelsConfig: {
cols: 3,
align: 'right',
},
formOptions: formOptionsGenerator(),
i18n: {
CLEANUP_POLICY_CARD_HEADER,
SET_CLEANUP_POLICY_BUTTON,
......@@ -34,49 +51,74 @@ export default {
},
fieldsAreValid: true,
apiErrors: null,
mutationLoading: false,
};
},
computed: {
...mapState(['formOptions', 'isLoading']),
...mapGetters({ isEdited: 'getIsEdited' }),
...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
showLoadingIcon() {
return this.isLoading || this.mutationLoading;
},
isSubmitButtonDisabled() {
return !this.fieldsAreValid || this.isLoading;
return !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
return !this.isEdited || this.isLoading;
return !this.isEdited || this.isLoading || this.mutationLoading;
},
mutationVariables() {
return {
projectPath: this.projectPath,
enabled: this.value.enabled,
cadence: this.value.cadence,
olderThan: this.value.olderThan,
keepN: this.value.keepN,
nameRegex: this.value.nameRegex,
nameRegexKeep: this.value.nameRegexKeep,
};
},
},
methods: {
...mapActions(['resetSettings', 'saveSettings']),
reset() {
this.track('reset_form');
this.apiErrors = null;
this.resetSettings();
this.$emit('reset');
},
setApiErrors(response) {
const messages = get(response, 'data.message', []);
this.apiErrors = Object.keys(messages).reduce((acc, curr) => {
if (curr.startsWith('container_expiration_policy.')) {
const key = curr.replace('container_expiration_policy.', '');
acc[key] = get(messages, [curr, 0], '');
}
this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
curr.extensions.problems.forEach(item => {
acc[item.path[0]] = item.message;
});
return acc;
}, {});
},
submit() {
this.track('submit_form');
this.apiErrors = null;
this.saveSettings()
.then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
.catch(({ response }) => {
this.setApiErrors(response);
this.mutationLoading = true;
return this.$apollo
.mutate({
mutation: updateContainerExpirationPolicyMutation,
variables: {
input: this.mutationVariables,
},
update: updateContainerExpirationPolicy(this.projectPath),
})
.then(({ data }) => {
const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
if (errorMessage) {
this.$toast.show(errorMessage, { type: 'error' });
}
this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
})
.catch(error => {
this.setApiErrors(error);
this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
})
.finally(() => {
this.mutationLoading = false;
});
},
onModelChange(changePayload) {
this.settings = changePayload.newValue;
this.$emit('input', changePayload.newValue);
if (this.apiErrors) {
this.apiErrors[changePayload.modified] = undefined;
}
......@@ -93,8 +135,8 @@ export default {
</template>
<template #default>
<expiration-policy-fields
:value="settings"
:form-options="formOptions"
:value="value"
:form-options="$options.formOptions"
:is-loading="isLoading"
:api-errors="apiErrors"
@validated="fieldsAreValid = true"
......@@ -115,12 +157,12 @@ export default {
ref="save-button"
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
variant="success"
category="primary"
class="js-no-auto-disable"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
<gl-loading-icon v-if="isLoading" class="gl-ml-3" />
</gl-button>
</template>
</gl-card>
......
......@@ -68,34 +68,31 @@ export default {
{
name: 'expiration-policy-interval',
label: EXPIRATION_INTERVAL_LABEL,
model: 'older_than',
optionKey: 'olderThan',
model: 'olderThan',
},
{
name: 'expiration-policy-schedule',
label: EXPIRATION_SCHEDULE_LABEL,
model: 'cadence',
optionKey: 'cadence',
},
{
name: 'expiration-policy-latest',
label: KEEP_N_LABEL,
model: 'keep_n',
optionKey: 'keepN',
model: 'keepN',
},
],
textAreaList: [
{
name: 'expiration-policy-name-matching',
label: NAME_REGEX_LABEL,
model: 'name_regex',
model: 'nameRegex',
placeholder: NAME_REGEX_PLACEHOLDER,
description: NAME_REGEX_DESCRIPTION,
},
{
name: 'expiration-policy-keep-name',
label: NAME_REGEX_KEEP_LABEL,
model: 'name_regex_keep',
model: 'nameRegexKeep',
placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
description: NAME_REGEX_KEEP_DESCRIPTION,
},
......@@ -107,17 +104,16 @@ export default {
},
computed: {
...mapComputedToEvent(
['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'],
['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'],
'value',
),
policyEnabledText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
textAreaValidation() {
const nameRegexErrors =
this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex);
const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex);
const nameKeepRegexErrors =
this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep);
this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep);
return {
/*
......@@ -127,11 +123,11 @@ export default {
* false: red border, error message
* So in this function we keep null if the are no message otherwise we 'invert' the error message
*/
name_regex: {
nameRegex: {
state: nameRegexErrors === null ? null : !nameRegexErrors,
message: nameRegexErrors,
},
name_regex_keep: {
nameRegexKeep: {
state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
message: nameKeepRegexErrors,
},
......@@ -139,8 +135,8 @@ export default {
},
fieldsValidity() {
return (
this.textAreaValidation.name_regex.state !== false &&
this.textAreaValidation.name_regex_keep.state !== false
this.textAreaValidation.nameRegex.state !== false &&
this.textAreaValidation.nameRegexKeep.state !== false
);
},
isFormElementDisabled() {
......@@ -216,11 +212,7 @@ export default {
:disabled="isFormElementDisabled"
@input="updateModel($event, select.model)"
>
<option
v-for="option in formOptions[select.optionKey]"
:key="option.key"
:value="option.key"
>
<option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</gl-form-select>
......
......@@ -21,20 +21,26 @@ export const mapComputedToEvent = (list, root) => {
return result;
};
export const optionLabelGenerator = (collection, singularSentence, pluralSentence) =>
export const olderThanTranslationGenerator = variable =>
n__(
'%d day until tags are automatically removed',
'%d days until tags are automatically removed',
variable,
);
export const keepNTranslationGenerator = variable =>
n__('%d tag per image name', '%d tags per image name', variable);
export const optionLabelGenerator = (collection, translationFn) =>
collection.map(option => ({
...option,
label: n__(singularSentence, pluralSentence, option.variable),
label: translationFn(option.variable),
}));
export const formOptionsGenerator = () => {
return {
olderThan: optionLabelGenerator(
OLDER_THAN_OPTIONS,
'%d days until tags are automatically removed',
'%d day until tags are automatically removed',
),
olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
cadence: CADENCE_OPTIONS,
keepN: optionLabelGenerator(KEEP_N_OPTIONS, '%d tag per image name', '%d tags per image name'),
keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
};
};
......@@ -165,6 +165,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
msgid "%d day until tags are automatically removed"
msgid_plural "%d days until tags are automatically removed"
msgstr[0] ""
msgstr[1] ""
msgid "%d error"
msgid_plural "%d errors"
msgstr[0] ""
......@@ -300,6 +305,11 @@ msgid_plural "%d tags"
msgstr[0] ""
msgstr[1] ""
msgid "%d tag per image name"
msgid_plural "%d tags per image name"
msgstr[0] ""
msgstr[1] ""
msgid "%d unassigned issue"
msgid_plural "%d unassigned issues"
msgstr[0] ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Settings App renders 1`] = `
<div>
<settings-form-stub />
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import component from '~/registry/settings/components/registry_settings_app.vue';
import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import SettingsForm from '~/registry/settings/components/settings_form.vue';
import { createStore } from '~/registry/settings/store/';
import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants';
import {
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/registry/settings/constants';
import { stringifiedFormOptions } from '../../shared/mock_data';
import { expirationPolicyPayload } from '../mock_data';
const localVue = createLocalVue();
describe('Registry Settings App', () => {
let wrapper;
let store;
let fakeApollo;
const defaultProvidedValues = {
projectPath: 'path',
isAdmin: false,
adminSettingsPath: 'settingsPath',
enableHistoricEntries: false,
};
const findSettingsComponent = () => wrapper.find(SettingsForm);
const findAlert = () => wrapper.find(GlAlert);
const mountComponent = ({ dispatchMock = 'mockResolvedValue' } = {}) => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
dispatchSpy[dispatchMock]();
const mountComponent = (provide = defaultProvidedValues, config) => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
......@@ -32,71 +39,72 @@ describe('Registry Settings App', () => {
show: jest.fn(),
},
},
store,
provide,
...config,
});
};
beforeEach(() => {
store = createStore();
});
const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[expirationPolicyQuery, resolver]];
fakeApollo = createMockApollo(requestHandlers);
mountComponent(provide, {
localVue,
apolloProvider: fakeApollo,
});
return requestHandlers.map(request => request[1]);
};
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
store.commit(SET_SETTINGS, { foo: 'bar' });
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('call the store function to load the data on mount', () => {
mountComponent();
expect(store.dispatch).toHaveBeenCalledWith('fetchSettings');
});
it('renders the setting form', async () => {
const requests = mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),
});
await Promise.all(requests);
it('renders the setting form', () => {
store.commit(SET_SETTINGS, { foo: 'bar' });
mountComponent();
expect(findSettingsComponent().exists()).toBe(true);
});
describe('the form is disabled', () => {
beforeEach(() => {
store.commit(SET_SETTINGS, undefined);
it('the form is hidden', () => {
mountComponent();
});
it('the form is hidden', () => {
expect(findSettingsComponent().exists()).toBe(false);
});
it('shows an alert', () => {
mountComponent();
const text = findAlert().text();
expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT);
expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT);
});
describe('an admin is visiting the page', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, {
...stringifiedFormOptions,
isAdmin: true,
adminSettingsPath: 'foo',
});
});
it('shows the admin part of the alert message', () => {
mountComponent({ ...defaultProvidedValues, isAdmin: true });
const sprintf = findAlert().find(GlSprintf);
expect(sprintf.text()).toBe('administration settings');
expect(sprintf.find(GlLink).attributes('href')).toBe('foo');
expect(sprintf.find(GlLink).attributes('href')).toBe(
defaultProvidedValues.adminSettingsPath,
);
});
});
});
describe('fetchSettingsError', () => {
beforeEach(() => {
mountComponent({ dispatchMock: 'mockRejectedValue' });
const requests = mountComponentWithApollo({
resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
});
return Promise.all(requests);
});
it('the form is hidden', () => {
......
export const expirationPolicyPayload = override => ({
data: {
project: {
containerExpirationPolicy: {
cadence: 'EVERY_DAY',
enabled: true,
keepN: 'TEN_TAGS',
nameRegex: 'asdasdssssdfdf',
nameRegexKeep: 'sss',
olderThan: 'FOURTEEN_DAYS',
...override,
},
},
},
});
export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({
data: {
updateContainerExpirationPolicy: {
containerExpirationPolicy: {
cadence: 'EVERY_DAY',
enabled: true,
keepN: 'TEN_TAGS',
nameRegex: 'asdasdssssdfdf',
nameRegexKeep: 'sss',
olderThan: 'FOURTEEN_DAYS',
...override,
},
errors,
},
},
});
......@@ -76,25 +76,25 @@ Array [
Object {
"default": false,
"key": "SEVEN_DAYS",
"label": "7 day until tags are automatically removed",
"label": "7 days until tags are automatically removed",
"variable": 7,
},
Object {
"default": false,
"key": "FOURTEEN_DAYS",
"label": "14 day until tags are automatically removed",
"label": "14 days until tags are automatically removed",
"variable": 14,
},
Object {
"default": false,
"key": "THIRTY_DAYS",
"label": "30 day until tags are automatically removed",
"label": "30 days until tags are automatically removed",
"variable": 30,
},
Object {
"default": true,
"key": "NINETY_DAYS",
"label": "90 day until tags are automatically removed",
"label": "90 days until tags are automatically removed",
"variable": 90,
},
]
......
......@@ -40,13 +40,13 @@ describe('Expiration Policy Form', () => {
});
describe.each`
elementName | modelName | value | disabledByToggle
${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'}
${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'}
${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'}
${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'}
elementName | modelName | value | disabledByToggle
${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'}
${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'}
${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'}
${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'}
${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'}
${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'}
`(
`${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`,
({ elementName, modelName, value, disabledByToggle }) => {
......@@ -128,9 +128,9 @@ describe('Expiration Policy Form', () => {
});
describe.each`
modelName | elementName
${'name_regex'} | ${'name-matching'}
${'name_regex_keep'} | ${'keep-name'}
modelName | elementName
${'nameRegex'} | ${'name-matching'}
${'nameRegexKeep'} | ${'keep-name'}
`('regex textarea validation', ({ modelName, elementName }) => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
......
export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' };
export const GlCard = {
name: 'gl-card-stub',
template: `
<div>
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
`,
};
import { formOptionsGenerator, optionLabelGenerator } from '~/registry/shared/utils';
import {
formOptionsGenerator,
optionLabelGenerator,
olderThanTranslationGenerator,
} from '~/registry/shared/utils';
describe('Utils', () => {
describe('optionLabelGenerator', () => {
it('returns an array with a set label', () => {
const result = optionLabelGenerator([{ variable: 1 }, { variable: 2 }], '%d day', '%d days');
expect(result).toEqual([{ variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }]);
const result = optionLabelGenerator(
[{ variable: 1 }, { variable: 2 }],
olderThanTranslationGenerator,
);
expect(result).toEqual([
{ variable: 1, label: '1 day until tags are automatically removed' },
{ variable: 2, label: '2 days until tags are automatically removed' },
]);
});
});
......
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