From bd05ee95382443edfd137aeb8b9d1b6b219df661 Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Sat, 31 Oct 2026 13:24:34 +0100 Subject: [PATCH] feat(plugins): the calendar's task view drawn by a plugin A frame slot `calendar-task-view` draws the task view below Bulwark's toolbar in place of its list (props: the toolbar's filter, the selected calendars); `ui.openCalendar({ view })` opens a calendar view and `ui.openTask(id)` a task in Bulwark's editor. --- components/calendar/calendar-app.tsx | 66 ++++++++++----- .../__tests__/plugin-task-view.test.tsx | 83 +++++++++++++++++++ components/plugins/plugin-task-view.tsx | 45 ++++++++++ .../plugin-actions-proxy-api.test.ts | 74 +++++++++++++++++ lib/plugin-sandbox/calendar-navigation.ts | 55 ++++++++++++ lib/plugin-sandbox/host-api.ts | 10 +++ lib/plugin-sandbox/open-task.ts | 52 ++++++++++++ lib/plugin-sandbox/page-navigation.ts | 11 ++- lib/plugin-sandbox/runtime.tsx | 18 ++++ lib/plugin-sandbox/slot-layout.ts | 2 +- lib/plugin-slot-catalog.ts | 1 + 11 files changed, 394 insertions(+), 23 deletions(-) create mode 100644 components/plugins/__tests__/plugin-task-view.test.tsx create mode 100644 components/plugins/plugin-task-view.tsx create mode 100644 lib/plugin-sandbox/calendar-navigation.ts create mode 100644 lib/plugin-sandbox/open-task.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 2e237ad33..01df6c83b 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -30,6 +30,8 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view"; import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice"; import { TaskToolbar } from "@/components/calendar/task-toolbar"; +import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view"; +import { onOpenTask } from "@/lib/plugin-sandbox/open-task"; import { TaskModal } from "@/components/calendar/task-modal"; import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; @@ -758,6 +760,15 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setShowTaskModal(true); }), [closeEventPanelForTask, whenEditorsDiscarded]); + // A plugin drawing the task view opens a task in the editor + // (api.ui.openTask), as a click in Bulwark's list does. + useEffect(() => onOpenTask((taskId) => { + const task = useTaskStore.getState().tasks.find((t) => t.id === taskId); + if (!task) return false; + openEditTaskModal(task); + return true; + }), [openEditTaskModal]); + const handleToggleTaskComplete = useCallback((task: CalendarTask) => { if (!client) return; // The checkbox shows the result; only a failure needs saying. @@ -1764,6 +1775,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = return withCompany; }, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]); + // The plugin drawing the task view's list, if one offers it. + const pluginTaskView = usePluginTaskView(); // The tasks the task list shows; plugin buttons in its toolbar act on them. const listedTaskIds = useMemo( () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id), @@ -1981,27 +1994,38 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = taskIds={listedTaskIds} compact={isMobile} /> - { - // Into a calendar that takes it: the default one, else the - // first the user may write to. - const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0]; - if (!client || !target) { - toast.error(t("notifications.task_save_error")); - return; - } - taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } }) - .then((created) => { if (created) toast.success(t("notifications.task_created")); }) - .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error")))); - }} - /> + {/* A plugin may draw the list itself (the calendar-task-view + slot); the toolbar above stays Bulwark's. */} + {pluginTaskView ? ( + + ) : ( + { + // Into a calendar that takes it: the default one, else the + // first the user may write to. + const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0]; + if (!client || !target) { + toast.error(t("notifications.task_save_error")); + return; + } + taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } }) + .then((created) => { if (created) toast.success(t("notifications.task_created")); }) + .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error")))); + }} + /> + )} ); } diff --git a/components/plugins/__tests__/plugin-task-view.test.tsx b/components/plugins/__tests__/plugin-task-view.test.tsx new file mode 100644 index 000000000..69e59a383 --- /dev/null +++ b/components/plugins/__tests__/plugin-task-view.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), +})); +vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) })); + +const { PluginTaskView, usePluginTaskView } = await import('../plugin-task-view'); +const registry = await import('@/lib/plugin-sandbox/registry'); + +function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions'] } = {}) { + const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read']; + const plugin: InstalledPlugin = { + id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension', + permissions, grantedPermissions: permissions, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; + registry.register({ + plugin, + code: '', + tier: 'untrusted', + background: { evaluateShouldShow: vi.fn(async () => true) } as never, + slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: false, ...(opts.fill ? { fill: true } : {}) }], + hookDisposables: [], + }); +} + +/** The task view's list area as calendar-app draws it. */ +function TaskArea({ calendarIds = ['c1', 'c2'] }: { calendarIds?: string[] }) { + const view = usePluginTaskView(); + if (!view) return
; + return ; +} + +beforeEach(() => { createSlotInstance.mockClear(); }); + +afterEach(() => { + cleanup(); + for (const p of registry.all()) registry.deregister(p.plugin.id); +}); + +describe('calendar-task-view', () => { + it('leaves Bulwark\'s list while no plugin offers the slot', () => { + offer('other', { slot: 'task-detail-section' }); + const { getByTestId, container } = render(); + expect(createSlotInstance).not.toHaveBeenCalled(); + expect(getByTestId('bulwark-task-list')).toBeInTheDocument(); + expect(container.querySelector('[data-plugin-task-view]')).toBeNull(); + }); + + it('draws the first plugin offering it, filled, with the toolbar\'s filter and the shown calendars', async () => { + offer('follow-ups', { fill: true }); + offer('second'); + const { container, queryByTestId } = render(); + expect(queryByTestId('bulwark-task-list')).toBeNull(); + expect(container.querySelectorAll('[data-plugin-task-view]')).toHaveLength(1); + expect(container.querySelector('[data-plugin-task-view]')).toHaveAttribute('data-plugin-task-view', 'follow-ups'); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(1)); + const opts = createSlotInstance.mock.calls[0][0] as { slot: string; extraProps: Record; fill?: boolean }; + expect(opts.slot).toBe('calendar-task-view'); + expect(opts.fill).toBe(true); + expect(opts.extraProps).toEqual({ filter: 'pending', calendarIds: ['c1', 'c2'] }); + expect(container.querySelector('[data-plugin-iframe-fill]')).not.toBeNull(); + }); + + it('hands the calendars only to a plugin that reads tasks', async () => { + offer('looks', { permissions: ['ui:tasks'] }); + render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record }; + expect(opts.extraProps).toEqual({ filter: 'pending' }); + }); + + it('lets the area scroll for a plugin that does not fill it', async () => { + offer('plain'); + const { container } = render(); + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto'); + }); +}); diff --git a/components/plugins/plugin-task-view.tsx b/components/plugins/plugin-task-view.tsx new file mode 100644 index 000000000..7684ac3a3 --- /dev/null +++ b/components/plugins/plugin-task-view.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useMemo } from 'react'; +import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; +import type { TaskViewFilter } from '@/stores/task-store'; +import { cn } from '@/lib/utils'; +import { PluginIframeSlot } from './plugin-iframe-slot'; + +// The `calendar-task-view` slot: a plugin drawing the calendar's task view +// below its toolbar, in place of Bulwark's task list. One plugin at a time, +// the first offering it (by its offer's order, then the plugins' order); +// none, and the calendar shows its own list. + +/** The plugin drawing the task view, or null when none offers it. */ +export function usePluginTaskView(): { pluginId: string; fill: boolean } | null { + const offers = usePluginSlotOffers('calendar-task-view'); + const first = offers[0]; + return useMemo(() => (first ? { pluginId: first.pluginId, fill: !!first.fill } : null), [first]); +} + +/** + * The plugin's frame in the area below the task toolbar: filled when the + * plugin asks for it (it scrolls inside), else the area scrolls. Its props: + * the toolbar's filter and the calendars ticked in the sidebar. + */ +export function PluginTaskView({ pluginId, fill, filter, calendarIds }: { + pluginId: string; + fill: boolean; + filter: TaskViewFilter; + calendarIds: readonly string[]; +}) { + // One array per set of ids, so the frame gets no update while they stay. + const idsKey = calendarIds.join('\u0000'); + const ids = useMemo(() => (idsKey ? idsKey.split('\u0000') : []), [idsKey]); + const extraProps = useMemo(() => ({ filter, calendarIds: ids }), [filter, ids]); + return ( +
+ +
+ ); +} diff --git a/lib/__tests__/plugin-actions-proxy-api.test.ts b/lib/__tests__/plugin-actions-proxy-api.test.ts index 6202f506b..127418b5b 100644 --- a/lib/__tests__/plugin-actions-proxy-api.test.ts +++ b/lib/__tests__/plugin-actions-proxy-api.test.ts @@ -18,6 +18,9 @@ import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels'; import { allPages, resetPages } from '../plugin-sandbox/pages'; import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests'; import { onPageNavigation, resetPageNavigation } from '../plugin-sandbox/page-navigation'; +import { onOpenTask, resetOpenTask } from '../plugin-sandbox/open-task'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; import type { StreamSink } from '../plugin-sandbox/proxy-client'; import '../plugin-sandbox/loader'; @@ -169,6 +172,77 @@ describe('day labels and pages', () => { await expect(dispatchApiCall(paged, 'ui.openPage', ['files'])).resolves.toBe(false); }); }); + + describe('ui.openTask', () => { + beforeEach(() => { resetOpenTask(); }); + + it('opens the task in the calendar\'s editor and says whether it did', async () => { + const seen: string[] = []; + onOpenTask((id) => { seen.push(id); return id === 't1'; }); + const tasky = plugin({ id: 'ot1', permissions: ['ui:tasks'] }); + await expect(dispatchApiCall(tasky, 'ui.openTask', ['t1'])).resolves.toBe(true); + await expect(dispatchApiCall(plugin({ id: 'ot1', permissions: ['tasks:read'] }), 'ui.openTask', ['gone'])).resolves.toBe(false); + expect(seen).toEqual(['t1', 'gone']); + }); + + it('answers false while no calendar is on screen', async () => { + await expect(dispatchApiCall(plugin({ id: 'ot2', permissions: ['ui:tasks'] }), 'ui.openTask', ['t1'])).resolves.toBe(false); + }); + + it('needs ui:tasks or tasks:read and a task id', async () => { + const seen: string[] = []; + onOpenTask((id) => { seen.push(id); return true; }); + await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['calendar:read'] }), 'ui.openTask', ['t1'])).rejects.toThrow(/lacks permission/); + await expect(dispatchApiCall(plugin({ id: 'ot3', permissions: ['ui:tasks'] }), 'ui.openTask', [42])).rejects.toThrow(/taskId/); + expect(seen).toEqual([]); + }); + }); + + describe('ui.openCalendar', () => { + const seen: string[] = []; + beforeEach(() => { + resetPageNavigation(); + seen.length = 0; + useSettingsStore.setState({ enableCalendarTasks: true }); + useCalendarStore.setState({ viewMode: 'month' }); + }); + + it('opens the task view: the calendar\'s view set, the tab moved to its permalink', async () => { + onPageNavigation((href) => { seen.push(href); }); + await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(true); + expect(useCalendarStore.getState().viewMode).toBe('tasks'); + await expect(dispatchApiCall(plugin({ id: 'tv1', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'week' }])).resolves.toBe(true); + expect(seen).toEqual(['/calendar/tasks', '/calendar/week']); + }); + + it('needs the view\'s permission and refuses any other view', async () => { + onPageNavigation((href) => { seen.push(href); }); + await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['calendar:read'] }), 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/lacks permission/); + await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'month' }])).rejects.toThrow(/lacks permission/); + await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'settings' }])).rejects.toThrow(/view must be one of/); + await expect(dispatchApiCall(plugin({ id: 'tv2', permissions: ['ui:tasks'] }), 'ui.openCalendar', [{ view: 'tasks', date: 'x' }])).rejects.toThrow(/unknown property/); + expect(seen).toEqual([]); + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); + + it('answers false while nothing can move the tab or the user has tasks off', async () => { + const tasky = plugin({ id: 'tv3', permissions: ['ui:tasks'] }); + await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false); + onPageNavigation((href) => { seen.push(href); }); + useSettingsStore.setState({ enableCalendarTasks: false }); + await expect(dispatchApiCall(tasky, 'ui.openCalendar', [{ view: 'tasks' }])).resolves.toBe(false); + expect(seen).toEqual([]); + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); + + it('moves the tab at most 10 times a minute', async () => { + onPageNavigation((href) => { seen.push(href); }); + const busy = plugin({ id: 'tv4', permissions: ['ui:tasks'] }); + for (let i = 0; i < 10; i++) await dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }]); + await expect(dispatchApiCall(busy, 'ui.openCalendar', [{ view: 'tasks' }])).rejects.toThrow(/a minute/); + expect(seen).toHaveLength(10); + }); + }); }); describe('proxy.fetch', () => { diff --git a/lib/plugin-sandbox/calendar-navigation.ts b/lib/plugin-sandbox/calendar-navigation.ts new file mode 100644 index 000000000..c69fc4a36 --- /dev/null +++ b/lib/plugin-sandbox/calendar-navigation.ts @@ -0,0 +1,55 @@ +// A plugin moving the tab to a view of the calendar (api.ui.openCalendar), +// e.g. a count of due tasks opening the task view, which a plugin may draw +// itself (the calendar-task-view slot). The view is set in the calendar's +// store first, so a calendar already on screen switches in place, then the +// tab moves to the view's permalink (/calendar/) through the same +// router handler as api.ui.openPage. Only the calendar's own views: a +// plugin can never send the tab anywhere else. + +import type { InstalledPlugin, Permission } from '../plugin-types'; +import type { CalendarViewMode } from '@/stores/calendar-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { buildCalendarPath } from '../deep-links'; +import { readObject, withinRate } from './host-api-input'; +import { pluginHasPermission } from './permissions'; +import { canNavigateInApp, navigateInApp } from './page-navigation'; + +const METHOD = 'ui.openCalendar'; + +/** The views a plugin may open, each with the permissions of which one is enough. */ +const VIEW_PERMISSIONS: Readonly> = { + month: ['ui:calendar-action', 'calendar:read'], + week: ['ui:calendar-action', 'calendar:read'], + day: ['ui:calendar-action', 'calendar:read'], + agenda: ['ui:calendar-action', 'calendar:read'], + tasks: ['ui:tasks', 'tasks:read'], +}; + +/** At most this many moves a minute, so a plugin cannot keep pulling the tab away. */ +export const CALENDAR_OPENS_PER_MINUTE = 10; + +/** + * Checks the request and opens the view: true when the tab went there, + * false when nothing could take it (no router yet, or the task view while + * the user has tasks turned off), or an error the plugin sees. + */ +export function requestCalendarView(plugin: InstalledPlugin, input: unknown): boolean { + const data = readObject(input, METHOD, 'options', ['view']); + const view = data.view; + if (typeof view !== 'string' || !Object.prototype.hasOwnProperty.call(VIEW_PERMISSIONS, view)) { + throw new Error(`${METHOD}: view must be one of ${Object.keys(VIEW_PERMISSIONS).join(', ')}`); + } + const mode = view as CalendarViewMode; + const perms = VIEW_PERMISSIONS[mode]; + if (!perms.some((perm) => pluginHasPermission(plugin, perm))) { + throw new Error(`Plugin "${plugin.id}" lacks permission "${perms.join('" or "')}"`); + } + if (mode === 'tasks' && !useSettingsStore.getState().enableCalendarTasks) return false; + if (!canNavigateInApp()) return false; + if (!withinRate('open-calendar', plugin.id, CALENDAR_OPENS_PER_MINUTE)) { + throw new Error(`${METHOD}: at most ${CALENDAR_OPENS_PER_MINUTE} a minute`); + } + useCalendarStore.getState().setViewMode(mode); + return navigateInApp(buildCalendarPath({ view: mode, date: null })); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 76e9a50e7..6ab1f2b0c 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -69,6 +69,8 @@ import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAcco import { setSectionTitle } from './section-titles'; import { requestPanel } from './panel-requests'; import { requestPageNavigation } from './page-navigation'; +import { requestCalendarView } from './calendar-navigation'; +import { requestOpenTask } from './open-task'; import { requestOpenEmail } from './open-email'; import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerGetSnapshot, doComposerReadAttachment, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer'; import { wkdAllowed } from '../outgoing-hosts'; @@ -254,6 +256,12 @@ export const PERM_PER_METHOD: Readonly> = { 'ui.setPages': 'ui:app-page', // Move the tab to one of the plugin's own pages (never anywhere else). 'ui.openPage': 'ui:app-page', + // A view of the calendar; the view's own permission is checked per call + // (the task view: ui:tasks or tasks:read; the others: ui:calendar-action + // or calendar:read). + 'ui.openCalendar': null, + // A task in Bulwark's task editor; ui:tasks or tasks:read, checked per call. + 'ui.openTask': null, // Opening a message shows its content, as reading it does. 'ui.openEmail': 'email:read', 'ui.setSectionTitle': 'ui:sidebar-widget', @@ -2035,6 +2043,8 @@ export async function dispatchApiCall( case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined; case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined; case 'ui.openPage': return requestPageNavigation(plugin.id, args[0], args[1]); + case 'ui.openCalendar': return requestCalendarView(plugin, args[0]); + case 'ui.openTask': return requestOpenTask(plugin, args[0]); case 'ui.openEmail': return requestOpenEmail(plugin, args[0]); case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined; case 'ui.setPanel': { diff --git a/lib/plugin-sandbox/open-task.ts b/lib/plugin-sandbox/open-task.ts new file mode 100644 index 000000000..ab0f17a39 --- /dev/null +++ b/lib/plugin-sandbox/open-task.ts @@ -0,0 +1,52 @@ +// A plugin opening one of the user's tasks in Bulwark's task editor +// (api.ui.openTask): what a click on a task in the calendar's task list +// does. A plugin drawing the task view itself (the calendar-task-view slot) +// lists the tasks but cannot open one otherwise. The calendar opens it +// through the handler it sets while it is on screen; without one (another +// page) the request resolves to false. A task the calendar has not loaded +// resolves to false too. + +import type { InstalledPlugin, Permission } from '../plugin-types'; +import { readId, withinRate } from './host-api-input'; +import { pluginHasPermission } from './permissions'; + +const METHOD = 'ui.openTask'; + +/** Either one lets a plugin open a task. */ +export const OPEN_TASK_PERMISSIONS: readonly Permission[] = ['ui:tasks', 'tasks:read']; + +/** At most this many a minute. */ +export const TASK_OPENS_PER_MINUTE = 20; + +/** Opens the task in the editor: true when it opened, false when the calendar does not have it. */ +export type OpenTaskHandler = (taskId: string) => boolean; + +// The calendars on screen, the latest mounted last; it takes the request. +const handlers: OpenTaskHandler[] = []; + +export function onOpenTask(handler: OpenTaskHandler): () => void { + handlers.push(handler); + return () => { + const at = handlers.lastIndexOf(handler); + if (at >= 0) handlers.splice(at, 1); + }; +} + +/** Checks the request and opens the task: true when the editor opened, else false. */ +export function requestOpenTask(plugin: InstalledPlugin, taskId: unknown): boolean { + if (!OPEN_TASK_PERMISSIONS.some((perm) => pluginHasPermission(plugin, perm))) { + throw new Error(`Plugin "${plugin.id}" lacks permission "${OPEN_TASK_PERMISSIONS.join('" or "')}"`); + } + const id = readId(taskId, METHOD, 'taskId'); + const handler = handlers.at(-1); + if (!handler) return false; + if (!withinRate('open-task', plugin.id, TASK_OPENS_PER_MINUTE)) { + throw new Error(`${METHOD}: at most ${TASK_OPENS_PER_MINUTE} a minute`); + } + return handler(id); +} + +/** Test helper. */ +export function resetOpenTask(): void { + handlers.length = 0; +} diff --git a/lib/plugin-sandbox/page-navigation.ts b/lib/plugin-sandbox/page-navigation.ts index 312f7b733..8b64c0e0f 100644 --- a/lib/plugin-sandbox/page-navigation.ts +++ b/lib/plugin-sandbox/page-navigation.ts @@ -39,7 +39,16 @@ export function pagePathHref(pluginId: string, pageId: unknown, path: unknown): /** Checks the request and moves the tab: true when it went, false when nothing could take it. */ export function requestPageNavigation(pluginId: string, pageId: unknown, path: unknown): boolean { - const href = pagePathHref(pluginId, pageId, path); + return navigateInApp(pagePathHref(pluginId, pageId, path)); +} + +/** Whether something can move the tab now (the router is mounted). */ +export function canNavigateInApp(): boolean { + return handler !== null; +} + +/** Moves the tab to an in-app href the host built: false while nothing can. */ +export function navigateInApp(href: string): boolean { if (!handler) return false; handler(href); return true; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index daf0ee0db..97dae8229 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -741,6 +741,24 @@ function buildPluginApi(manifest: PluginManifest) { */ openPage: (pageId: string, path?: string) => callApi('ui.openPage', [pageId, path]) as Promise, + /** + * Moves this tab to a view of the calendar: 'month', 'week', 'day', + * 'agenda' (needs ui:calendar-action or calendar:read) or 'tasks', the + * task view (needs ui:tasks or tasks:read), which a plugin offering the + * calendar-task-view slot draws. Resolves to false when the app could + * not take it (not ready yet, or the user has tasks turned off). At + * most 10 a minute. + */ + openCalendar: (options: { view: 'month' | 'week' | 'day' | 'agenda' | 'tasks' }) => + callApi('ui.openCalendar', [options]) as Promise, + /** + * Opens one of the user's tasks (an id from tasks.list) in Bulwark's + * task editor, as a click on it in the calendar's task list does: for + * a plugin drawing the task view (calendar-task-view). Resolves to + * false when the calendar is not on screen or has not loaded the task. + * Needs ui:tasks or tasks:read; at most 20 a minute. + */ + openTask: (taskId: string) => callApi('ui.openTask', [taskId]) as Promise, /** * Opens a message in the reader of this tab, as a click on it in the * list does: in place on the mail page, in an email tab in the Pro diff --git a/lib/plugin-sandbox/slot-layout.ts b/lib/plugin-sandbox/slot-layout.ts index d3dedb2e6..6cb866bcf 100644 --- a/lib/plugin-sandbox/slot-layout.ts +++ b/lib/plugin-sandbox/slot-layout.ts @@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null { * can pin controls at the bottom, like a chat input. Elsewhere a frame grows * with its content. */ -export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page']; +export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page', 'calendar-task-view']; export function isFillableSlot(slot: string): boolean { return FILLABLE_SLOTS.includes(slot); diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 043667986..4ddcb7e53 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -106,6 +106,7 @@ export const SLOT_CATALOG = { // ─── Tasks ───────────────────────────────────────────────── 'task-detail-section': S('tasks', 'frame', ['ui:tasks'], { task: 'tasks:read' }, 'a section in a task\'s details'), 'task-list-actions': S('tasks', 'action', ['ui:tasks'], { taskIds: 'tasks:read' }, 'buttons in the task list toolbar', true), + 'calendar-task-view': S('tasks', 'frame', ['ui:tasks'], { filter: null, calendarIds: 'tasks:read' }, 'the calendar\'s task view (Calendar › Tasks) below its toolbar, in place of Bulwark\'s task list; the toolbar (filter, new task, task-list-actions) stays; one plugin at a time, the first offering it; `filter` is the toolbar\'s filter ("all" | "pending" | "completed" | "overdue"), `calendarIds` the calendars ticked in the sidebar; `slots[\'calendar-task-view\'].fill = true` makes the frame as tall as the area below the toolbar, so the plugin scrolls inside it; api.ui.openCalendar({ view: \'tasks\' }) opens the view'), // ─── Files ───────────────────────────────────────────────── 'file-actions': S('files', 'action', ['ui:files'], { files: 'files:read' }, 'entries in a file\'s menu and the selection toolbar'),