fix: show the built-in task list when a plugin's task view does not draw #1498

This commit is contained in:
Linus Rath
2026-11-02 20:19:43 +01:00
parent bd05ee9538
commit 68dd11a30f
4 changed files with 102 additions and 43 deletions
+29 -25
View File
@@ -1981,7 +1981,30 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onCreateEvent={() => openCreateModal()}
/>
);
case "tasks":
case "tasks": {
const taskList = (
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// 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 (
<div className="flex flex-col h-full">
{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 ? (
<PluginTaskView
pluginId={pluginTaskView.pluginId}
fill={pluginTaskView.fill}
filter={taskStore.filter}
calendarIds={selectedCalendarIds}
fallback={taskList}
/>
) : (
<TaskListView
tasks={taskStore.tasks}
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
// 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}
</div>
);
}
}
})();
@@ -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<unknown> = 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 <div data-testid="bulwark-task-list" />;
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} />;
const list = <div data-testid="bulwark-task-list" />;
if (!view) return list;
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} fallback={list} />;
}
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(<TaskArea />);
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(<TaskArea />);
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(<TaskArea />);
+24 -1
View File
@@ -25,6 +25,13 @@ interface Props {
extraProps?: Record<string, unknown>;
/** 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<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const onShownChangeRef = useRef(onShownChange);
useEffect(() => { onShownChangeRef.current = onShownChange; }, [onShownChange]);
const [height, setHeight] = useState<number>(0);
const [width, setWidth] = useState<number | undefined>(undefined);
const [floatingDropdown, setFloatingDropdown] = useState<FloatingDropdownState | null>(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);
+22 -11
View File
@@ -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<string | null>(null);
const notShown = notShownBy === pluginId;
const onShownChange = useCallback((shown: boolean) => setNotShownBy(shown ? null : pluginId), [pluginId]);
return (
<div
data-plugin-slot="calendar-task-view"
data-plugin-task-view={pluginId}
className={cn('min-h-0 flex-1', fill ? 'flex flex-col' : 'overflow-y-auto')}
>
<PluginIframeSlot key={pluginId} pluginId={pluginId} slot="calendar-task-view" extraProps={extraProps} fill={fill} />
</div>
<>
{notShown && fallback}
<div
data-plugin-slot="calendar-task-view"
data-plugin-task-view={pluginId}
className={cn('min-h-0 flex-1', fill ? 'flex flex-col' : 'overflow-y-auto', notShown && 'hidden')}
>
<PluginIframeSlot key={pluginId} pluginId={pluginId} slot="calendar-task-view" extraProps={extraProps} fill={fill} onShownChange={onShownChange} />
</div>
</>
);
}