Commit 3d91af7e authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '212561-saving-changes-action' into 'master'

Submit changes in the Static Site Editor

Closes #212561

See merge request gitlab-org/gitlab!29073
parents 6cf6d3ec 99ff28f0
<script> <script>
import { GlNewButton } from '@gitlab/ui'; import { GlNewButton, GlLoadingIcon } from '@gitlab/ui';
export default { export default {
components: { components: {
GlNewButton, GlNewButton,
GlLoadingIcon,
}, },
props: { props: {
saveable: { saveable: {
...@@ -11,12 +12,22 @@ export default { ...@@ -11,12 +12,22 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
savingChanges: {
type: Boolean,
required: false,
default: false,
},
}, },
}; };
</script> </script>
<template> <template>
<div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4"> <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4">
<gl-new-button variant="success" :disabled="!saveable"> <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" />
<gl-new-button
variant="success"
:disabled="!saveable || savingChanges"
@click="$emit('submit')"
>
{{ __('Submit Changes') }} {{ __('Submit Changes') }}
</gl-new-button> </gl-new-button>
</div> </div>
......
...@@ -12,14 +12,14 @@ export default { ...@@ -12,14 +12,14 @@ export default {
Toolbar, Toolbar,
}, },
computed: { computed: {
...mapState(['content', 'isLoadingContent']), ...mapState(['content', 'isLoadingContent', 'isSavingChanges']),
...mapGetters(['isContentLoaded', 'contentChanged']), ...mapGetters(['isContentLoaded', 'contentChanged']),
}, },
mounted() { mounted() {
this.loadContent(); this.loadContent();
}, },
methods: { methods: {
...mapActions(['loadContent', 'setContent']), ...mapActions(['loadContent', 'setContent', 'submitChanges']),
}, },
}; };
</script> </script>
...@@ -41,7 +41,11 @@ export default { ...@@ -41,7 +41,11 @@ export default {
:value="content" :value="content"
@input="setContent" @input="setContent"
/> />
<toolbar :saveable="contentChanged" /> <toolbar
:saveable="contentChanged"
:saving-changes="isSavingChanges"
@submit="submitChanges"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -6,7 +6,7 @@ const initStaticSiteEditor = el => { ...@@ -6,7 +6,7 @@ const initStaticSiteEditor = el => {
const { projectId, path: sourcePath } = el.dataset; const { projectId, path: sourcePath } = el.dataset;
const store = createStore({ const store = createStore({
initialState: { projectId, sourcePath }, initialState: { projectId, sourcePath, username: window.gon.current_username },
}); });
return new Vue({ return new Vue({
......
// TODO implement
const submitContentChanges = () => new Promise(resolve => setTimeout(resolve, 1000));
export default submitContentChanges;
...@@ -3,6 +3,7 @@ import { __ } from '~/locale'; ...@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import * as mutationTypes from './mutation_types'; import * as mutationTypes from './mutation_types';
import loadSourceContent from '~/static_site_editor/services/load_source_content'; import loadSourceContent from '~/static_site_editor/services/load_source_content';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
export const loadContent = ({ commit, state: { sourcePath, projectId } }) => { export const loadContent = ({ commit, state: { sourcePath, projectId } }) => {
commit(mutationTypes.LOAD_CONTENT); commit(mutationTypes.LOAD_CONTENT);
...@@ -19,4 +20,15 @@ export const setContent = ({ commit }, content) => { ...@@ -19,4 +20,15 @@ export const setContent = ({ commit }, content) => {
commit(mutationTypes.SET_CONTENT, content); commit(mutationTypes.SET_CONTENT, content);
}; };
export const submitChanges = ({ state: { projectId, content, sourcePath, username }, commit }) => {
commit(mutationTypes.SUBMIT_CHANGES);
return submitContentChanges({ content, projectId, sourcePath, username })
.then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data))
.catch(error => {
commit(mutationTypes.SUBMIT_CHANGES_ERROR);
createFlash(error.message);
});
};
export default () => {}; export default () => {};
...@@ -2,3 +2,6 @@ export const LOAD_CONTENT = 'loadContent'; ...@@ -2,3 +2,6 @@ export const LOAD_CONTENT = 'loadContent';
export const RECEIVE_CONTENT_SUCCESS = 'receiveContentSuccess'; export const RECEIVE_CONTENT_SUCCESS = 'receiveContentSuccess';
export const RECEIVE_CONTENT_ERROR = 'receiveContentError'; export const RECEIVE_CONTENT_ERROR = 'receiveContentError';
export const SET_CONTENT = 'setContent'; export const SET_CONTENT = 'setContent';
export const SUBMIT_CHANGES = 'submitChanges';
export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess';
export const SUBMIT_CHANGES_ERROR = 'submitChangesError';
...@@ -16,4 +16,15 @@ export default { ...@@ -16,4 +16,15 @@ export default {
[types.SET_CONTENT](state, content) { [types.SET_CONTENT](state, content) {
state.content = content; state.content = content;
}, },
[types.SUBMIT_CHANGES](state) {
state.isSavingChanges = true;
},
[types.SUBMIT_CHANGES_SUCCESS](state, meta) {
state.savedContentMeta = meta;
state.isSavingChanges = false;
state.originalContent = state.content;
},
[types.SUBMIT_CHANGES_ERROR](state) {
state.isSavingChanges = false;
},
}; };
const createState = (initialState = {}) => ({ const createState = (initialState = {}) => ({
username: null,
projectId: null, projectId: null,
sourcePath: null, sourcePath: null,
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlNewButton } from '@gitlab/ui'; import { GlNewButton, GlLoadingIcon } from '@gitlab/ui';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
...@@ -16,6 +16,7 @@ describe('Static Site Editor Toolbar', () => { ...@@ -16,6 +16,7 @@ describe('Static Site Editor Toolbar', () => {
}; };
const findSaveChangesButton = () => wrapper.find(GlNewButton); const findSaveChangesButton = () => wrapper.find(GlNewButton);
const findLoadingIndicator = () => wrapper.find(GlLoadingIcon);
beforeEach(() => { beforeEach(() => {
buildWrapper(); buildWrapper();
...@@ -33,6 +34,10 @@ describe('Static Site Editor Toolbar', () => { ...@@ -33,6 +34,10 @@ describe('Static Site Editor Toolbar', () => {
expect(findSaveChangesButton().attributes('disabled')).toBe('true'); expect(findSaveChangesButton().attributes('disabled')).toBe('true');
}); });
it('does not display saving changes indicator', () => {
expect(findLoadingIndicator().classes()).toContain('invisible');
});
describe('when saveable', () => { describe('when saveable', () => {
it('enables Submit Changes button', () => { it('enables Submit Changes button', () => {
buildWrapper({ saveable: true }); buildWrapper({ saveable: true });
...@@ -40,4 +45,26 @@ describe('Static Site Editor Toolbar', () => { ...@@ -40,4 +45,26 @@ describe('Static Site Editor Toolbar', () => {
expect(findSaveChangesButton().attributes('disabled')).toBeFalsy(); expect(findSaveChangesButton().attributes('disabled')).toBeFalsy();
}); });
}); });
describe('when saving changes', () => {
beforeEach(() => {
buildWrapper({ saveable: true, savingChanges: true });
});
it('disables Submit Changes button', () => {
expect(findSaveChangesButton().attributes('disabled')).toBe('true');
});
it('displays saving changes indicator', () => {
expect(findLoadingIndicator().classes()).not.toContain('invisible');
});
});
it('emits submit event when submit button is clicked', () => {
buildWrapper({ saveable: true });
findSaveChangesButton().vm.$emit('click');
expect(wrapper.emitted('submit')).toHaveLength(1);
});
}); });
...@@ -9,6 +9,8 @@ import StaticSiteEditor from '~/static_site_editor/components/static_site_editor ...@@ -9,6 +9,8 @@ import StaticSiteEditor from '~/static_site_editor/components/static_site_editor
import EditArea from '~/static_site_editor/components/edit_area.vue'; import EditArea from '~/static_site_editor/components/edit_area.vue';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
import { sourceContent } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -18,10 +20,12 @@ describe('StaticSiteEditor', () => { ...@@ -18,10 +20,12 @@ describe('StaticSiteEditor', () => {
let store; let store;
let loadContentActionMock; let loadContentActionMock;
let setContentActionMock; let setContentActionMock;
let submitChangesActionMock;
const buildStore = ({ initialState, getters } = {}) => { const buildStore = ({ initialState, getters } = {}) => {
loadContentActionMock = jest.fn(); loadContentActionMock = jest.fn();
setContentActionMock = jest.fn(); setContentActionMock = jest.fn();
submitChangesActionMock = jest.fn();
store = new Vuex.Store({ store = new Vuex.Store({
state: createState(initialState), state: createState(initialState),
...@@ -33,6 +37,7 @@ describe('StaticSiteEditor', () => { ...@@ -33,6 +37,7 @@ describe('StaticSiteEditor', () => {
actions: { actions: {
loadContent: loadContentActionMock, loadContent: loadContentActionMock,
setContent: setContentActionMock, setContent: setContentActionMock,
submitChanges: submitChangesActionMock,
}, },
}); });
}; };
...@@ -119,18 +124,35 @@ describe('StaticSiteEditor', () => { ...@@ -119,18 +124,35 @@ describe('StaticSiteEditor', () => {
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
}); });
it('sets toolbar as saving when saving changes', () => {
buildContentLoadedStore({
initialState: {
isSavingChanges: true,
},
});
buildWrapper();
expect(findPublishToolbar().props('savingChanges')).toBe(true);
});
it('dispatches load content action', () => { it('dispatches load content action', () => {
expect(loadContentActionMock).toHaveBeenCalled(); expect(loadContentActionMock).toHaveBeenCalled();
}); });
it('dispatches setContent action when edit area emits input event', () => { it('dispatches setContent action when edit area emits input event', () => {
const content = 'new content';
buildContentLoadedStore(); buildContentLoadedStore();
buildWrapper(); buildWrapper();
findEditArea().vm.$emit('input', content); findEditArea().vm.$emit('input', sourceContent);
expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), sourceContent, undefined);
});
it('dispatches submitChanges action when toolbar emits submit event', () => {
buildContentLoadedStore();
buildWrapper();
findPublishToolbar().vm.$emit('submit');
expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), content, undefined); expect(submitChangesActionMock).toHaveBeenCalled();
}); });
}); });
...@@ -14,5 +14,23 @@ twitter_image: '/images/tweets/handbook-gitlab.png' ...@@ -14,5 +14,23 @@ twitter_image: '/images/tweets/handbook-gitlab.png'
export const sourceContentTitle = 'Handbook'; export const sourceContentTitle = 'Handbook';
export const username = 'gitlabuser';
export const projectId = '123456'; export const projectId = '123456';
export const sourcePath = 'foobar.md.html'; export const sourcePath = 'foobar.md.html';
export const savedContentMeta = {
branch: {
label: 'foobar',
url: 'foobar/-/tree/foorbar',
},
commit: {
label: 'c1461b08 ',
url: 'foobar/-/c1461b08',
},
mergeRequest: {
label: '123',
url: 'foobar/-/merge_requests/123',
},
};
export const submitChangesError = 'Could not save changes';
...@@ -3,18 +3,23 @@ import createState from '~/static_site_editor/store/state'; ...@@ -3,18 +3,23 @@ import createState from '~/static_site_editor/store/state';
import * as actions from '~/static_site_editor/store/actions'; import * as actions from '~/static_site_editor/store/actions';
import * as mutationTypes from '~/static_site_editor/store/mutation_types'; import * as mutationTypes from '~/static_site_editor/store/mutation_types';
import loadSourceContent from '~/static_site_editor/services/load_source_content'; import loadSourceContent from '~/static_site_editor/services/load_source_content';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { import {
username,
projectId, projectId,
sourcePath, sourcePath,
sourceContentTitle as title, sourceContentTitle as title,
sourceContent as content, sourceContent as content,
savedContentMeta,
submitChangesError,
} from '../mock_data'; } from '../mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn()); jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn());
jest.mock('~/static_site_editor/services/submit_content_changes', () => jest.fn());
describe('Static Site Editor Store actions', () => { describe('Static Site Editor Store actions', () => {
let state; let state;
...@@ -84,4 +89,59 @@ describe('Static Site Editor Store actions', () => { ...@@ -84,4 +89,59 @@ describe('Static Site Editor Store actions', () => {
]); ]);
}); });
}); });
describe('submitChanges', () => {
describe('on success', () => {
beforeEach(() => {
state = createState({
projectId,
content,
username,
sourcePath,
});
submitContentChanges.mockResolvedValueOnce(savedContentMeta);
});
it('commits submitChangesSuccess mutation', () => {
testAction(
actions.submitChanges,
null,
state,
[
{ type: mutationTypes.SUBMIT_CHANGES },
{ type: mutationTypes.SUBMIT_CHANGES_SUCCESS, payload: savedContentMeta },
],
[],
);
expect(submitContentChanges).toHaveBeenCalledWith({
username,
projectId,
content,
sourcePath,
});
});
});
describe('on error', () => {
const expectedMutations = [
{ type: mutationTypes.SUBMIT_CHANGES },
{ type: mutationTypes.SUBMIT_CHANGES_ERROR },
];
beforeEach(() => {
submitContentChanges.mockRejectedValueOnce(new Error(submitChangesError));
});
it('dispatches receiveContentError', () => {
testAction(actions.submitChanges, null, state, expectedMutations);
});
it('displays flash communicating error', () => {
return testAction(actions.submitChanges, null, state, expectedMutations).then(() => {
expect(createFlash).toHaveBeenCalledWith(submitChangesError);
});
});
});
});
}); });
import createState from '~/static_site_editor/store/state'; import createState from '~/static_site_editor/store/state';
import mutations from '~/static_site_editor/store/mutations'; import mutations from '~/static_site_editor/store/mutations';
import * as types from '~/static_site_editor/store/mutation_types'; import * as types from '~/static_site_editor/store/mutation_types';
import { sourceContentTitle as title, sourceContent as content } from '../mock_data'; import {
sourceContentTitle as title,
sourceContent as content,
savedContentMeta,
} from '../mock_data';
describe('Static Site Editor Store mutations', () => { describe('Static Site Editor Store mutations', () => {
let state; let state;
const contentLoadedPayload = { title, content };
beforeEach(() => { beforeEach(() => {
state = createState(); state = createState();
}); });
describe('loadContent', () => { it.each`
beforeEach(() => { mutation | stateProperty | payload | expectedValue
mutations[types.LOAD_CONTENT](state); ${types.LOAD_CONTENT} | ${'isLoadingContent'} | ${undefined} | ${true}
}); ${types.RECEIVE_CONTENT_SUCCESS} | ${'isLoadingContent'} | ${contentLoadedPayload} | ${false}
${types.RECEIVE_CONTENT_SUCCESS} | ${'title'} | ${contentLoadedPayload} | ${title}
it('sets isLoadingContent to true', () => { ${types.RECEIVE_CONTENT_SUCCESS} | ${'content'} | ${contentLoadedPayload} | ${content}
expect(state.isLoadingContent).toBe(true); ${types.RECEIVE_CONTENT_SUCCESS} | ${'originalContent'} | ${contentLoadedPayload} | ${content}
}); ${types.RECEIVE_CONTENT_ERROR} | ${'isLoadingContent'} | ${undefined} | ${false}
}); ${types.SET_CONTENT} | ${'content'} | ${content} | ${content}
${types.SUBMIT_CHANGES} | ${'isSavingChanges'} | ${undefined} | ${true}
describe('receiveContentSuccess', () => { ${types.SUBMIT_CHANGES_SUCCESS} | ${'savedContentMeta'} | ${savedContentMeta} | ${savedContentMeta}
const payload = { title, content }; ${types.SUBMIT_CHANGES_SUCCESS} | ${'isSavingChanges'} | ${savedContentMeta} | ${false}
${types.SUBMIT_CHANGES_ERROR} | ${'isSavingChanges'} | ${undefined} | ${false}
beforeEach(() => { `(
mutations[types.RECEIVE_CONTENT_SUCCESS](state, payload); '$mutation sets $stateProperty to $expectedValue',
}); ({ mutation, stateProperty, payload, expectedValue }) => {
mutations[mutation](state, payload);
it('sets current state to LOADING', () => { expect(state[stateProperty]).toBe(expectedValue);
expect(state.isLoadingContent).toBe(false); },
}); );
it('sets title', () => { it(`${types.SUBMIT_CHANGES_SUCCESS} sets originalContent to content current value`, () => {
expect(state.title).toBe(payload.title); const editedContent = `${content} plus something else`;
});
state = createState({
it('sets originalContent and content', () => { originalContent: content,
expect(state.content).toBe(payload.content); content: editedContent,
expect(state.originalContent).toBe(payload.content); });
}); mutations[types.SUBMIT_CHANGES_SUCCESS](state);
});
expect(state.originalContent).toBe(state.content);
describe('receiveContentError', () => {
beforeEach(() => {
mutations[types.RECEIVE_CONTENT_ERROR](state);
});
it('sets current state to LOADING_ERROR', () => {
expect(state.isLoadingContent).toBe(false);
});
});
describe('setContent', () => {
it('sets content', () => {
mutations[types.SET_CONTENT](state, content);
expect(state.content).toBe(content);
});
}); });
}); });
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