Fix draft saving and add tests
This commit is contained in:
parent
9e2086edaf
commit
690812f27c
8 changed files with 364 additions and 2 deletions
1
changelog.d/draft-save.fix
Normal file
1
changelog.d/draft-save.fix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Fix draft saving when auto-save is off
|
||||||
1
changelog.d/post-more-actions-label.fix
Normal file
1
changelog.d/post-more-actions-label.fix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add text label for more actions button in post status form
|
||||||
|
|
@ -376,6 +376,14 @@ const PostStatusForm = {
|
||||||
this.newStatus.hasPoll
|
this.newStatus.hasPoll
|
||||||
) && this.saveable
|
) && this.saveable
|
||||||
},
|
},
|
||||||
|
hasEmptyDraft () {
|
||||||
|
return this.newStatus.id && !(
|
||||||
|
this.newStatus.status ||
|
||||||
|
this.newStatus.spoilerText ||
|
||||||
|
this.newStatus.files?.length ||
|
||||||
|
this.newStatus.hasPoll
|
||||||
|
)
|
||||||
|
},
|
||||||
...mapGetters(['mergedConfig']),
|
...mapGetters(['mergedConfig']),
|
||||||
...mapState(useInterfaceStore, {
|
...mapState(useInterfaceStore, {
|
||||||
mobileLayout: store => store.mobileLayout
|
mobileLayout: store => store.mobileLayout
|
||||||
|
|
@ -784,7 +792,7 @@ const PostStatusForm = {
|
||||||
this.$emit('draft-done')
|
this.$emit('draft-done')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (this.newStatus.id) {
|
} else if (this.hasEmptyDraft) {
|
||||||
// There is a draft, but there is nothing in it, clear it
|
// There is a draft, but there is nothing in it, clear it
|
||||||
return this.abandonDraft()
|
return this.abandonDraft()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,7 @@
|
||||||
trigger="click"
|
trigger="click"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
:offset="{ y: 5 }"
|
:offset="{ y: 5 }"
|
||||||
|
:trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<FAIcon
|
<FAIcon
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,8 @@
|
||||||
"auto_save_saved": "Saved.",
|
"auto_save_saved": "Saved.",
|
||||||
"auto_save_saving": "Saving...",
|
"auto_save_saving": "Saving...",
|
||||||
"save_to_drafts_button": "Save to drafts",
|
"save_to_drafts_button": "Save to drafts",
|
||||||
"save_to_drafts_and_close_button": "Save to drafts and close"
|
"save_to_drafts_and_close_button": "Save to drafts and close",
|
||||||
|
"more_post_actions": "More post actions..."
|
||||||
},
|
},
|
||||||
"registration": {
|
"registration": {
|
||||||
"bio_optional": "Bio (optional)",
|
"bio_optional": "Bio (optional)",
|
||||||
|
|
|
||||||
52
test/fixtures/mock_store.js
vendored
Normal file
52
test/fixtures/mock_store.js
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { createStore } from 'vuex'
|
||||||
|
import { cloneDeep } from 'lodash'
|
||||||
|
import instanceModule from 'src/modules/instance.js'
|
||||||
|
import statusesModule from 'src/modules/statuses.js'
|
||||||
|
import notificationsModule from 'src/modules/notifications.js'
|
||||||
|
import usersModule from 'src/modules/users.js'
|
||||||
|
import apiModule from 'src/modules/api.js'
|
||||||
|
import configModule from 'src/modules/config.js'
|
||||||
|
import profileConfigModule from 'src/modules/profileConfig.js'
|
||||||
|
import serverSideStorageModule from 'src/modules/serverSideStorage.js'
|
||||||
|
import adminSettingsModule from 'src/modules/adminSettings.js'
|
||||||
|
import oauthModule from 'src/modules/oauth.js'
|
||||||
|
import authFlowModule from 'src/modules/auth_flow.js'
|
||||||
|
import oauthTokensModule from 'src/modules/oauth_tokens.js'
|
||||||
|
import draftsModule from 'src/modules/drafts.js'
|
||||||
|
import chatsModule from 'src/modules/chats.js'
|
||||||
|
import bookmarkFoldersModule from 'src/modules/bookmark_folders.js'
|
||||||
|
|
||||||
|
const tweakModules = modules => {
|
||||||
|
const res = {}
|
||||||
|
Object.entries(modules).forEach(([name, module]) => {
|
||||||
|
const m = { ...module }
|
||||||
|
m.state = cloneDeep(module.state)
|
||||||
|
res[name] = m
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMockStore = () => {
|
||||||
|
return createStore({
|
||||||
|
modules: tweakModules({
|
||||||
|
instance: instanceModule,
|
||||||
|
// TODO refactor users/statuses modules, they depend on each other
|
||||||
|
users: usersModule,
|
||||||
|
statuses: statusesModule,
|
||||||
|
notifications: notificationsModule,
|
||||||
|
api: apiModule,
|
||||||
|
config: configModule,
|
||||||
|
profileConfig: profileConfigModule,
|
||||||
|
serverSideStorage: serverSideStorageModule,
|
||||||
|
adminSettings: adminSettingsModule,
|
||||||
|
oauth: oauthModule,
|
||||||
|
authFlow: authFlowModule,
|
||||||
|
oauthTokens: oauthTokensModule,
|
||||||
|
drafts: draftsModule,
|
||||||
|
chats: chatsModule,
|
||||||
|
bookmarkFolders: bookmarkFoldersModule
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeMockStore
|
||||||
132
test/fixtures/setup_test.js
vendored
Normal file
132
test/fixtures/setup_test.js
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { config } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import VueVirtualScroller from 'vue-virtual-scroller'
|
||||||
|
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||||
|
import routes from 'src/boot/routes'
|
||||||
|
import makeMockStore from './mock_store'
|
||||||
|
|
||||||
|
export const $t = msg => msg
|
||||||
|
const $i18n = { t: msg => msg }
|
||||||
|
|
||||||
|
const applyAfterStore = (store, afterStore) => {
|
||||||
|
afterStore(store)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultOpts = ({ afterStore = () => {} } = {}) => ({
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
applyAfterStore(makeMockStore(), afterStore),
|
||||||
|
VueVirtualScroller,
|
||||||
|
createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: routes({ state: {
|
||||||
|
users: {
|
||||||
|
currentUser: {}
|
||||||
|
},
|
||||||
|
instance: {}
|
||||||
|
}})
|
||||||
|
}),
|
||||||
|
(Vue) => { Vue.directive('body-scroll-lock', {}) }
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
I18nT: true,
|
||||||
|
teleport: true,
|
||||||
|
FAIcon: true,
|
||||||
|
FALayers: true,
|
||||||
|
},
|
||||||
|
mocks: {
|
||||||
|
$t,
|
||||||
|
$i18n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/vuejs/vue-test-utils/issues/960
|
||||||
|
const customBehaviors = () => {
|
||||||
|
const filterByText = keyword => {
|
||||||
|
const match = keyword instanceof RegExp
|
||||||
|
? (target) => target && keyword.test(target)
|
||||||
|
: (target) => keyword === target
|
||||||
|
|
||||||
|
return wrapper => (
|
||||||
|
match(wrapper.text()) ||
|
||||||
|
match(wrapper.attributes('aria-label')) ||
|
||||||
|
match(wrapper.attributes('title'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
findComponentByText(searchedComponent, text) {
|
||||||
|
return this.findAllComponents(searchedComponent)
|
||||||
|
.filter(filterByText(text))
|
||||||
|
.at(0)
|
||||||
|
},
|
||||||
|
findByText(searchedElement, text) {
|
||||||
|
return this.findAll(searchedElement)
|
||||||
|
.filter(filterByText(text))
|
||||||
|
.at(0)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config.plugins.VueWrapper.install(customBehaviors)
|
||||||
|
|
||||||
|
export const mountOpts = (allOpts = {}) => {
|
||||||
|
const { afterStore, ...opts } = allOpts
|
||||||
|
const defaultOpts = getDefaultOpts({ afterStore })
|
||||||
|
const mergedOpts = {
|
||||||
|
...opts,
|
||||||
|
global: {
|
||||||
|
...defaultOpts.global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.global) {
|
||||||
|
mergedOpts.global.plugins = mergedOpts.global.plugins.concat(opts.global.plugins || [])
|
||||||
|
Object.entries(opts.global).forEach(([k, v]) => {
|
||||||
|
if (k === 'plugins') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (defaultOpts.global[k]) {
|
||||||
|
mergedOpts.global[k] = {
|
||||||
|
...defaultOpts.global[k],
|
||||||
|
...v,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergedOpts.global[k] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/78033718/how-can-i-wait-for-an-emitted-event-of-a-mounted-component-in-vue-test-utils
|
||||||
|
export const waitForEvent = (wrapper, event, {
|
||||||
|
timeout = 1000,
|
||||||
|
timesEmitted = 1
|
||||||
|
} = {}) => {
|
||||||
|
const tick = 10
|
||||||
|
const totalTries = timeout / tick
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let currentTries = 0
|
||||||
|
const wait = () => {
|
||||||
|
const e = wrapper.emitted(event)
|
||||||
|
if (e && e.length >= timesEmitted) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentTries >= totalTries) {
|
||||||
|
reject(new Error('Event did not fire'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
++currentTries
|
||||||
|
setTimeout(wait, tick)
|
||||||
|
}
|
||||||
|
wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
166
test/unit/specs/components/draft.spec.js
Normal file
166
test/unit/specs/components/draft.spec.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import sinon from 'sinon'
|
||||||
|
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
|
||||||
|
import { mountOpts, waitForEvent, $t } from '../../../fixtures/setup_test'
|
||||||
|
|
||||||
|
const autoSaveOrNot = (caseFn, caseTitle, runFn) => {
|
||||||
|
caseFn(`${caseTitle} with auto-save`, function () {
|
||||||
|
return runFn.bind(this)(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
caseFn(`${caseTitle} with no auto-save`, function () {
|
||||||
|
return runFn.bind(this)(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveManually = async (wrapper) => {
|
||||||
|
const morePostActions = wrapper.findByText('button', $t('post_status.more_post_actions'))
|
||||||
|
await morePostActions.trigger('click')
|
||||||
|
|
||||||
|
const btn = wrapper.findByText('button', $t('post_status.save_to_drafts_button'))
|
||||||
|
await btn.trigger('click')
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitSaveTime = 4000
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Draft saving', () => {
|
||||||
|
autoSaveOrNot(it, 'should save when the button is clicked', async (autoSave) => {
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts())
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: autoSave
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
|
||||||
|
await saveManually(wrapper)
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
|
||||||
|
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal('mew mew')
|
||||||
|
console.log('done')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should auto-save if it is enabled', async function () {
|
||||||
|
this.timeout(5000)
|
||||||
|
const clock = sinon.useFakeTimers(Date.now())
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts())
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: true
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
await clock.tickAsync(waitSaveTime)
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
|
||||||
|
expect(wrapper.vm.$store.getters.draftsArray[0].status).to.equal('mew mew')
|
||||||
|
clock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should auto-save when close if auto-save is on', async () => {
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts({
|
||||||
|
props: {
|
||||||
|
closeable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: true
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
wrapper.vm.requestClose()
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
|
||||||
|
await waitForEvent(wrapper, 'can-close')
|
||||||
|
console.log('done')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save when close if auto-save is off, and unsavedPostAction is save', async () => {
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts({
|
||||||
|
props: {
|
||||||
|
closeable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: false
|
||||||
|
})
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'unsavedPostAction',
|
||||||
|
value: 'save'
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
wrapper.vm.requestClose()
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
|
||||||
|
await waitForEvent(wrapper, 'can-close')
|
||||||
|
console.log('done')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should discard when close if auto-save is off, and unsavedPostAction is discard', async () => {
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts({
|
||||||
|
props: {
|
||||||
|
closeable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: false
|
||||||
|
})
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'unsavedPostAction',
|
||||||
|
value: 'discard'
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
wrapper.vm.requestClose()
|
||||||
|
await waitForEvent(wrapper, 'can-close')
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
console.log('done')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm when close if auto-save is off, and unsavedPostAction is confirm', async () => {
|
||||||
|
try {
|
||||||
|
const wrapper = mount(PostStatusForm, mountOpts({
|
||||||
|
props: {
|
||||||
|
closeable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'autoSaveDraft',
|
||||||
|
value: false
|
||||||
|
})
|
||||||
|
await wrapper.vm.$store.dispatch('setOption', {
|
||||||
|
name: 'unsavedPostAction',
|
||||||
|
value: 'confirm'
|
||||||
|
})
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(0)
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
await textarea.setValue('mew mew')
|
||||||
|
wrapper.vm.requestClose()
|
||||||
|
await nextTick()
|
||||||
|
const saveButton = wrapper.findByText('button', $t('post_status.close_confirm_save_button'))
|
||||||
|
expect(saveButton).to.be.ok
|
||||||
|
await saveButton.trigger('click')
|
||||||
|
console.log('clicked')
|
||||||
|
expect(wrapper.vm.$store.getters.draftCount).to.equal(1)
|
||||||
|
await flushPromises()
|
||||||
|
await waitForEvent(wrapper, 'can-close')
|
||||||
|
console.log('done')
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue