diff --git a/stores/__tests__/task-store-hooks.test.ts b/stores/__tests__/task-store-hooks.test.ts new file mode 100644 index 000000000..0a861e5e9 --- /dev/null +++ b/stores/__tests__/task-store-hooks.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useTaskStore } from '../task-store'; +import { clearAllHooks, taskHooks } from '@/lib/plugin-hooks'; +import type { CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Plugins hear about loaded, created, changed, deleted and checked-off tasks +// and filter changes; the before-hooks run ahead of the server call and can +// stop the change. + +function makeTask(overrides: Partial = {}): CalendarTask { + return { + id: 't1', + calendarIds: { 'cal-1': true }, + '@type': 'Task', + uid: 'task-uid-1', + title: 'Write report', + description: '', + due: '2026-09-08T10:00:00', + progress: 'needs-action', + priority: 0, + keywords: null, + ...overrides, + } as CalendarTask; +} + +let client: IJMAPClient; + +beforeEach(() => { + clearAllHooks(); + useTaskStore.setState({ tasks: [], selectedTaskId: null, filter: 'all', showCompleted: false, isLoading: false, error: null }); + client = { + getCalendarTasks: vi.fn().mockResolvedValue([makeTask()]), + createCalendarTask: vi.fn(async (t: Partial) => makeTask({ ...t, id: 'created' })), + updateCalendarTask: vi.fn().mockResolvedValue(undefined), + deleteCalendarTask: vi.fn().mockResolvedValue(undefined), + } as never; +}); + +describe('onTasksLoaded', () => { + it('reports the fetched tasks as views', async () => { + const handler = vi.fn(); + taskHooks.onTasksLoaded.register('t', handler); + await useTaskStore.getState().fetchTasks(client); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual([ + expect.objectContaining({ id: 't1', title: 'Write report', isComplete: false, calendarId: 'cal-1' }), + ]); + }); + + it('stays quiet when loading failed', async () => { + vi.mocked(client.getCalendarTasks).mockRejectedValue(new Error('boom')); + const handler = vi.fn(); + taskHooks.onTasksLoaded.register('t', handler); + await useTaskStore.getState().fetchTasks(client); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('create, update and delete', () => { + it('asks before creating and reports the created task', async () => { + const before = vi.fn((view) => { + expect(client.createCalendarTask).not.toHaveBeenCalled(); + expect(view).toMatchObject({ title: 'New task' }); + return true; + }); + taskHooks.onBeforeTaskCreate.register('t', before); + const after = vi.fn(); + taskHooks.onTaskCreate.register('t', after); + + await useTaskStore.getState().createTask(client, { title: 'New task', calendarIds: { 'cal-1': true } }); + + expect(before).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledTimes(1); + expect(after.mock.calls[0][0]).toMatchObject({ id: 'created', title: 'New task' }); + }); + + it('creates nothing when a plugin says no', async () => { + taskHooks.onBeforeTaskCreate.register('t', () => false); + const after = vi.fn(); + taskHooks.onTaskCreate.register('t', after); + + const created = await useTaskStore.getState().createTask(client, { title: 'New task' }); + + expect(created).toBeNull(); + expect(client.createCalendarTask).not.toHaveBeenCalled(); + expect(useTaskStore.getState().tasks).toEqual([]); + expect(after).not.toHaveBeenCalled(); + }); + + it('asks before changing and reports the changed task', async () => { + useTaskStore.setState({ tasks: [makeTask()] }); + const before = vi.fn(() => { + expect(client.updateCalendarTask).not.toHaveBeenCalled(); + return true; + }); + taskHooks.onBeforeTaskUpdate.register('t', before); + const after = vi.fn(); + taskHooks.onTaskUpdate.register('t', after); + + await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' }); + + expect(before).toHaveBeenCalledWith({ taskId: 't1', patch: { title: 'Renamed' } }); + expect(after).toHaveBeenCalledTimes(1); + expect(after.mock.calls[0][0]).toMatchObject({ id: 't1', title: 'Renamed' }); + }); + + it('leaves the task alone when a plugin says no', async () => { + useTaskStore.setState({ tasks: [makeTask()] }); + taskHooks.onBeforeTaskUpdate.register('t', () => false); + const after = vi.fn(); + taskHooks.onTaskUpdate.register('t', after); + + await useTaskStore.getState().updateTask(client, 't1', { title: 'Renamed' }); + + expect(client.updateCalendarTask).not.toHaveBeenCalled(); + expect(useTaskStore.getState().tasks[0].title).toBe('Write report'); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a change the server refused', async () => { + useTaskStore.setState({ tasks: [makeTask()] }); + vi.mocked(client.updateCalendarTask).mockRejectedValue(new Error('boom')); + const after = vi.fn(); + taskHooks.onTaskUpdate.register('t', after); + await expect(useTaskStore.getState().updateTask(client, 't1', { title: 'X' })).rejects.toThrow('boom'); + expect(after).not.toHaveBeenCalled(); + }); + + it('asks before deleting and reports the deleted task with its title', async () => { + useTaskStore.setState({ tasks: [makeTask()] }); + const before = vi.fn(() => { + expect(client.deleteCalendarTask).not.toHaveBeenCalled(); + return true; + }); + taskHooks.onBeforeTaskDelete.register('t', before); + const after = vi.fn(); + taskHooks.onTaskDelete.register('t', after); + + await useTaskStore.getState().deleteTask(client, 't1'); + + expect(before).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' }); + expect(after).toHaveBeenCalledWith({ taskId: 't1', title: 'Write report' }); + }); + + it('keeps the task when a plugin says no, and reports no failed delete', async () => { + useTaskStore.setState({ tasks: [makeTask()] }); + taskHooks.onBeforeTaskDelete.register('t', () => false); + const after = vi.fn(); + taskHooks.onTaskDelete.register('t', after); + + await useTaskStore.getState().deleteTask(client, 't1'); + expect(client.deleteCalendarTask).not.toHaveBeenCalled(); + expect(useTaskStore.getState().tasks).toHaveLength(1); + + clearAllHooks(); + taskHooks.onTaskDelete.register('t', after); + vi.mocked(client.deleteCalendarTask).mockRejectedValue(new Error('boom')); + await expect(useTaskStore.getState().deleteTask(client, 't1')).rejects.toThrow('boom'); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('onTaskToggleComplete', () => { + it('reports the new state after the server took it, and not as an update', async () => { + const task = makeTask(); + useTaskStore.setState({ tasks: [task] }); + const toggled = vi.fn((_payload: unknown) => { + expect(client.updateCalendarTask).toHaveBeenCalled(); + }); + taskHooks.onTaskToggleComplete.register('t', toggled); + const updated = vi.fn(); + taskHooks.onTaskUpdate.register('t', updated); + + await useTaskStore.getState().toggleTaskComplete(client, task); + await useTaskStore.getState().toggleTaskComplete(client, { ...task, progress: 'completed' }); + + expect(toggled.mock.calls.map((c) => c[0])).toEqual([ + { taskId: 't1', complete: true }, + { taskId: 't1', complete: false }, + ]); + expect(updated).not.toHaveBeenCalled(); + }); + + it('stays quiet when the server refused', async () => { + const task = makeTask(); + vi.mocked(client.updateCalendarTask).mockRejectedValue(new Error('boom')); + const toggled = vi.fn(); + taskHooks.onTaskToggleComplete.register('t', toggled); + await expect(useTaskStore.getState().toggleTaskComplete(client, task)).rejects.toThrow('boom'); + expect(toggled).not.toHaveBeenCalled(); + }); +}); + +describe('onTaskFilterChange', () => { + it('reports filter and show-completed changes, but not unchanged values', () => { + const handler = vi.fn(); + taskHooks.onTaskFilterChange.register('t', handler); + useTaskStore.getState().setFilter('all'); + useTaskStore.getState().setShowCompleted(false); + expect(handler).not.toHaveBeenCalled(); + + useTaskStore.getState().setFilter('overdue'); + useTaskStore.getState().setShowCompleted(true); + expect(handler.mock.calls.map((c) => c[0])).toEqual([ + { filter: 'overdue', showCompleted: false }, + { filter: 'overdue', showCompleted: true }, + ]); + }); +}); diff --git a/stores/__tests__/task-store.test.ts b/stores/__tests__/task-store.test.ts index 73adad370..89d4d19ac 100644 --- a/stores/__tests__/task-store.test.ts +++ b/stores/__tests__/task-store.test.ts @@ -105,7 +105,7 @@ describe('task-store', () => { const created = await useTaskStore.getState().createTask(mockClient as IJMAPClient, { title: 'New Task' }); expect(mockClient.createCalendarTask).toHaveBeenCalledWith({ title: 'New Task' }); - expect(created.id).toBe('task-created'); + expect(created?.id).toBe('task-created'); expect(useTaskStore.getState().tasks).toContainEqual(created); }); diff --git a/stores/task-store.ts b/stores/task-store.ts index e595d413f..57833328a 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { CalendarTask } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { debug } from '@/lib/debug'; +import { taskHooks } from '@/lib/plugin-hooks'; +import { taskToView } from '@/lib/plugin-projection'; export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -17,7 +19,8 @@ interface TaskStore { setFilter: (filter: TaskViewFilter) => void; setShowCompleted: (show: boolean) => void; fetchTasks: (client: IJMAPClient, calendarIds?: string[]) => Promise; - createTask: (client: IJMAPClient, task: Partial) => Promise; + /** Resolves to null when a plugin stopped the create. */ + createTask: (client: IJMAPClient, task: Partial) => Promise; updateTask: (client: IJMAPClient, id: string, updates: Partial) => Promise; deleteTask: (client: IJMAPClient, id: string) => Promise; toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise; @@ -33,8 +36,16 @@ export const useTaskStore = create((set, get) => ({ error: null, setTasks: (tasks) => set({ tasks }), setSelectedTaskId: (id) => set({ selectedTaskId: id }), - setFilter: (filter) => set({ filter }), - setShowCompleted: (show) => set({ showCompleted: show }), + setFilter: (filter) => { + if (get().filter === filter) return; + set({ filter }); + void taskHooks.onTaskFilterChange.emit({ filter, showCompleted: get().showCompleted }); + }, + setShowCompleted: (show) => { + if (get().showCompleted === show) return; + set({ showCompleted: show }); + void taskHooks.onTaskFilterChange.emit({ filter: get().filter, showCompleted: show }); + }, fetchTasks: async (client, calendarIds) => { debug.log('tasks', 'TaskStore/fetchTasks start', { calendarIds: calendarIds || 'all' }); @@ -50,6 +61,7 @@ export const useTaskStore = create((set, get) => ({ }); }); set({ tasks, isLoading: false }); + if (taskHooks.onTasksLoaded.size > 0) void taskHooks.onTasksLoaded.emit(tasks.map(taskToView)); } catch (error) { debug.error('TaskStore/fetchTasks failed', error); set({ isLoading: false, error: 'Failed to fetch tasks' }); @@ -58,32 +70,44 @@ export const useTaskStore = create((set, get) => ({ createTask: async (client, task) => { debug.log('tasks', 'TaskStore/createTask', task); + if (!(await taskHooks.onBeforeTaskCreate.intercept(taskToView({ id: '', ...task } as CalendarTask)))) return null; const created = await client.createCalendarTask(task); debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); set({ tasks: [...get().tasks, created] }); + void taskHooks.onTaskCreate.emit(taskToView(created)); return created; }, updateTask: async (client, id, updates) => { + if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return; + const before = get().tasks.find(t => t.id === id); const newId = await client.updateCalendarTask(id, updates); if (newId && newId !== id) { // Moved into a calendar of another account: the server recreated it // under a new id, so reload rather than patch the old entry. (#1204) set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId }); await get().fetchTasks(client); + const moved = get().tasks.find(t => t.id === newId) + ?? ({ ...before, ...updates, id: newId } as CalendarTask); + void taskHooks.onTaskUpdate.emit(taskToView(moved)); return; } + const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask; set({ - tasks: get().tasks.map(t => t.id === id ? { ...t, ...updates, updated: new Date().toISOString() } : t), + tasks: get().tasks.map(t => t.id === id ? updated : t), }); + void taskHooks.onTaskUpdate.emit(taskToView(updated)); }, deleteTask: async (client, id) => { + const title = get().tasks.find(t => t.id === id)?.title || ''; + if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return; await client.deleteCalendarTask(id); set({ tasks: get().tasks.filter(t => t.id !== id), selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId, }); + void taskHooks.onTaskDelete.emit({ taskId: id, title }); }, toggleTaskComplete: async (client, task) => { @@ -95,6 +119,9 @@ export const useTaskStore = create((set, get) => ({ set({ tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t), }); + // A toggle is reported as onTaskToggleComplete only, not also as + // onTaskUpdate, so a plugin that listens to both sees each change once. + void taskHooks.onTaskToggleComplete.emit({ taskId: task.id, complete: newProgress === 'completed' }); }, clearTasks: () => set({ tasks: [], selectedTaskId: null, error: null }),