commit: 690812f27cf79646fd6020bc659192ec5beddb1c
parent 9e2086edaf08bd9a2b8adf0cf308c131a2a5ecc6
Author: tusooa <tusooa@kazv.moe>
Date: Tue, 18 Feb 2025 17:42:50 -0500
Fix draft saving and add tests
Diffstat:
8 files changed, 364 insertions(+), 2 deletions(-)
diff --git a/changelog.d/draft-save.fix b/changelog.d/draft-save.fix
@@ -0,0 +1 @@
+Fix draft saving when auto-save is off
diff --git a/changelog.d/post-more-actions-label.fix b/changelog.d/post-more-actions-label.fix
@@ -0,0 +1 @@
+Add text label for more actions button in post status form
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
@@ -376,6 +376,14 @@ const PostStatusForm = {
this.newStatus.hasPoll
) && this.saveable
},
+ hasEmptyDraft () {
+ return this.newStatus.id && !(
+ this.newStatus.status ||
+ this.newStatus.spoilerText ||
+ this.newStatus.files?.length ||
+ this.newStatus.hasPoll
+ )
+ },
...mapGetters(['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: store => store.mobileLayout
@@ -784,7 +792,7 @@ const PostStatusForm = {
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
return this.abandonDraft()
.then(() => {
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
@@ -322,6 +322,7 @@
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
+ :trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
>
<template #trigger>
<FAIcon
diff --git a/src/i18n/en.json b/src/i18n/en.json
@@ -323,7 +323,8 @@
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"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": {
"bio_optional": "Bio (optional)",
diff --git a/test/fixtures/mock_store.js b/test/fixtures/mock_store.js
@@ -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
diff --git a/test/fixtures/setup_test.js b/test/fixtures/setup_test.js
@@ -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()
+ })
+}
diff --git a/test/unit/specs/components/draft.spec.js b/test/unit/specs/components/draft.spec.js
@@ -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
+ }
+ })
+})