diff --git a/webapp/components/Editor/Editor.spec.js b/webapp/components/Editor/Editor.spec.js index ee762b332..2367fe975 100644 --- a/webapp/components/Editor/Editor.spec.js +++ b/webapp/components/Editor/Editor.spec.js @@ -1,22 +1,28 @@ -import { mount } from '@vue/test-utils' -import Editor from './Editor' - -import MutationObserver from 'mutation-observer' import Vue from 'vue' +import VueRouter from 'vue-router' +import { mount } from '@vue/test-utils' +import MutationObserver from 'mutation-observer' + +import Editor from './Editor' global.MutationObserver = MutationObserver const localVue = global.localVue +localVue.use(VueRouter) + +const localStorage = global.localStorage describe('Editor.vue', () => { let wrapper let propsData let mocks + let router const Wrapper = () => { return (wrapper = mount(Editor, { mocks, propsData, + router, localVue, sync: false, stubs: { @@ -141,4 +147,65 @@ describe('Editor.vue', () => { }) }) }) + + describe(':autosave', () => { + const getFirst = () => { + const storageKey = Object.keys(localStorage)[0] + const value = localStorage[storageKey] + return { + storageKey, + value, + } + } + describe('when false', () => { + let routerWrapper + + beforeEach(() => { + router = new VueRouter({ + routes: [{ path: 'post/create' }], + }) + router.push('/post/create') + propsData.autosave = false + routerWrapper = Wrapper() + }) + + it('does nothing', () => { + jest.useFakeTimers() + + const content = '
NOOP WIP
' + routerWrapper.vm.editor.setContent(content, true) + jest.runAllTimers() + + expect(Object.keys(localStorage).length).toBe(0) + }) + }) + + describe('when editing a post', () => { + let routerWrapper + const content = 'Post WIP
' + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem') + + beforeEach(() => { + router = new VueRouter({ + routes: [{ path: 'post/create' }], + }) + router.push('/post/create') + routerWrapper = Wrapper() + }) + + afterEach(setItemSpy.mockReset) + + it('saves editor content to localStorage on input', async () => { + jest.useFakeTimers() + + routerWrapper.vm.editor.setContent(content, true) + await jest.runAllTimers() + + const { storageKey, value } = getFirst() + expect(setItemSpy).toHaveBeenCalled() + expect(storageKey.startsWith('draft:post:')).toBe(true) + expect(value).toBe(content) + }) + }) + }) }) diff --git a/webapp/components/Editor/Editor.story.js b/webapp/components/Editor/Editor.story.js index 0db445eb1..c7521a131 100644 --- a/webapp/components/Editor/Editor.story.js +++ b/webapp/components/Editor/Editor.story.js @@ -1,5 +1,6 @@ import { storiesOf } from '@storybook/vue' import { withA11y } from '@storybook/addon-a11y' +import StoryRouter from 'storybook-vue-router' import HcEditor from '~/components/Editor/Editor.vue' import helpers from '~/storybook/helpers' import Vue from 'vue' @@ -35,6 +36,7 @@ const users = [ storiesOf('Editor', module) .addDecorator(withA11y) + .addDecorator(StoryRouter()) .addDecorator((storyFn) => { const ctx = storyFn() return { diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/Editor.vue index f0e9c7909..1fbe49be6 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/Editor.vue @@ -52,6 +52,7 @@ export default { hashtags: { type: Array, default: () => null }, // If 'null', than the Hashtag extention is not assigned. value: { type: String, default: '' }, doc: { type: Object, default: () => {} }, + autosave: { type: Boolean, default: true }, }, data() { return { diff --git a/webapp/components/Editor/defaultExtensions.js b/webapp/components/Editor/defaultExtensions.js index 63cf8c73f..29f934f83 100644 --- a/webapp/components/Editor/defaultExtensions.js +++ b/webapp/components/Editor/defaultExtensions.js @@ -4,6 +4,7 @@ import Link from '~/components/Editor/nodes/Link.js' import Strike from '~/components/Editor/marks/Strike' import Italic from '~/components/Editor/marks/Italic' import Bold from '~/components/Editor/marks/Bold' +import AutoSave from '~/components/Editor/plugins/autoSave' import EmbedQuery from '~/graphql/EmbedQuery.js' import { Heading, @@ -18,8 +19,8 @@ import { } from 'tiptap-extensions' export default function defaultExtensions(component) { - const { placeholder, $t, $apollo } = component - return [ + const { autosave, placeholder, $t, $apollo, $route } = component + const extensions = [ new Heading(), new HardBreak(), new Blockquote(), @@ -54,4 +55,10 @@ export default function defaultExtensions(component) { }, }), ] + + if (autosave && $route) { + extensions.push(new AutoSave({ $route })) + } + + return extensions } diff --git a/webapp/components/Editor/plugins/autoSave.js b/webapp/components/Editor/plugins/autoSave.js new file mode 100644 index 000000000..b0e3197e0 --- /dev/null +++ b/webapp/components/Editor/plugins/autoSave.js @@ -0,0 +1,55 @@ +import { Extension, Plugin, PluginKey } from 'tiptap' +import { DOMSerializer } from 'prosemirror-model' + +export default class AutoSave extends Extension { + constructor({ $route }) { + super() + this.route = $route + this._postId = 'randomIdForPosts' + } + + static toHTML(content, schema) { + const container = document.createElement('div') + const fragment = DOMSerializer.fromSchema(schema).serializeFragment(content) + container.appendChild(fragment) + return container.innerHTML + } + + get name() { + return 'auto_save' + } + + get storageKey() { + if (this.route.path === '/post/create') { + return `draft:post:${this._postId}` + } + return null + } + + get plugins() { + return [ + new Plugin({ + key: new PluginKey('auto_save'), + filterTransaction: (tr, editorState) => { + if (tr.docChanged) { + localStorage.setItem( + this.storageKey, + AutoSave.toHTML(tr.doc.content, editorState.config.schema), + ) + } + return tr + }, + state: { + init() { + return { + saveNextUpdate: false, + } + }, + apply(_, prev) { + return { ...prev } + }, + }, + }), + ] + } +}