diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 01df6c83b..9c7ad076a 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1981,7 +1981,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onCreateEvent={() => openCreateModal()} /> ); - case "tasks": + case "tasks": { + const taskList = ( + { + // 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")))); + }} + /> + ); return (
{taskStore.error === TASKS_LOAD_ERROR && ( @@ -1995,39 +2018,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = compact={isMobile} /> {/* A plugin may draw the list itself (the calendar-task-view - slot); the toolbar above stays Bulwark's. */} + slot), with Bulwark's list back while its frame is not + drawn; 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")))); - }} - /> - )} + ) : taskList}
); + } } })(); diff --git a/components/plugins/__tests__/plugin-task-view.test.tsx b/components/plugins/__tests__/plugin-task-view.test.tsx index 69e59a383..fc5988e2f 100644 --- a/components/plugins/__tests__/plugin-task-view.test.tsx +++ b/components/plugins/__tests__/plugin-task-view.test.tsx @@ -2,7 +2,8 @@ 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() })); +const slotInstance = (initPromise: Promise = Promise.resolve({})) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn(), initPromise }); +const createSlotInstance = vi.fn((..._args: unknown[]) => slotInstance()); vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ createSlotInstance: (...args: unknown[]) => createSlotInstance(...args), })); @@ -11,7 +12,7 @@ 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'] } = {}) { +function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: InstalledPlugin['permissions']; shouldShow?: boolean } = {}) { const permissions = opts.permissions ?? ['ui:tasks', 'tasks:read']; const plugin: InstalledPlugin = { id, name: id, version: '1.0.0', author: '', description: '', type: 'ui-extension', @@ -22,8 +23,8 @@ function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: 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 } : {}) }], + background: { evaluateShouldShow: vi.fn(async () => opts.shouldShow ?? true) } as never, + slotOffers: [{ name: (opts.slot ?? 'calendar-task-view') as never, order: 100, hasShouldShow: opts.shouldShow !== undefined, ...(opts.fill ? { fill: true } : {}) }], hookDisposables: [], }); } @@ -31,8 +32,9 @@ function offer(id: string, opts: { slot?: string; fill?: boolean; permissions?: /** 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 ; + const list =
; + if (!view) return list; + return ; } beforeEach(() => { createSlotInstance.mockClear(); }); @@ -64,6 +66,8 @@ describe('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(); + await Promise.resolve(); + expect(queryByTestId('bulwark-task-list')).toBeNull(); }); it('hands the calendars only to a plugin that reads tasks', async () => { @@ -74,6 +78,23 @@ describe('calendar-task-view', () => { expect(opts.extraProps).toEqual({ filter: 'pending' }); }); + it("gives Bulwark's list back when the plugin's shouldShow says no", async () => { + offer('picky', { shouldShow: false }); + const { findByTestId, container } = render(); + expect(await findByTestId('bulwark-task-list')).toBeInTheDocument(); + expect(createSlotInstance).not.toHaveBeenCalled(); + expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden'); + }); + + it("gives Bulwark's list back when the plugin's frame fails to start", async () => { + createSlotInstance.mockImplementationOnce(() => slotInstance(Promise.reject(new Error('boom')))); + offer('broken'); + const { findByTestId, container } = render(); + expect(await findByTestId('bulwark-task-list')).toBeInTheDocument(); + expect(createSlotInstance).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-plugin-task-view]')).toHaveClass('hidden'); + }); + it('lets the area scroll for a plugin that does not fill it', async () => { offer('plain'); const { container } = render(); diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index e02b9bcc9..1865ce624 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -25,6 +25,13 @@ interface Props { extraProps?: Record; /** The frame fills its panel and scrolls inside, instead of growing with its content. */ fill?: boolean; + /** + * Told whether the frame is up: false when the plugin's shouldShow says no + * or its frame fails to start, true once it has started. For a slot that + * stands in for a part of Bulwark (calendar-task-view), so the caller can + * draw its own part again. + */ + onShownChange?: (shown: boolean) => void; } interface FloatingDropdownState { @@ -38,9 +45,11 @@ interface FloatingDropdownState { ariaLabel?: string; } -export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: Props) { +export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false, onShownChange }: Props) { const wrapperRef = useRef(null); const instanceRef = useRef(null); + const onShownChangeRef = useRef(onShownChange); + useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]); const [height, setHeight] = useState(0); const [width, setWidth] = useState(undefined); const [floatingDropdown, setFloatingDropdown] = useState(null); @@ -80,6 +89,10 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P return () => { cancelled = true; }; }, [pluginId, slot, props]); + useEffect(() => { + if (show === false) onShownChangeRef.current?.(false); + }, [show]); + // Spawn / tear down the slot iframe. useEffect(() => { if (show !== true) return; @@ -130,7 +143,17 @@ export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: P }, }); instanceRef.current = inst; + // A frame that fails to start stays blank; say so to a caller that can + // draw something in its place. + let live = true; + if (onShownChangeRef.current) { + inst.initPromise.then( + () => { if (live) onShownChangeRef.current?.(true); }, + () => { if (live) onShownChangeRef.current?.(false); }, + ); + } return () => { + live = false; try { inst.destroy(); } catch { /* ignore */ } instanceRef.current = null; setFloatingDropdown(null); diff --git a/components/plugins/plugin-task-view.tsx b/components/plugins/plugin-task-view.tsx index 7684ac3a3..eb5fdd6bd 100644 --- a/components/plugins/plugin-task-view.tsx +++ b/components/plugins/plugin-task-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; import type { TaskViewFilter } from '@/stores/task-store'; import { cn } from '@/lib/utils'; @@ -9,7 +9,8 @@ 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. +// none, and the calendar shows its own list. So does a plugin whose +// shouldShow says no or whose frame fails to start. /** The plugin drawing the task view, or null when none offers it. */ export function usePluginTaskView(): { pluginId: string; fill: boolean } | null { @@ -21,25 +22,35 @@ export function usePluginTaskView(): { pluginId: string; fill: boolean } | null /** * 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. + * the toolbar's filter and the calendars ticked in the sidebar. `fallback` + * (Bulwark's list) stands in while the frame is not drawn. */ -export function PluginTaskView({ pluginId, fill, filter, calendarIds }: { +export function PluginTaskView({ pluginId, fill, filter, calendarIds, fallback }: { pluginId: string; fill: boolean; filter: TaskViewFilter; calendarIds: readonly string[]; + fallback: ReactNode; }) { // 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]); + // The plugin whose frame is not drawn. The frame stays mounted (hidden), + // so a shouldShow that says yes for another filter takes the view back. + const [notShownBy, setNotShownBy] = useState(null); + const notShown = notShownBy === pluginId; + const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]); return ( -
- -
+ <> + {notShown && fallback} +
+ +
+ ); }