mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix: show the built-in task list when a plugin's task view does not draw #1498
This commit is contained in:
@@ -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 />);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user