Commit 99ff28f0 authored by Enrique Alcántara's avatar Enrique Alcántara Committed by Natalia Tepluhina

Implement Save changes action in SSE

Implement UI to submit changes in the Static Site
Editor and display an indicator that changes are
being saved.
parent 1267617d
<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