mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user