mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when tasks are loaded, created, changed, checked off, deleted or filtered, and let them stop changes
This commit is contained in:
@@ -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> = {}): 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<CalendarTask>) => 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+31
-4
@@ -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<void>;
|
||||
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask>;
|
||||
/** Resolves to null when a plugin stopped the create. */
|
||||
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask | null>;
|
||||
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<void>;
|
||||
deleteTask: (client: IJMAPClient, id: string) => Promise<void>;
|
||||
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
|
||||
@@ -33,8 +36,16 @@ export const useTaskStore = create<TaskStore>((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<TaskStore>((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<TaskStore>((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<TaskStore>((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 }),
|
||||
|
||||
Reference in New Issue
Block a user