mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge branch 'slots/apps' into feat/plugin-system-audit
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { CalendarWeekView } from '../calendar-week-view';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
import { MiniCalendar } from '../mini-calendar';
|
||||
import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces in the calendar: a section in an event's details and in
|
||||
// its form, and the labels and dots plugins put on days.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const calendars = [
|
||||
{ id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
const existing = {
|
||||
id: 'e1',
|
||||
uid: 'uid-1',
|
||||
title: 'Planning',
|
||||
start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetDayLabels();
|
||||
});
|
||||
|
||||
describe('calendar-event-detail', () => {
|
||||
it('shows the event in the detail popover', () => {
|
||||
offerSlots('p1', ['calendar-event-detail']);
|
||||
render(
|
||||
<EventDetailPopover
|
||||
event={existing}
|
||||
calendar={calendars[0]}
|
||||
anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSaveNote={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
|
||||
expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
|
||||
});
|
||||
|
||||
it('shows the event in the modal view, and not in the form', () => {
|
||||
offerSlots('p1', ['calendar-event-detail']);
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
|
||||
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when no plugin offers it', () => {
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar-event-form', () => {
|
||||
it('hands the plugin the form state of an edited event and follows the fields', async () => {
|
||||
offerSlots('p1', ['calendar-event-form']);
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
const frame = screen.getByTestId('plugin-frame-calendar-event-form');
|
||||
expect(framePropsOf(frame).event).toMatchObject({
|
||||
id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
|
||||
await waitFor(() => {
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a new event an empty id', () => {
|
||||
offerSlots('p1', ['calendar-event-form']);
|
||||
render(
|
||||
<EventModal
|
||||
calendars={calendars}
|
||||
defaultDate={new Date('2026-10-12T10:00:00')}
|
||||
defaultEndDate={new Date('2026-10-12T11:30:00')}
|
||||
defaultCalendarId="cal-1"
|
||||
onSave={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
|
||||
id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when no plugin offers it', () => {
|
||||
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar-day-header and mini-calendar-marks', () => {
|
||||
const week = {
|
||||
focus: { date: new Date(2026, 8, 9), nonce: 0 },
|
||||
windowKey: 'week:2026-09-07',
|
||||
rangeStart: new Date(2026, 8, 7),
|
||||
rangeEnd: new Date(2026, 8, 13),
|
||||
selectedDate: new Date(2026, 8, 9),
|
||||
events: [],
|
||||
calendars,
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
onCreateAtTime: vi.fn(),
|
||||
};
|
||||
|
||||
function headerOf(dayKey: string): HTMLElement {
|
||||
const header = document.querySelector<HTMLElement>(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
|
||||
if (!header) throw new Error(`no header for ${dayKey}`);
|
||||
return header;
|
||||
}
|
||||
|
||||
it('puts a label under the day name in the week view', () => {
|
||||
render(<CalendarWeekView {...week} />);
|
||||
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
|
||||
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
|
||||
|
||||
const label = headerOf('2026-09-09').querySelector<HTMLElement>('[data-plugin-surface="calendar-day-header"]');
|
||||
expect(label).toHaveTextContent('Feast day');
|
||||
expect(label?.title).toBe('A feast (Holidays)');
|
||||
expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts a label under the day name in the day view', () => {
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
|
||||
render(<CalendarDayView {...week} windowKey="day:2026-09-09" rangeStart={new Date(2026, 8, 9)} rangeEnd={new Date(2026, 8, 9)} />);
|
||||
|
||||
expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
|
||||
});
|
||||
|
||||
it('shows the label in the month cell header on desktop only', () => {
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
|
||||
const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
|
||||
const { unmount } = render(<CalendarMonthView {...month} />);
|
||||
|
||||
const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
|
||||
unmount();
|
||||
|
||||
render(<CalendarMonthView {...month} isMobile />);
|
||||
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks days with a dot in the small month calendar', () => {
|
||||
render(
|
||||
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
|
||||
);
|
||||
expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
|
||||
|
||||
act(() => setDayLabels('hol', 'Holidays', [
|
||||
{ date: '2026-09-09', dot: true, title: 'A feast' },
|
||||
{ date: '2026-09-10', text: 'Text only' },
|
||||
]));
|
||||
|
||||
const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
|
||||
expect(dots).toHaveLength(1);
|
||||
expect(dots[0].closest('button')).toHaveTextContent('9');
|
||||
expect(dots[0].closest('button')).toHaveTextContent('A feast');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { TaskModal } from '../task-modal';
|
||||
import { TaskToolbar } from '../task-toolbar';
|
||||
import { filterTasksForList } from '../task-list-view';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces for tasks: a section in a task's details and buttons in
|
||||
// the task list toolbar acting on the tasks the list shows.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[];
|
||||
|
||||
function makeTask(id: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Task',
|
||||
uid: `uid-${id}`,
|
||||
title: `Task ${id}`,
|
||||
description: '',
|
||||
due: null,
|
||||
showWithoutTime: true,
|
||||
priority: 0,
|
||||
progress: 'needs-action',
|
||||
calendarIds: { 'cal-1': true },
|
||||
alerts: null,
|
||||
...overrides,
|
||||
} as unknown as CalendarTask;
|
||||
}
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('task-detail-section', () => {
|
||||
it('shows an open task to the plugin', () => {
|
||||
offerSlots('p1', ['task-detail-section']);
|
||||
render(<TaskModal task={makeTask('t1', { priority: 5 })} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-task-detail-section');
|
||||
expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false });
|
||||
});
|
||||
|
||||
it('is not shown for a new task or without a plugin', () => {
|
||||
offerSlots('p1', ['task-detail-section']);
|
||||
const { unmount } = render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
|
||||
unmount();
|
||||
|
||||
clearSlotOffers();
|
||||
render(<TaskModal task={makeTask('t1')} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-list-actions', () => {
|
||||
const toolbar = {
|
||||
filter: 'all' as const,
|
||||
showCompleted: false,
|
||||
onFilterChange: vi.fn(),
|
||||
onShowCompletedChange: vi.fn(),
|
||||
onCreateTask: vi.fn(),
|
||||
};
|
||||
|
||||
it('puts plugin buttons after the create button and sends the listed task ids', async () => {
|
||||
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
|
||||
render(<TaskToolbar {...toolbar} taskIds={['t2', 't1']} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Plan my day' });
|
||||
expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] });
|
||||
});
|
||||
|
||||
it('draws icon-only buttons when compact', () => {
|
||||
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
|
||||
render(<TaskToolbar {...toolbar} taskIds={[]} compact />);
|
||||
expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
|
||||
});
|
||||
|
||||
it('renders nothing without a plugin', () => {
|
||||
render(<TaskToolbar {...toolbar} taskIds={['t1']} />);
|
||||
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the ids the task list shows, in its order', () => {
|
||||
const tasks = [
|
||||
makeTask('done', { progress: 'completed' }),
|
||||
makeTask('later', { due: '2030-01-02' }),
|
||||
makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
|
||||
makeTask('soon', { due: '2030-01-01' }),
|
||||
];
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView } from "@/components/calendar/task-list-view";
|
||||
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
@@ -1488,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
|
||||
// The tasks the task list shows; plugin buttons in its toolbar act on them.
|
||||
const listedTaskIds = useMemo(
|
||||
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
|
||||
[taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
|
||||
);
|
||||
|
||||
// Tasks on the month, week and day grids follow the calendar filter like
|
||||
// the events do (#1107).
|
||||
const calendarTasks = useMemo(() => {
|
||||
@@ -1679,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onFilterChange={taskStore.setFilter}
|
||||
onShowCompletedChange={taskStore.setShowCompleted}
|
||||
onCreateTask={openCreateTaskModal}
|
||||
taskIds={listedTaskIds}
|
||||
compact={isMobile}
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
|
||||
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -334,6 +335,7 @@ export function CalendarDayView({
|
||||
>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
|
||||
</div>
|
||||
|
||||
{hasAllDayArea && (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -379,6 +380,7 @@ export function CalendarMonthView({
|
||||
>
|
||||
{formatDayNumber(day)}
|
||||
</button>
|
||||
{!isMobile && <PluginDayLabel surface="calendar-day-header" date={key} className="min-w-0 flex-1" />}
|
||||
</div>
|
||||
{isMobile && !showChips ? (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
|
||||
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -436,6 +437,7 @@ export function CalendarWeekView({
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
<PluginDayLabel surface="calendar-day-header" date={dayKey(day)} className="w-full px-1 text-center" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -145,6 +147,7 @@ export function EventDetailPopover({
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
const meeting = useMemo(() => findMeetingLink(event), [event]);
|
||||
const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
|
||||
const virtualLocation = meeting?.uri ?? null;
|
||||
|
||||
// A URL opens as a link, a location that only names the service joins the
|
||||
@@ -580,6 +583,8 @@ export function EventDetailPopover({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="calendar-event-detail" className="w-full" extraProps={pluginProps} />
|
||||
</div>
|
||||
|
||||
{/* Quick Note */}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
calendarHooks,
|
||||
type CalendarEventFormSavePatch,
|
||||
} from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
@@ -446,6 +446,55 @@ export function EventModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
// calendar-event-detail: the event as plugins see it, in the read views.
|
||||
const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
|
||||
|
||||
// calendar-event-form: the form's current state in the same shape. Typing
|
||||
// would hand the plugin iframe new props on every key, so the view settles
|
||||
// for half a second first.
|
||||
const formEventView = useMemo<CalendarEventView>(() => {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
|
||||
const start = new Date(startStr);
|
||||
let duration = event?.duration ?? "PT1H";
|
||||
if (!isNaN(start.getTime())) {
|
||||
if (allDay) {
|
||||
const inclusiveEnd = new Date(`${endDate}T00:00:00`);
|
||||
if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
|
||||
} else {
|
||||
const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
|
||||
if (!isNaN(end.getTime())) duration = buildDuration(start, end);
|
||||
}
|
||||
}
|
||||
const base = event ? eventToView(event) : null;
|
||||
return {
|
||||
id: event?.id ?? "",
|
||||
uid: event?.uid ?? "",
|
||||
calendarId,
|
||||
title,
|
||||
description,
|
||||
start: startStr,
|
||||
duration,
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isAllDay: allDay,
|
||||
location,
|
||||
status: base?.status ?? "confirmed",
|
||||
participants: attendees.map((a) => {
|
||||
const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
|
||||
return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
|
||||
}),
|
||||
isRecurring: recurrence !== "none",
|
||||
keywords: base?.keywords ?? [],
|
||||
};
|
||||
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
|
||||
const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formEventView]);
|
||||
const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -823,6 +872,8 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1000,6 +1051,8 @@ export function EventModal({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1347,6 +1400,8 @@ export function EventModal({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
|
||||
@@ -199,6 +200,7 @@ export function MiniCalendar({
|
||||
{hasEvent && !selected && !today && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
|
||||
|
||||
const NONE: RegisteredDayLabel[] = [];
|
||||
const EMPTY_MAP = new Map<string, RegisteredDayLabel[]>();
|
||||
|
||||
/** The labels plugins put on one day (api.ui.setDayLabels). */
|
||||
export function useDayLabels(date: string): RegisteredDayLabel[] {
|
||||
const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
|
||||
return byDate.get(date) ?? NONE;
|
||||
}
|
||||
|
||||
function tooltip(labels: RegisteredDayLabel[]): string {
|
||||
return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
|
||||
}
|
||||
|
||||
interface PluginDayLabelProps {
|
||||
/**
|
||||
* calendar-day-header: the label text under a day's name (day, week and
|
||||
* month views). mini-calendar-marks: a dot on the day in the small month
|
||||
* calendar; the parent must be positioned.
|
||||
*/
|
||||
surface: "calendar-day-header" | "mini-calendar-marks";
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** What plugins put on a calendar day. Renders nothing without labels. */
|
||||
export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
|
||||
const labels = useDayLabels(date);
|
||||
|
||||
if (surface === "mini-calendar-marks") {
|
||||
const marks = labels.filter((l) => l.dot);
|
||||
if (marks.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-plugin-surface="mini-calendar-marks"
|
||||
title={tooltip(marks)}
|
||||
className={cn("pointer-events-none absolute top-0.5 end-0.5 h-1 w-1 rounded-full bg-current opacity-70", className)}
|
||||
/>
|
||||
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const withText = labels.filter((l) => l.text);
|
||||
if (withText.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-plugin-surface="calendar-day-header"
|
||||
title={tooltip(withText)}
|
||||
className={cn("max-w-full truncate text-[10px] leading-tight text-muted-foreground", className)}
|
||||
>
|
||||
{withText.map((l) => l.text).join(" · ")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
|
||||
};
|
||||
}
|
||||
|
||||
/** The tasks the list shows, in its order: filtered by calendar and status. */
|
||||
export function filterTasksForList(
|
||||
tasks: CalendarTask[],
|
||||
selectedCalendarIds: string[],
|
||||
filter: TaskViewFilter,
|
||||
showCompleted: boolean,
|
||||
): CalendarTask[] {
|
||||
let result = tasks.filter(task => {
|
||||
const calIds = Object.keys(task.calendarIds);
|
||||
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||
});
|
||||
|
||||
if (!showCompleted) {
|
||||
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case "pending":
|
||||
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||
break;
|
||||
case "completed":
|
||||
result = result.filter(task => task.progress === "completed");
|
||||
break;
|
||||
case "overdue":
|
||||
result = result.filter(task => {
|
||||
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||
result.sort((a, b) => {
|
||||
// Completed tasks at the bottom
|
||||
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||
|
||||
// Tasks with due dates before those without
|
||||
if (a.due && !b.due) return -1;
|
||||
if (!a.due && b.due) return 1;
|
||||
if (a.due && b.due) {
|
||||
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
|
||||
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||
const aPri = a.priority || 10;
|
||||
const bPri = b.priority || 10;
|
||||
return aPri - bPri;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function TaskListView({
|
||||
tasks,
|
||||
calendars,
|
||||
@@ -77,53 +131,10 @@ export function TaskListView({
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks.filter(task => {
|
||||
const calIds = Object.keys(task.calendarIds);
|
||||
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||
});
|
||||
|
||||
if (!showCompleted) {
|
||||
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case "pending":
|
||||
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||
break;
|
||||
case "completed":
|
||||
result = result.filter(task => task.progress === "completed");
|
||||
break;
|
||||
case "overdue":
|
||||
result = result.filter(task => {
|
||||
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||
result.sort((a, b) => {
|
||||
// Completed tasks at the bottom
|
||||
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||
|
||||
// Tasks with due dates before those without
|
||||
if (a.due && !b.due) return -1;
|
||||
if (!a.due && b.due) return 1;
|
||||
if (a.due && b.due) {
|
||||
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
|
||||
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||
const aPri = a.priority || 10;
|
||||
const bPri = b.priority || 10;
|
||||
return aPri - bPri;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [tasks, selectedCalendarIds, filter, showCompleted]);
|
||||
const filteredTasks = useMemo(
|
||||
() => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
|
||||
[tasks, selectedCalendarIds, filter, showCompleted],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,6 +8,8 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { taskToView } from "@/lib/plugin-projection";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
@@ -48,6 +50,8 @@ export function TaskModal({
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!task;
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
// task-detail-section: the saved task, for an existing task only.
|
||||
const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
|
||||
|
||||
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
@@ -302,6 +306,8 @@ export function TaskModal({
|
||||
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{pluginTaskProps && <PluginSlot name="task-detail-section" className="w-full" extraProps={pluginTaskProps} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
|
||||
interface TaskToolbarProps {
|
||||
@@ -12,8 +14,14 @@ interface TaskToolbarProps {
|
||||
onFilterChange: (filter: TaskViewFilter) => void;
|
||||
onShowCompletedChange: (show: boolean) => void;
|
||||
onCreateTask: () => void;
|
||||
/** The tasks the list shows, in order: what plugin buttons here act on. */
|
||||
taskIds?: string[];
|
||||
/** Icon-only plugin buttons (phones). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const NO_IDS: string[] = [];
|
||||
|
||||
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||
|
||||
export function TaskToolbar({
|
||||
@@ -22,8 +30,11 @@ export function TaskToolbar({
|
||||
onFilterChange,
|
||||
onShowCompletedChange,
|
||||
onCreateTask,
|
||||
taskIds = NO_IDS,
|
||||
compact,
|
||||
}: TaskToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const pluginContext = useMemo(() => ({ taskIds }), [taskIds]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||
@@ -60,6 +71,8 @@ export function TaskToolbar({
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("tasks.create")}
|
||||
</Button>
|
||||
|
||||
<PluginActions surface="task-list-actions" context={pluginContext} compact={compact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import { ContactList } from '../contact-list';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces in contacts: a section in a contact's details and buttons
|
||||
// in the list's selection toolbar.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: '1',
|
||||
uid: 'uid-alice',
|
||||
addressBookIds: { ab1: true },
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
notes: { n0: { note: 'VIP customer' } },
|
||||
};
|
||||
const bob: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: { ab1: true },
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
};
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('contact-detail-section', () => {
|
||||
it('shows the contact to the plugin in its details', () => {
|
||||
offerSlots('p1', ['contact-detail-section']);
|
||||
render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-contact-detail-section');
|
||||
expect(framePropsOf(frame).contact).toMatchObject({
|
||||
id: '1', uid: 'uid-alice', addressBookId: 'ab1', firstName: 'Alice', lastName: 'Smith', emails: ['alice@example.com'],
|
||||
});
|
||||
// Below the contact's own fields.
|
||||
expect(screen.getByText('VIP customer').compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing without a plugin or without a contact', () => {
|
||||
const { unmount } = render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
unmount();
|
||||
|
||||
offerSlots('p1', ['contact-detail-section']);
|
||||
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact-list-actions', () => {
|
||||
const props = {
|
||||
contacts: [alice, bob],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectRangeContacts: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
onEditContact: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
onAddContactToGroup: vi.fn(),
|
||||
};
|
||||
|
||||
it('puts plugin buttons in the selection toolbar and sends the selected ids', async () => {
|
||||
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
|
||||
render(<ContactList {...props} selectedContactIds={new Set(['1', '2'])} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Sync to CRM' });
|
||||
// Next to the app's own bulk buttons.
|
||||
expect(button.closest('div.flex-wrap')?.parentElement).toContainElement(screen.getByText('bulk.delete'));
|
||||
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'contact-list-actions', 'sync', { contactIds: ['1', '2'] });
|
||||
});
|
||||
|
||||
it('shows no plugin buttons without a selection or without a plugin', () => {
|
||||
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
|
||||
const { unmount } = render(<ContactList {...props} selectedContactIds={new Set()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
|
||||
unmount();
|
||||
|
||||
resetActions();
|
||||
render(<ContactList {...props} selectedContactIds={new Set(['1'])} />);
|
||||
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -125,6 +125,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
|
||||
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
|
||||
|
||||
// Tell plugins once per contact shown here, not on every edit of that card.
|
||||
const latestContact = useRef(contact);
|
||||
@@ -509,6 +510,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{pluginContactProps && (
|
||||
<PluginSlot name="contact-detail-section" className="w-full py-6" extraProps={pluginContactProps} />
|
||||
)}
|
||||
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{(contact.created || contact.updated) && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
@@ -268,6 +269,7 @@ export function ContactList({
|
||||
}, [sorted, locale, sortByLastName]);
|
||||
|
||||
const hasSelection = selectedContactIds.size > 0;
|
||||
const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
|
||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||
|
||||
return (
|
||||
@@ -521,6 +523,7 @@ export function ContactList({
|
||||
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.delete")}
|
||||
</Button>
|
||||
<PluginActions surface="contact-list-actions" context={pluginContext} />
|
||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { FileDetailsPanel } from '../file-details-panel';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { ImagePreviewModal } from '../image-preview-modal';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileResourceView } from '@/lib/plugin-types';
|
||||
|
||||
// Plugin surfaces in Files: entries in an item's menu and buttons in the
|
||||
// selection toolbar (file-actions), and a section in the previews and the
|
||||
// details panel (file-preview-section).
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const folder = {
|
||||
id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
|
||||
} as FileResource;
|
||||
const notes = {
|
||||
id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
|
||||
} as FileResource;
|
||||
const photo = {
|
||||
id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99,
|
||||
lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
const notesView: FileResourceView = {
|
||||
id: 'f-notes', name: 'notes.txt', type: 'file', size: 10, mimeType: 'text/plain', path: '/', modified: '2026-10-01T00:00:00Z',
|
||||
};
|
||||
|
||||
function renderBrowser(selected: string[] = []) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({
|
||||
client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true,
|
||||
});
|
||||
});
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[folder, notes, photo]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set(selected)}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onDownload={noop}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onPreviewImage={vi.fn()}
|
||||
onCreateTextFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function rowOf(name: string): HTMLElement {
|
||||
return within(screen.getByRole('grid', { name: 'file_list' })).getByText(name).closest('[role=row]') as HTMLElement;
|
||||
}
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
|
||||
});
|
||||
|
||||
describe('file-actions', () => {
|
||||
it('adds entries to an item menu, filtered by its type, and sends the item', async () => {
|
||||
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
|
||||
{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
|
||||
{ id: 'edit-image', label: 'Edit image', mimeTypes: ['image/'] },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
]));
|
||||
renderBrowser();
|
||||
|
||||
fireEvent.contextMenu(rowOf('notes.txt'));
|
||||
expect(await screen.findByRole('menuitem', { name: 'Read text' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Edit image' })).toBeNull();
|
||||
// After the app's own entries.
|
||||
const items = screen.getAllByRole('menuitem').map((el) => el.textContent);
|
||||
expect(items.indexOf('Archive')).toBeGreaterThan(items.findIndex((text) => text?.includes('delete')));
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Read text' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ocr', 'file-actions', 'read-text', { files: [notesView] });
|
||||
});
|
||||
|
||||
it('keeps type-specific entries off folders', async () => {
|
||||
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
|
||||
{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
]));
|
||||
renderBrowser();
|
||||
|
||||
fireEvent.contextMenu(rowOf('Documents'));
|
||||
expect(await screen.findByRole('menuitem', { name: 'Archive' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Read text' })).toBeNull();
|
||||
});
|
||||
|
||||
it('adds buttons to the selection toolbar with the selected items', async () => {
|
||||
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
|
||||
]));
|
||||
renderBrowser(['notes.txt', 'photo.png']);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' });
|
||||
const button = within(toolbar).getByRole('button', { name: 'Archive' });
|
||||
// Several items: no type filter applies, so every entry shows.
|
||||
expect(within(toolbar).getByRole('button', { name: 'Read text' })).toBeInTheDocument();
|
||||
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
const context = dispatch.mock.calls[0][3] as { files: FileResourceView[] };
|
||||
expect(context.files.map((f) => f.id)).toEqual(['f-notes', 'f-photo']);
|
||||
});
|
||||
|
||||
it('filters the toolbar by the type of a single selected file', () => {
|
||||
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }]));
|
||||
renderBrowser(['photo.png']);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' });
|
||||
expect(within(toolbar).queryByRole('button', { name: 'Read text' })).toBeNull();
|
||||
});
|
||||
|
||||
it('adds nothing without a plugin', async () => {
|
||||
renderBrowser(['notes.txt']);
|
||||
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
|
||||
fireEvent.contextMenu(rowOf('notes.txt'));
|
||||
await screen.findAllByRole('menuitem');
|
||||
expect(screen.queryByRole('menuitem', { name: 'Archive' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-preview-section', () => {
|
||||
const panelProps = {
|
||||
resource: notes,
|
||||
selection: [notes],
|
||||
folderName: 'Files',
|
||||
folderItems: [notes],
|
||||
path: '/notes.txt',
|
||||
isStarred: false,
|
||||
canShare: false,
|
||||
canPreview: true,
|
||||
canEdit: false,
|
||||
iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false },
|
||||
getImageUrl: async () => '',
|
||||
onClose: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onShare: vi.fn(),
|
||||
onToggleStar: vi.fn(),
|
||||
} as unknown as React.ComponentProps<typeof FileDetailsPanel>;
|
||||
|
||||
it('shows the selected file in the details panel', () => {
|
||||
offerSlots('p1', ['file-preview-section']);
|
||||
render(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
|
||||
});
|
||||
|
||||
it('passes the selection from the browser to the details panel', () => {
|
||||
offerSlots('p1', ['file-preview-section']);
|
||||
localStorage.setItem('files-show-details', '1');
|
||||
renderBrowser(['notes.txt']);
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
|
||||
});
|
||||
|
||||
it('shows the previewed file under the text preview', async () => {
|
||||
offerSlots('p1', ['file-preview-section']);
|
||||
render(
|
||||
<FilePreviewModal
|
||||
name="notes.txt"
|
||||
file={notesView}
|
||||
onClose={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
getFileContent={async () => ({ blob: new Blob(['hi'], { type: 'text/plain' }), contentType: 'text/plain' })}
|
||||
/>,
|
||||
);
|
||||
expect(framePropsOf(await screen.findByTestId('plugin-frame-file-preview-section')).file).toEqual(notesView);
|
||||
});
|
||||
|
||||
it('keeps clicks on the section from closing the image preview', () => {
|
||||
offerSlots('p1', ['file-preview-section']);
|
||||
const onClose = vi.fn();
|
||||
render(<ImagePreviewModal name="photo.png" file={{ ...notesView, id: 'f-photo' }} onClose={onClose} onDownload={async () => {}} getImageUrl={async () => ''} />);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-file-preview-section');
|
||||
expect(framePropsOf(frame).file).toMatchObject({ id: 'f-photo' });
|
||||
fireEvent.click(frame);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders nothing without a plugin or a file', () => {
|
||||
render(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
cleanup();
|
||||
|
||||
offerSlots('p1', ['file-preview-section']);
|
||||
render(<ImagePreviewModal name="photo.png" onClose={vi.fn()} onDownload={async () => {}} getImageUrl={async () => ''} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,13 @@ import { FileDetailsPanel } from "@/components/files/file-details-panel";
|
||||
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
import { resourceToView, useFileStore, type FileResource } from "@/stores/file-store";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
interface ClipboardState {
|
||||
mode: "cut" | "copy";
|
||||
@@ -371,6 +373,8 @@ export function FileBrowser({
|
||||
const cutNames = useMemo(() => new Set(clipboard?.mode === "cut" ? clipboard.names : []), [clipboard]);
|
||||
const selectedList = useMemo(() => displayResources.filter((r) => selectedResources.has(r.name)), [displayResources, selectedResources]);
|
||||
const singleSelected = selectedList.length === 1 ? selectedList[0] : null;
|
||||
// What plugins see of the selection (file-actions, file-preview-section).
|
||||
const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
|
||||
|
||||
// ---- Breadcrumbs -----------------------------------------------------------
|
||||
const crumbs: Crumb[] = useMemo(() => {
|
||||
@@ -759,6 +763,7 @@ export function FileBrowser({
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy(names); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete_count", { count: names.length })} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
|
||||
<FilePluginMenuEntries files={menuSelection.map((r) => resourceToView(r, currentPath))} onRan={closeMenu} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -800,6 +805,11 @@ export function FileBrowser({
|
||||
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
|
||||
<FilePluginMenuEntries
|
||||
files={[resourceToView(resource, currentPath)]}
|
||||
mimeType={resource.isDirectory ? "" : resource.contentType}
|
||||
onRan={closeMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -962,6 +972,7 @@ export function FileBrowser({
|
||||
canShare={canShare(singleSelected)}
|
||||
canPreview={!!singleSelected && isPreviewable(singleSelected.name)}
|
||||
canEdit={!!singleSelected && !!onEditFile && !!isOfficeEditable?.(singleSelected.name)}
|
||||
pluginFile={singleSelected ? selectedViews[0] : null}
|
||||
iconOptions={iconOptions}
|
||||
getImageUrl={getImageUrl}
|
||||
onClose={() => toggleDetails(false)}
|
||||
@@ -991,6 +1002,8 @@ export function FileBrowser({
|
||||
onCopy: () => onCopy(selectedList.map((r) => r.name)),
|
||||
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
|
||||
onClear: onClearSelection,
|
||||
pluginFiles: selectedViews,
|
||||
pluginMimeType: singleSelected ? (singleSelected.isDirectory ? "" : singleSelected.contentType) : undefined,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
@@ -1272,3 +1285,15 @@ function ListSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin entries at the end of a file's menu, after a separator when there are any. */
|
||||
function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) {
|
||||
const hasActions = usePluginActions("file-actions", { mimeType }).length > 0;
|
||||
if (!hasActions) return null;
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<PluginActions surface="file-actions" variant="menu" context={{ files }} mimeType={mimeType} onRan={onRan} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import {
|
||||
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
@@ -31,6 +34,8 @@ interface FileDetailsPanelProps {
|
||||
onShare: () => void;
|
||||
onToggleStar: () => void;
|
||||
className?: string;
|
||||
/** The one selected item as plugins see it (file-preview-section). */
|
||||
pluginFile?: FileResourceView | null;
|
||||
}
|
||||
|
||||
function kindOf(resource: FileResource, t: (key: string, values?: Record<string, string>) => string): string {
|
||||
@@ -46,9 +51,10 @@ function totalSize(items: FileResource[]): number {
|
||||
|
||||
export function FileDetailsPanel({
|
||||
resource, selection, folderName, folderItems, path, isStarred, canShare, canPreview, canEdit,
|
||||
iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className,
|
||||
iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile,
|
||||
}: FileDetailsPanelProps) {
|
||||
const t = useTranslations("files");
|
||||
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between px-4 h-11 border-b border-border flex-shrink-0">
|
||||
@@ -155,6 +161,7 @@ export function FileDetailsPanel({
|
||||
<Row label={t("sharing")} value={sharing} />
|
||||
<Row label={t("path")} value={path} mono />
|
||||
</dl>
|
||||
{pluginProps && <PluginSlot name="file-preview-section" className="w-full" extraProps={pluginProps} />}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
||||
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
||||
@@ -22,6 +24,8 @@ interface FilePreviewModalProps {
|
||||
onClose: () => void;
|
||||
onDownload: () => Promise<void> | void;
|
||||
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
|
||||
/** The previewed file as plugins see it (file-preview-section). */
|
||||
file?: FileResourceView | null;
|
||||
}
|
||||
|
||||
function SimpleMarkdown({ content }: { content: string }) {
|
||||
@@ -115,7 +119,7 @@ function processInline(text: string): React.ReactNode {
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: FilePreviewModalProps) {
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file }: FilePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
@@ -381,6 +385,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FilePreviewPluginSection file={file} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
/**
|
||||
* file-preview-section in the full-screen previews: a panel under the
|
||||
* preview. Nothing at all when no plugin offers it or the file is unknown.
|
||||
*/
|
||||
export function FilePreviewPluginSection({ file }: { file?: FileResourceView | null }) {
|
||||
const offered = usePluginSlotOffers("file-preview-section").length > 0;
|
||||
const extraProps = useMemo(() => (file ? { file } : null), [file]);
|
||||
if (!offered || !extraProps) return null;
|
||||
return (
|
||||
// The overlay closes on a click; the panel is not part of the backdrop.
|
||||
<div
|
||||
className="max-h-[40vh] w-full flex-shrink-0 overflow-y-auto border-t border-border bg-background pb-[env(safe-area-inset-bottom)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PluginSlot name="file-preview-section" className="w-full" extraProps={extraProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, resourceServerRef, type UploadResult } from "@/stores/file-store";
|
||||
import { useFileStore, resourceServerRef, resourceToView, type UploadResult } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -544,6 +544,13 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
|
||||
|
||||
// The previewed file as plugins see it (file-preview-section).
|
||||
const previewedName = previewFile ?? previewImage;
|
||||
const previewedView = useMemo(() => {
|
||||
const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
|
||||
return resource ? resourceToView(resource, currentPath) : null;
|
||||
}, [previewedName, resources, currentPath]);
|
||||
useDeepLinkUrl(
|
||||
isEmbedded
|
||||
? (isFocusedProTab ? filesLinkPath : null)
|
||||
@@ -715,6 +722,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onClose={() => setPreviewImage(null)}
|
||||
onDownload={handleDownload}
|
||||
getImageUrl={getImageUrl}
|
||||
file={previewedView}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -725,6 +733,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onClose={() => setPreviewFile(null)}
|
||||
onDownload={() => handleDownload(previewFile)}
|
||||
getFileContent={() => getFileContent(previewFile)}
|
||||
file={previewedView}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
|
||||
@@ -12,6 +12,8 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
export type SortKey = "name" | "size" | "modified";
|
||||
export type SortDir = "asc" | "desc";
|
||||
@@ -39,6 +41,10 @@ interface SelectionActions {
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
/** The selection as plugins see it (file-actions). */
|
||||
pluginFiles: FileResourceView[];
|
||||
/** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */
|
||||
pluginMimeType?: string;
|
||||
}
|
||||
|
||||
interface FilesHeaderProps {
|
||||
@@ -132,6 +138,7 @@ export function FilesHeader({
|
||||
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
|
||||
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
|
||||
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
|
||||
<SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
@@ -426,3 +433,9 @@ function SortItem({ label, checked, onClick, icon: Icon }: {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin buttons in the selection toolbar, icon-only like the app's own. */
|
||||
function SelectionPluginActions({ files, mimeType }: { files: FileResourceView[]; mimeType?: string }) {
|
||||
const context = useMemo(() => ({ files }), [files]);
|
||||
return <PluginActions surface="file-actions" context={context} mimeType={mimeType} compact className="flex-nowrap gap-0.5" />;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,19 @@ import { X, Download } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onDownload: (name: string) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
/** The previewed file as plugins see it (file-preview-section). */
|
||||
file?: FileResourceView | null;
|
||||
}
|
||||
|
||||
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: ImagePreviewModalProps) {
|
||||
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file }: ImagePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -111,6 +115,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
<FilePreviewPluginSection file={file} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Test helpers for mount-site tests: a plugin that offers a slot, and a
|
||||
// stand-in for the sandboxed iframe that shows which props it was handed.
|
||||
//
|
||||
// Use with:
|
||||
// vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
|
||||
const registered = new Set<string>();
|
||||
|
||||
/** Registers an active plugin that offers these slots. */
|
||||
export function offerSlots(pluginId: string, slots: SlotName[]): void {
|
||||
register({
|
||||
plugin: { id: pluginId, name: pluginId },
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: {},
|
||||
slotOffers: slots.map((name) => ({ name, order: 100, hasShouldShow: false })),
|
||||
hookDisposables: [],
|
||||
} as unknown as ActivePlugin);
|
||||
registered.add(pluginId);
|
||||
}
|
||||
|
||||
export function clearSlotOffers(): void {
|
||||
for (const id of registered) deregister(id);
|
||||
registered.clear();
|
||||
}
|
||||
|
||||
/** Drop-in for PluginIframeSlot: no iframe, the props as JSON. */
|
||||
export function PluginIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record<string, unknown> }) {
|
||||
return <div data-testid={`plugin-frame-${slot}`} data-plugin-id={pluginId} data-props={JSON.stringify(extraProps ?? {})} />;
|
||||
}
|
||||
|
||||
/** The props the fake iframe for this slot received. */
|
||||
export function framePropsOf(el: HTMLElement): Record<string, unknown> {
|
||||
return JSON.parse(el.getAttribute('data-props') || '{}') as Record<string, unknown>;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function isMounted(name: string): boolean {
|
||||
// that finishes an area removes it here; the test then insists every
|
||||
// surface in it has a mount site.
|
||||
const NOT_YET_MOUNTED = new Set<SlotArea>([
|
||||
'app', 'navigation', 'mail-list', 'reader', 'composer', 'calendar', 'contacts', 'tasks', 'files', 'settings',
|
||||
'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings',
|
||||
]);
|
||||
|
||||
describe('slot catalogue', () => {
|
||||
|
||||
@@ -293,7 +293,7 @@ function joinPath(base: string, name: string): string {
|
||||
}
|
||||
|
||||
/** A listed item as plugins see it; `path` is the folder that holds it. */
|
||||
function resourceToView(r: FileResource, path: string): FileResourceView {
|
||||
export function resourceToView(r: FileResource, path: string): FileResourceView {
|
||||
return fileToView({
|
||||
id: r.id,
|
||||
parentId: r.parentId,
|
||||
|
||||
Reference in New Issue
Block a user