logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/
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:

Achangelog.d/draft-save.fix1+
Achangelog.d/post-more-actions-label.fix1+
Msrc/components/post_status_form/post_status_form.js10+++++++++-
Msrc/components/post_status_form/post_status_form.vue1+
Msrc/i18n/en.json3++-
Atest/fixtures/mock_store.js52++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/fixtures/setup_test.js132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/unit/specs/components/draft.spec.js166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } + }) +})