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.
This commit is contained in:
Jacob Zeitz
2026-11-01 20:07:18 +01:00
parent 3f3dfebb84
commit bd05ee9538
11 changed files with 394 additions and 23 deletions
+45 -21
View File
@@ -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}
/>
<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"))));
}}
/>
{/* A plugin may draw the list itself (the calendar-task-view
slot); the toolbar above stays Bulwark's. */}
{pluginTaskView ? (
<PluginTaskView
pluginId={pluginTaskView.pluginId}
fill={pluginTaskView.fill}
filter={taskStore.filter}
calendarIds={selectedCalendarIds}
/>
) : (
<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"))));
}}
/>
)}
</div>
);
}
@@ -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 <div data-testid="bulwark-task-list" />;
return <PluginTaskView pluginId={view.pluginId} fill={view.fill} filter="pending" calendarIds={calendarIds} />;
}
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(<TaskArea />);
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(<TaskArea />);
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<string, unknown>; 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(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = createSlotInstance.mock.calls[0][0] as { extraProps: Record<string, unknown> };
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(<TaskArea />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
expect(container.querySelector('[data-plugin-task-view]')?.className).toContain('overflow-y-auto');
});
});
+45
View File
@@ -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 (
<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>
);
}
@@ -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', () => {
+55
View File
@@ -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/<view>) 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<Record<CalendarViewMode, readonly Permission[]>> = {
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 }));
}
+10
View File
@@ -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<Record<string, Permission | null>> = {
'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': {
+52
View File
@@ -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;
}
+10 -1
View File
@@ -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;
+18
View File
@@ -741,6 +741,24 @@ function buildPluginApi(manifest: PluginManifest) {
*/
openPage: (pageId: string, path?: string) =>
callApi('ui.openPage', [pageId, path]) as Promise<boolean>,
/**
* 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<boolean>,
/**
* 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<boolean>,
/**
* 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
+1 -1
View File
@@ -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);
+1
View File
@@ -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'),