From 046f79623c83fae566c00f0eb951a7ea5725eb27 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 2 Nov 2026 17:33:57 +0100 Subject: [PATCH 1/2] refactor(user_status): migrate from vuex to pinia Replace the vuex store with pinia setup stores, written in TypeScript. The user status and backup status stores share their state handling through a common composable. Both apps mounted by the menu entry point (user menu and dashboard status) keep sharing one pinia instance. Saving a predefined status message now awaits the request, so errors are shown and the modal stays open, like for custom messages. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- apps/user_status/src/UserStatus.spec.ts | 55 ++++ apps/user_status/src/UserStatus.vue | 10 +- .../components/PredefinedStatusesList.spec.ts | 39 +++ .../src/components/PredefinedStatusesList.vue | 15 +- .../src/components/SetStatusModal.vue | 56 ++-- apps/user_status/src/menu.js | 7 +- .../src/mixins/OnlineStatusMixin.js | 16 +- .../src/services/predefinedStatusService.js | 2 +- apps/user_status/src/store/index.js | 18 -- .../src/store/predefinedStatuses.js | 54 ---- .../src/store/predefinedStatuses.spec.ts | 47 +++ .../src/store/predefinedStatuses.ts | 41 +++ apps/user_status/src/store/statusState.ts | 78 +++++ .../user_status/src/store/userBackupStatus.js | 103 ------ .../src/store/userBackupStatus.spec.ts | 86 +++++ .../user_status/src/store/userBackupStatus.ts | 55 ++++ apps/user_status/src/store/userStatus.js | 296 ------------------ apps/user_status/src/store/userStatus.spec.ts | 159 ++++++++++ apps/user_status/src/store/userStatus.ts | 142 +++++++++ 19 files changed, 747 insertions(+), 532 deletions(-) create mode 100644 apps/user_status/src/UserStatus.spec.ts create mode 100644 apps/user_status/src/components/PredefinedStatusesList.spec.ts delete mode 100644 apps/user_status/src/store/index.js delete mode 100644 apps/user_status/src/store/predefinedStatuses.js create mode 100644 apps/user_status/src/store/predefinedStatuses.spec.ts create mode 100644 apps/user_status/src/store/predefinedStatuses.ts create mode 100644 apps/user_status/src/store/statusState.ts delete mode 100644 apps/user_status/src/store/userBackupStatus.js create mode 100644 apps/user_status/src/store/userBackupStatus.spec.ts create mode 100644 apps/user_status/src/store/userBackupStatus.ts delete mode 100644 apps/user_status/src/store/userStatus.js create mode 100644 apps/user_status/src/store/userStatus.spec.ts create mode 100644 apps/user_status/src/store/userStatus.ts diff --git a/apps/user_status/src/UserStatus.spec.ts b/apps/user_status/src/UserStatus.spec.ts new file mode 100644 index 0000000000000..0d58fc87fe73c --- /dev/null +++ b/apps/user_status/src/UserStatus.spec.ts @@ -0,0 +1,55 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { cleanup, render } from '@testing-library/vue' +import { createPinia } from 'pinia' +import { afterEach, describe, expect, it, vi } from 'vitest' +import UserStatus from './UserStatus.vue' +import { useUserStatusStore } from './store/userStatus.ts' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/initial-state', () => ({ loadState: vi.fn() })) + +describe('UserStatus', () => { + afterEach(cleanup) + + it('shows the status from the initial state', async () => { + vi.mocked(loadState).mockReturnValue({ + status: 'away', + statusIsUserDefined: true, + message: 'In a meeting', + icon: '📅', + clearAt: null, + messageIsPredefined: false, + messageId: null, + }) + + const component = render(UserStatus, { + props: { inline: true }, + global: { plugins: [createPinia()] }, + }) + + expect(await component.findByRole('button', { name: '📅 In a meeting' })).toBeTruthy() + }) + + it('updates the shared store from status events of the current user', async () => { + vi.mocked(loadState).mockReturnValue({ status: 'online', statusIsUserDefined: true, message: null, icon: null }) + + const pinia = createPinia() + const component = render(UserStatus, { + props: { inline: true }, + global: { plugins: [pinia] }, + }) + expect(await component.findByRole('button', { name: 'Online' })).toBeTruthy() + + emit('user_status:status.updated', { userId: 'other', status: 'dnd', message: 'Other user', icon: null }) + emit('user_status:status.updated', { userId: 'test', status: 'dnd', message: null, icon: null }) + + expect(await component.findByRole('button', { name: 'Do not disturb' })).toBeTruthy() + expect(useUserStatusStore(pinia).status).toBe('dnd') + }) +}) diff --git a/apps/user_status/src/UserStatus.vue b/apps/user_status/src/UserStatus.vue index 789097407288d..5e8687ff7f7a3 100644 --- a/apps/user_status/src/UserStatus.vue +++ b/apps/user_status/src/UserStatus.vue @@ -82,10 +82,10 @@ export default { /** * Loads the current user's status from initial state - * and stores it in Vuex + * and stores it in the user status store */ mounted() { - this.$store.dispatch('loadStatusFromInitialState') + this.userStatusStore.loadStatusFromInitialState() if (OC.config.session_keepalive) { this.stopHeartbeat = startHeartbeat((isAway) => this._backgroundHeartbeat(isAway)) @@ -127,9 +127,9 @@ export default { try { const status = await sendHeartbeat(isAway) if (status?.userId) { - this.$store.dispatch('setStatusFromHeartbeat', status) + this.userStatusStore.loadStatusFromServer(status) } else { - await this.$store.dispatch('reFetchStatusFromServer') + await this.userStatusStore.reFetchStatusFromServer() } } catch (error) { logger.debug('Failed sending heartbeat, got: ' + error.response?.status) @@ -138,7 +138,7 @@ export default { handleUserStatusUpdated(state) { if (getCurrentUser()?.uid === state.userId) { - this.$store.dispatch('setStatusFromObject', { + this.userStatusStore.loadStatusFromServer({ status: state.status, icon: state.icon, message: state.message, diff --git a/apps/user_status/src/components/PredefinedStatusesList.spec.ts b/apps/user_status/src/components/PredefinedStatusesList.spec.ts new file mode 100644 index 0000000000000..aefb837a27154 --- /dev/null +++ b/apps/user_status/src/components/PredefinedStatusesList.spec.ts @@ -0,0 +1,39 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { cleanup, render } from '@testing-library/vue' +import { createPinia } from 'pinia' +import { afterEach, describe, expect, it, vi } from 'vitest' +import PredefinedStatusesList from './PredefinedStatusesList.vue' +import { fetchAllPredefinedStatuses } from '../services/predefinedStatusService.js' +import { useUserStatusStore } from '../store/userStatus.ts' + +vi.mock('../services/predefinedStatusService.js', () => ({ + fetchAllPredefinedStatuses: vi.fn(), +})) + +describe('PredefinedStatusesList', () => { + afterEach(cleanup) + + it('loads the predefined statuses and marks the current one as selected', async () => { + vi.mocked(fetchAllPredefinedStatuses).mockResolvedValue([ + { id: 'meeting', icon: '📅', message: 'In a meeting', clearAt: null }, + { id: 'sick', icon: '🤒', message: 'Out sick', clearAt: null }, + ]) + + const pinia = createPinia() + useUserStatusStore(pinia).loadStatusFromServer({ messageId: 'sick' }) + + const component = render(PredefinedStatusesList, { + global: { plugins: [pinia] }, + }) + + const list = await component.findByRole('list', { name: 'Predefined statuses' }) + expect(list.textContent).toContain('In a meeting') + expect(list.textContent).toContain('Out sick') + expect(fetchAllPredefinedStatuses).toHaveBeenCalledOnce() + expect(component.getByRole('radio', { checked: true, name: /Out sick/ })).toBeTruthy() + }) +}) diff --git a/apps/user_status/src/components/PredefinedStatusesList.vue b/apps/user_status/src/components/PredefinedStatusesList.vue index 068656c21ba25..d7ee19b3314d8 100644 --- a/apps/user_status/src/components/PredefinedStatusesList.vue +++ b/apps/user_status/src/components/PredefinedStatusesList.vue @@ -27,9 +27,11 @@