mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-pim-fixes' into feat/desktop-offline
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
|
||||
// pim-25: the event editor sat beside the week grid and squeezed its seven
|
||||
// columns to ~85 px at 1366x768. It now opens as a sheet over the grid from
|
||||
// the right, and the grid keeps its width.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsMobile: () => false,
|
||||
useIsDesktop: () => true,
|
||||
useIsFullDesktop: () => true,
|
||||
useIsTablet: () => false,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
|
||||
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
|
||||
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
|
||||
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}] as unknown as Calendar[];
|
||||
const event = {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true },
|
||||
participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed',
|
||||
} as unknown as CalendarEvent;
|
||||
const client = {
|
||||
getAccountId: () => 'acc',
|
||||
getMaxParticipantsPerEvent: () => null,
|
||||
supportsCalendars: () => true,
|
||||
queryCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
getCalendarEvent: vi.fn().mockResolvedValue(null),
|
||||
getCalendarTasks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
|
||||
useCalendarStore.setState({
|
||||
calendars, events: [event], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week',
|
||||
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
|
||||
fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
|
||||
createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(),
|
||||
} as never);
|
||||
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never);
|
||||
useTaskStore.setState({ tasks: [] } as never);
|
||||
});
|
||||
|
||||
describe('CalendarApp event editor (pim-25)', () => {
|
||||
it('opens over the grid instead of beside it', async () => {
|
||||
render(<CalendarApp />);
|
||||
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
|
||||
const editor = await screen.findByRole('dialog', { name: 'events.create' });
|
||||
const sheet = editor.parentElement!;
|
||||
// Out of the row's flow, pinned to its end edge, above the grid.
|
||||
expect(sheet.className).toMatch(/\babsolute\b/);
|
||||
expect(sheet.className).toMatch(/\binset-y-0\b/);
|
||||
expect(sheet.className).toMatch(/\bend-0\b/);
|
||||
expect(sheet.className).not.toMatch(/\bflex-shrink-0\b/);
|
||||
expect(sheet.parentElement!.className).toMatch(/\brelative\b/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
// pim-12 review: a series of another login was deleted with the active
|
||||
// login's addresses, so a meeting the user organizes as that login counted as
|
||||
// someone else's: no "attendees will be told" note, and truncating the series
|
||||
// didn't tell them. The event's own login's addresses decide now.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsMobile: () => false,
|
||||
useIsDesktop: () => true,
|
||||
useIsFullDesktop: () => true,
|
||||
useIsTablet: () => false,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
|
||||
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
|
||||
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
|
||||
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}, {
|
||||
id: 'login-b:bcal', originalId: 'bcal', localAccountId: 'login-b', name: 'B Personal', color: '#654321', isDefault: true,
|
||||
sortOrder: 0, description: null, isSubscribed: true, isVisible: true, includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}] as unknown as Calendar[];
|
||||
const series = {
|
||||
id: 'login-b:e1', originalId: 'e1', localAccountId: 'login-b', uid: 'u1', '@type': 'Event', title: 'Team sync',
|
||||
description: '', start: '2026-10-12T10:00:00', duration: 'PT1H', timeZone: null, showWithoutTime: false,
|
||||
calendarIds: { 'login-b:bcal': true }, organizerCalendarAddress: 'mailto:b@example.org',
|
||||
participants: {
|
||||
org: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
att: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
alerts: null, recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], isOrigin: false, status: 'confirmed',
|
||||
} as unknown as CalendarEvent;
|
||||
const client = {
|
||||
getAccountId: () => 'acc',
|
||||
getMaxParticipantsPerEvent: () => null,
|
||||
supportsCalendars: () => true,
|
||||
queryCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
getCalendarEvent: vi.fn().mockResolvedValue(null),
|
||||
getCalendarTasks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc', username: 'me@example.com' } as never);
|
||||
useAccountStore.setState({
|
||||
accounts: [{ id: 'acc', email: 'me@example.com', username: 'me@example.com' }, { id: 'login-b', email: 'b@example.org', username: 'b@example.org' }],
|
||||
activeAccountId: 'acc',
|
||||
} as never);
|
||||
useCalendarStore.setState({
|
||||
calendars, events: [series], supportsCalendar: true, selectedCalendarIds: ['cal', 'login-b:bcal'], viewMode: 'week',
|
||||
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
|
||||
fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
|
||||
createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(),
|
||||
} as never);
|
||||
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: false, proInterface: false } as never);
|
||||
useTaskStore.setState({ tasks: [] } as never);
|
||||
});
|
||||
|
||||
describe('CalendarApp series of another login', () => {
|
||||
it("tells attendees when the user organizes it as that login", async () => {
|
||||
render(<CalendarApp />);
|
||||
const chips = await screen.findAllByText('Team sync');
|
||||
fireEvent.contextMenu(chips[0]);
|
||||
fireEvent.click(await screen.findByText('events.delete'));
|
||||
expect(await screen.findByText('participants.cancel_notification')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
|
||||
// pim-15: the desktop app's File > Import > Calendar…, on the whole calendar page
|
||||
// with the shell around it stubbed out.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useIsMobile: () => false,
|
||||
useIsDesktop: () => true,
|
||||
useIsFullDesktop: () => true,
|
||||
useIsTablet: () => false,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
|
||||
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
|
||||
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
const config = vi.hoisted(() => ({ demoMode: false }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
import { requestAppCommand } from '@/lib/app-commands';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
|
||||
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
const client = {
|
||||
getAccountId: () => 'acc',
|
||||
getMaxParticipantsPerEvent: () => null,
|
||||
supportsCalendars: () => true,
|
||||
queryCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||
getCalendarEvent: vi.fn().mockResolvedValue(null),
|
||||
getCalendarTasks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config.demoMode = false;
|
||||
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
|
||||
useCalendarStore.setState({
|
||||
calendars, events: [], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week',
|
||||
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
|
||||
fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(),
|
||||
} as never);
|
||||
useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never);
|
||||
useTaskStore.setState({ tasks: [] } as never);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('Calendar in the desktop menus', () => {
|
||||
it('File > Import > Calendar… opens the calendar import', () => {
|
||||
render(<CalendarApp />);
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
let taken = false;
|
||||
act(() => { taken = requestAppCommand('import-calendar'); });
|
||||
expect(taken).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens nothing in demo mode, where the toolbar has no import either', () => {
|
||||
config.demoMode = true;
|
||||
render(<CalendarApp />);
|
||||
act(() => { requestAppCommand('import-calendar'); });
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
import { CalendarSearchField } from '../calendar-search';
|
||||
|
||||
// pim-21: at 150 % in German the search box read "Termine durchs". A field
|
||||
// too narrow for "Search events" now says "Search"; its name stays whole.
|
||||
|
||||
/** The field is `width` px wide inside; text measures 10px per character. */
|
||||
function stubWidth(width: number) {
|
||||
vi.stubGlobal('ResizeObserver', class implements ResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
font: '',
|
||||
measureText: (text: string) => ({ width: text.length * 10 }),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CalendarSearchField placeholder', () => {
|
||||
it('says the short word in a narrow field', () => {
|
||||
// "search.title" is 12 characters: 120px does not fit in 96.
|
||||
stubWidth(96);
|
||||
render(<CalendarSearchField value="" onChange={vi.fn()} />);
|
||||
const input = screen.getByRole('searchbox');
|
||||
expect(input.getAttribute('placeholder')).toBe('search_short');
|
||||
expect(input.getAttribute('aria-label')).toBe('search.title');
|
||||
});
|
||||
|
||||
it('keeps the whole phrase where it fits', () => {
|
||||
stubWidth(200);
|
||||
render(<CalendarSearchField value="" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('searchbox').getAttribute('placeholder')).toBe('search.title');
|
||||
});
|
||||
|
||||
it('still hands the input to the page', () => {
|
||||
stubWidth(200);
|
||||
const ref = createRef<HTMLInputElement>();
|
||||
render(<CalendarSearchField ref={ref} value="" onChange={vi.fn()} />);
|
||||
expect(ref.current).toBe(screen.getByRole('searchbox'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// pim-20: renaming a calendar only worked from Settings, through a pencil
|
||||
// that showed on hover. The calendar's menu has "Rename…" now, and calls
|
||||
// clearing "Clear all events…" and deleting "Delete…" (both ask first).
|
||||
|
||||
const work = {
|
||||
id: 'work', name: 'Work', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
|
||||
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
} as unknown as Calendar;
|
||||
const shared = { ...work, id: 'x:team', name: 'Team', isShared: true, accountId: 'x', accountName: 'b@example.org' } as Calendar;
|
||||
const client = { getAccountId: () => 'a' } as unknown as IJMAPClient;
|
||||
let updateCalendar: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
updateCalendar = vi.fn().mockResolvedValue(undefined);
|
||||
useCalendarStore.setState({ updateCalendar, viewMode: 'month', icalSubscriptions: [] } as never);
|
||||
});
|
||||
|
||||
function openMenu(name: string) {
|
||||
render(<CalendarSidebarPanel calendars={[work, shared]} selectedCalendarIds={['work', 'x:team']} onToggleVisibility={() => {}} onClearCalendar={() => {}} onDeleteCalendar={() => {}} client={client} />);
|
||||
fireEvent.contextMenu(screen.getAllByTestId('calendar-item').find((el) => el.dataset.calendarName === name)!);
|
||||
}
|
||||
|
||||
describe('calendar menu Rename (pim-20)', () => {
|
||||
it('renames the calendar from its menu', async () => {
|
||||
openMenu('Work');
|
||||
const items = screen.getAllByRole('menuitem').map((el) => el.textContent);
|
||||
expect(items).toContain('rename_calendar');
|
||||
expect(items).toContain('clear_all_events');
|
||||
expect(items).toContain('delete_calendar');
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'rename_calendar' }));
|
||||
const field = await screen.findByDisplayValue('Work');
|
||||
fireEvent.change(field, { target: { value: 'Office' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'rename_confirm' }));
|
||||
await waitFor(() => expect(updateCalendar).toHaveBeenCalledWith(client, 'work', { name: 'Office' }));
|
||||
});
|
||||
|
||||
it('offers no Rename for a calendar someone shared', () => {
|
||||
openMenu('Team');
|
||||
expect(screen.getAllByRole('menuitem').map((el) => el.textContent)).not.toContain('rename_calendar');
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe('CalendarSidebarPanel on touch screens', () => {
|
||||
it('opens a calendar\'s menu on a long press', async () => {
|
||||
render(<CalendarSidebarPanel calendars={[work]} selectedCalendarIds={['work']} onToggleVisibility={() => {}} onDeleteCalendar={() => {}} client={client} />);
|
||||
fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] });
|
||||
expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument();
|
||||
// "Delete…" in the menu, as it opens a dialog (pim-20).
|
||||
expect(await screen.findByRole('menuitem', { name: 'delete_calendar' }, { timeout: 2000 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// With two logins the editor lists every login's calendars. Moving an existing
|
||||
// event into another login's calendar sent that calendar's raw id to the
|
||||
// event's own server, which either moved it into an unrelated calendar there
|
||||
// or failed. An existing event now moves only within its own login; a new
|
||||
// event can still go anywhere.
|
||||
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
const rights = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
const calendars = [
|
||||
{ id: 'a-personal', name: 'A Personal', color: '#123456', isDefault: true, myRights: rights },
|
||||
{ id: 'a-work', name: 'A Work', color: '#123456', isDefault: false, myRights: rights },
|
||||
{ id: 'login-b:b', name: 'B Personal', color: '#654321', isDefault: true, myRights: rights, localAccountId: 'login-b', originalId: 'b' },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
const event = {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { 'a-personal': true },
|
||||
participants: null, alerts: null, recurrenceRules: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function options(): string[] {
|
||||
const select = document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement | null;
|
||||
return select ? within(select).getAllByRole('option').map((o) => o.textContent ?? '') : [];
|
||||
}
|
||||
|
||||
describe('EventModal calendar choice with several logins', () => {
|
||||
it("offers only the event's own login's calendars when editing", () => {
|
||||
render(<EventModal event={event} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
expect(options()).toEqual(['A Personal', 'A Work']);
|
||||
});
|
||||
|
||||
it("keeps another login's event in that login", () => {
|
||||
const other = { ...event, calendarIds: { 'login-b:b': true } } as CalendarEvent;
|
||||
render(<EventModal event={other} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
// One calendar left: no select at all.
|
||||
expect(options()).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers every login for a new event', () => {
|
||||
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
expect(options()).toEqual(['A Personal', 'A Work', 'B Personal']);
|
||||
});
|
||||
});
|
||||
@@ -109,8 +109,8 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a
|
||||
|
||||
// RFC 5546: when the organizer moves an event, the attendees are asked
|
||||
// again; every other save keeps their answers. The editor shows and writes
|
||||
// the event in the user's zone (here Tokyo, UTC+9), which is no move by
|
||||
// itself.
|
||||
// the event on its own clock (New York), whatever the user's zone (here
|
||||
// Tokyo, UTC+9) is (pim-2).
|
||||
describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => {
|
||||
beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' }));
|
||||
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
|
||||
@@ -155,11 +155,11 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a
|
||||
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' });
|
||||
});
|
||||
|
||||
it('keeps the answers when only the time zone is rewritten', async () => {
|
||||
it('keeps the answers, and the event in its own zone, when nothing changes', async () => {
|
||||
const data = await save(own(), () => {});
|
||||
|
||||
// Written in the user's zone, at the same instant.
|
||||
expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' });
|
||||
// Written in its own zone, as it was (pim-2); it was rewritten into the user's.
|
||||
expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
|
||||
expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { AUTO_TIME_ZONE, fromDisplayDate } from '@/lib/timezone';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import type { EventEditorState } from '@/lib/recurrence-scope-edit';
|
||||
|
||||
// pim-2: an edit kept the event's time but moved it into the user's zone
|
||||
// (every save wrote timeZone: <user's zone>), and there was no way to put an
|
||||
// event in another zone. pim-7: the form asked for the date and time last.
|
||||
// pim-19, pim-22: plain words for the participant answers and "(No title)".
|
||||
|
||||
// Keys with their values, so a test can read what a line says.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
|
||||
}));
|
||||
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
|
||||
|
||||
const RIGHTS = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
};
|
||||
const calendars = [
|
||||
{ id: 'cal', name: 'Work', color: '#123456', isDefault: true, myRights: RIGHTS },
|
||||
{ id: 'home', name: 'Home', color: '#654321', isDefault: false, myRights: RIGHTS },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
// 10:00-11:00 in New York on 5 November (UTC-5): 16:00-17:00 in Berlin.
|
||||
const newYork = {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Call with New York office', description: '',
|
||||
start: '2026-11-05T10:00:00', duration: 'PT1H', timeZone: 'America/New_York',
|
||||
utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z',
|
||||
showWithoutTime: false, calendarIds: { cal: true }, participants: null, alerts: null, recurrenceRules: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function editor(event: CalendarEvent | null, extra: Partial<Parameters<typeof EventModal>[0]> = {}) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EventModal event={event} startInEditMode calendars={calendars} onSave={onSave} onClose={vi.fn()} {...extra} />);
|
||||
return onSave;
|
||||
}
|
||||
|
||||
async function save(onSave: ReturnType<typeof vi.fn>): Promise<{ data: Partial<CalendarEvent>; state: EventEditorState }> {
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
return { data: onSave.mock.calls[0][0], state: onSave.mock.calls[0][2] };
|
||||
}
|
||||
|
||||
const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement;
|
||||
|
||||
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
|
||||
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
|
||||
|
||||
describe('the event editor keeps the event in its own time zone (pim-2)', () => {
|
||||
it('shows the times on the event clock and says which zone that is', () => {
|
||||
editor(newYork);
|
||||
expect(field('form.start_time').value).toBe('10:00');
|
||||
expect(field('form.end_time').value).toBe('11:00');
|
||||
expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"New York"}');
|
||||
expect(screen.getByText(/^your_time:/).textContent).toBe('your_time:{"times":"16:00–17:00"}');
|
||||
});
|
||||
|
||||
it('saves a title change with the zone and start the event had', async () => {
|
||||
const onSave = editor(newYork);
|
||||
fireEvent.change(field('form.title'), { target: { value: 'Call with New York office, room 2' } });
|
||||
const { data, state } = await save(onSave);
|
||||
expect(data).toMatchObject({ title: 'Call with New York office, room 2', start: '2026-11-05T10:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
|
||||
// The editor state stays in display dates: the same instant.
|
||||
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-05T15:00:00.000Z');
|
||||
expect(state.timeZoneChanged).toBeUndefined();
|
||||
});
|
||||
|
||||
// Review of pim-2: the duration and the editor state came from Dates in the
|
||||
// browser's zone. 01:00-04:00 in New York on the night its clocks go
|
||||
// forward lasts two hours, not three; and 02:30 exists in New York on the
|
||||
// night Berlin's clocks go forward, where a browser Date moved it to 03:30.
|
||||
it("counts the duration on the event's clock across its daylight-saving switch", async () => {
|
||||
const onSave = editor({ ...newYork, start: '2026-03-08T01:00:00', duration: 'PT2H', utcStart: '2026-03-08T06:00:00Z', utcEnd: '2026-03-08T08:00:00Z' } as CalendarEvent);
|
||||
expect(field('form.end_time').value).toBe('04:00');
|
||||
fireEvent.change(field('form.title'), { target: { value: 'Night shift handover' } });
|
||||
const { data } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-03-08T01:00:00', timeZone: 'America/New_York', duration: 'PT2H' });
|
||||
});
|
||||
|
||||
it("keeps a time that falls in the browser's daylight-saving gap", async () => {
|
||||
const onSave = editor(newYork);
|
||||
fireEvent.change(field('form.start_date'), { target: { value: '2026-03-29' } });
|
||||
fireEvent.change(field('form.end_date'), { target: { value: '2026-03-29' } });
|
||||
fireEvent.change(field('form.start_time'), { target: { value: '02:30' } });
|
||||
fireEvent.change(field('form.end_time'), { target: { value: '03:30' } });
|
||||
const { data, state } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-03-29T02:30:00', duration: 'PT1H' });
|
||||
// 02:30 in New York (UTC-4 since 8 March) is 06:30 UTC.
|
||||
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-03-29T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('moves the event on its own clock', async () => {
|
||||
const onSave = editor(newYork);
|
||||
fireEvent.change(field('form.start_time'), { target: { value: '12:00' } });
|
||||
const { data } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-11-05T12:00:00', timeZone: 'America/New_York', duration: 'PT1H' });
|
||||
});
|
||||
|
||||
it('creates an event in another zone through the picker', async () => {
|
||||
const onSave = editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) });
|
||||
fireEvent.change(field('form.title'), { target: { value: 'Tokyo partner sync' } });
|
||||
// A new event is in the user's zone, behind a small link.
|
||||
expect(screen.queryByText(/^time_zone_line/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'time_zone' }));
|
||||
fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } });
|
||||
const { data, state } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' });
|
||||
// 10:00 in Tokyo is 01:00 UTC.
|
||||
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-12T01:00:00.000Z');
|
||||
});
|
||||
|
||||
it('tells the series save when the user picked another zone', async () => {
|
||||
const onSave = editor(newYork);
|
||||
fireEvent.click(screen.getByText(/^time_zone_line:/));
|
||||
fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } });
|
||||
const { data, state } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' });
|
||||
expect(state.timeZoneChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a floating event without a zone', async () => {
|
||||
const onSave = editor({ ...newYork, timeZone: null, utcStart: undefined, utcEnd: undefined } as unknown as CalendarEvent);
|
||||
expect(screen.queryByTestId('event-time-zone')).toBeNull();
|
||||
fireEvent.change(field('form.title'), { target: { value: 'Floating' } });
|
||||
const { data } = await save(onSave);
|
||||
expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: null });
|
||||
});
|
||||
|
||||
it('shows the event clock beside the user one in the summary', () => {
|
||||
render(<EventModal event={newYork} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('event-zone-times').textContent)
|
||||
.toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}');
|
||||
});
|
||||
|
||||
it('shows no zone line for an event on the user clock', () => {
|
||||
render(<EventModal event={{ ...newYork, timeZone: 'Europe/Paris' } as CalendarEvent} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('event-zone-times')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the event form order (pim-7)', () => {
|
||||
it('asks for the time right after the title and for the description last', () => {
|
||||
editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) });
|
||||
const order = ['form.title', 'form.all_day_event', 'form.start_date', 'form.end_time', 'recurrence.title', 'form.calendar_select',
|
||||
'form.location', 'form.meeting_link', 'participants.title', 'alerts.title', 'form.description'];
|
||||
const nodes = order.map((label) => screen.getAllByText(label).find((el) => el.closest('label')) ?? screen.getAllByText(label)[0]);
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
const before = nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING;
|
||||
expect(before, `${order[i - 1]} before ${order[i]}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain words (pim-19, pim-22)', () => {
|
||||
it('says "Not answered yet" for an attendee who has not answered', () => {
|
||||
const meeting = {
|
||||
...newYork,
|
||||
participants: {
|
||||
me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
render(<EventModal event={meeting} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />);
|
||||
expect(screen.getByText('status_needs_action')).toBeTruthy();
|
||||
expect(screen.getByText('status_accepted')).toBeTruthy();
|
||||
expect(screen.queryByText('participants.needs_action')).toBeNull();
|
||||
});
|
||||
|
||||
it('labels the grid preview of an untitled event in the user language', () => {
|
||||
const onPreviewChange = vi.fn();
|
||||
editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0), onPreviewChange });
|
||||
const last = onPreviewChange.mock.calls.filter(([p]) => p).pop()![0];
|
||||
expect(last.title).toBe('events.no_title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { QuickCreatePopover, computePosition } from '../quick-create-popover';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { AUTO_TIME_ZONE } from '@/lib/timezone';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// pim-2: the popover of an event in another zone said nothing about it.
|
||||
// pim-19: "Needs action" for an attendee who hasn't answered.
|
||||
// pim-21: the quick editor cut a long calendar name off.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
|
||||
}));
|
||||
|
||||
const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
|
||||
|
||||
function event(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'e1', calendarIds: { cal: true }, title: 'Call with New York office', start: '2026-11-05T10:00:00', duration: 'PT1H',
|
||||
timeZone: 'America/New_York', utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z',
|
||||
showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
|
||||
...overrides,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
function popover(e: CalendarEvent) {
|
||||
render(
|
||||
<EventDetailPopover event={e} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()}
|
||||
currentUserEmails={['me@example.com']} />,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
|
||||
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
|
||||
|
||||
describe('event popover', () => {
|
||||
it('shows the times on the event clock with the user times after them (pim-2)', () => {
|
||||
popover(event());
|
||||
expect(screen.getByTestId('event-zone-times').textContent)
|
||||
.toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}');
|
||||
});
|
||||
|
||||
it('names the weekday on the event clock when its day there is another', () => {
|
||||
// 08:00 on Friday in Tokyo is 00:00 on Friday in Berlin; in New York
|
||||
// (the user here) it is still Thursday.
|
||||
useSettingsStore.setState({ timeZone: 'America/New_York' });
|
||||
popover(event({ timeZone: 'Asia/Tokyo', start: '2026-11-06T08:00:00', utcStart: '2026-11-05T23:00:00Z', utcEnd: '2026-11-06T00:00:00Z' }));
|
||||
expect(screen.getByTestId('event-zone-times').textContent)
|
||||
.toBe('zone_times:{"times":"Fri 08:00–09:00","zone":"Tokyo","local":"18:00–19:00"}');
|
||||
});
|
||||
|
||||
it('shows the plain times for an event on the user clock, an all-day and a floating one', () => {
|
||||
popover(event({ timeZone: 'Europe/Berlin', start: '2026-11-05T16:00:00' }));
|
||||
popover(event({ id: 'e2', showWithoutTime: true, timeZone: null, start: '2026-11-05T00:00:00', duration: 'P1D' }));
|
||||
popover(event({ id: 'e3', timeZone: null, utcStart: undefined, utcEnd: undefined }));
|
||||
expect(screen.queryByTestId('event-zone-times')).toBeNull();
|
||||
});
|
||||
|
||||
it('says an attendee has not answered yet in plain words (pim-19)', () => {
|
||||
popover(event({
|
||||
participants: {
|
||||
me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
eve: { '@type': 'Participant', name: 'Eve', calendarAddress: 'mailto:eve@example.com', roles: { attendee: true }, participationStatus: 'tentative' },
|
||||
},
|
||||
} as unknown as Partial<CalendarEvent>));
|
||||
expect(screen.getByText('status_needs_action')).toBeTruthy();
|
||||
expect(screen.getByText('status_tentative')).toBeTruthy();
|
||||
expect(screen.queryByText('participants.needs_action')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick create (pim-21)', () => {
|
||||
const long = 'Stalwart Calendar (dso-ux-pim@example.org)';
|
||||
const calendars = [
|
||||
{ id: 'a', name: long, color: '#3b82f6' },
|
||||
{ id: 'b', name: 'Work', color: '#22c55e' },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
it('grows to fit the calendar names and names the calendar on hover', () => {
|
||||
render(
|
||||
<QuickCreatePopover draft={{ start: new Date(2026, 10, 15), end: new Date(2026, 10, 15), title: '', calendarId: 'a', allDay: true }}
|
||||
calendars={calendars} onChange={vi.fn()} onSave={vi.fn()} onMoreOptions={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
// As wide as its content (the select is as wide as its longest name), from 340 px on.
|
||||
expect(dialog.style.width).toBe('max-content');
|
||||
expect(dialog.style.minWidth).toBe('340px');
|
||||
expect(dialog.style.maxWidth).toContain('480px');
|
||||
expect(screen.getByRole('combobox').getAttribute('title')).toBe(long);
|
||||
});
|
||||
|
||||
it('places a wider popover by its own width', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1366, configurable: true });
|
||||
Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true });
|
||||
// Beside an anchor at the right: 460 px don't fit on the right side, so it goes left of it.
|
||||
const anchor = new DOMRect(1000, 300, 150, 20);
|
||||
expect(computePosition(anchor, 200, 340)).toEqual({ top: 300, left: 652 });
|
||||
expect(computePosition(anchor, 200, 460)).toEqual({ top: 300, left: 532 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
import { EventCard } from '../event-card';
|
||||
import { EventContextMenu } from '../event-context-menu';
|
||||
import { CalendarAgendaView } from '../calendar-agenda-view';
|
||||
import { gridReplyState } from '@/lib/calendar-reply-state';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
|
||||
// pim-9: an invitation the user hasn't answered looked like an accepted
|
||||
// event, a solid block. It is now outlined and hatched with a "?" / "Reply"
|
||||
// marker that offers the answers; "maybe" gets a dashed outline; the agenda a
|
||||
// hollow dot. With several logins, "the user" is the login the event is in.
|
||||
|
||||
const me = ['me@example.com'];
|
||||
const calendar = { id: 'cal-1', name: 'Work', color: '#2563eb' } as unknown as Calendar;
|
||||
|
||||
function invitation(myStatus: CalendarParticipant['participationStatus'] | undefined, extra: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
const mine: Record<string, unknown> = { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true } };
|
||||
if (myStatus) mine.participationStatus = myStatus;
|
||||
return {
|
||||
id: 'ev-1',
|
||||
'@type': 'Event',
|
||||
uid: 'inv-1',
|
||||
title: 'UXFIX-grid weekly sync',
|
||||
start: '2026-11-05T10:00:00',
|
||||
duration: 'PT1H',
|
||||
showWithoutTime: false,
|
||||
status: 'confirmed',
|
||||
calendarIds: { 'cal-1': true },
|
||||
organizerCalendarAddress: 'mailto:boss@example.com',
|
||||
participants: {
|
||||
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
me: mine,
|
||||
},
|
||||
...extra,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||
});
|
||||
|
||||
describe('gridReplyState', () => {
|
||||
it('tells an open invitation, a maybe, and nothing to answer apart', () => {
|
||||
expect(gridReplyState(invitation('needs-action'), me)).toBe('needs-action');
|
||||
// JSCalendar: no status is "not answered yet".
|
||||
expect(gridReplyState(invitation(undefined), me)).toBe('needs-action');
|
||||
expect(gridReplyState(invitation('tentative'), me)).toBe('tentative');
|
||||
expect(gridReplyState(invitation('accepted'), me)).toBeNull();
|
||||
expect(gridReplyState(invitation('needs-action', { status: 'cancelled' }), me)).toBeNull();
|
||||
// The organiser has nothing to answer.
|
||||
expect(gridReplyState(invitation('needs-action'), ['boss@example.com'])).toBeNull();
|
||||
expect(gridReplyState(invitation('needs-action'), [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventCard of an invitation', () => {
|
||||
it.each(['chip', 'span', 'block'] as const)('outlines and hatches an unanswered one, with a marker (%s)', (variant) => {
|
||||
const onReply = vi.fn();
|
||||
const event = invitation('needs-action');
|
||||
render(<EventCard event={event} calendar={calendar} variant={variant} currentUserEmails={me} onReply={onReply} />);
|
||||
const card = screen.getByRole('button', { name: /UXFIX-grid weekly sync/ });
|
||||
expect(card.dataset.replyState).toBe('needs-action');
|
||||
expect(card.style.boxShadow).toContain('inset 0 0 0 1.5px');
|
||||
expect(card.style.backgroundImage).toContain('repeating-linear-gradient');
|
||||
// Said in words too, not only by the look.
|
||||
expect(card.getAttribute('aria-label')).toContain('status_needs_action');
|
||||
const marker = screen.getByRole('button', { name: 'reply_to_invitation' });
|
||||
fireEvent.click(marker);
|
||||
expect(onReply).toHaveBeenCalledWith(marker, event);
|
||||
});
|
||||
|
||||
it('draws a maybe with a dashed outline and no marker', () => {
|
||||
render(<EventCard event={invitation('tentative')} calendar={calendar} variant="span" currentUserEmails={me} onReply={vi.fn()} />);
|
||||
const card = screen.getByRole('button');
|
||||
expect(card.dataset.replyState).toBe('tentative');
|
||||
expect(card.style.border).toContain('dashed');
|
||||
expect(screen.queryByRole('button', { name: 'reply_to_invitation' })).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves an accepted invitation solid', () => {
|
||||
render(<EventCard event={invitation('accepted')} calendar={calendar} variant="span" currentUserEmails={me} onReply={vi.fn()} />);
|
||||
const card = screen.getByRole('button');
|
||||
expect(card.dataset.replyState).toBeUndefined();
|
||||
expect(card.style.backgroundImage).toBe('');
|
||||
});
|
||||
|
||||
describe('with two logins', () => {
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({
|
||||
accounts: [
|
||||
{ id: 'acc-a', email: 'me@example.com' },
|
||||
{ id: 'acc-b', email: 'b@example.com' },
|
||||
] as never,
|
||||
activeAccountId: 'acc-a',
|
||||
});
|
||||
});
|
||||
|
||||
const ofB = (status: CalendarParticipant['participationStatus']) => {
|
||||
const event = invitation('accepted', { localAccountId: 'acc-b' } as Partial<CalendarEvent>);
|
||||
event.participants!.b = { '@type': 'Participant', calendarAddress: 'mailto:b@example.com', roles: { attendee: true }, participationStatus: status } as unknown as CalendarParticipant;
|
||||
return event;
|
||||
};
|
||||
|
||||
it('reads the other login\'s event with its own address', () => {
|
||||
// A (me@) accepted, B has not answered: the event is B's, so it is open.
|
||||
render(<EventCard event={ofB('needs-action')} calendar={calendar} variant="span" currentUserEmails={me} />);
|
||||
expect(screen.getByRole('button').dataset.replyState).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('strikes through what the other login declined', () => {
|
||||
render(<EventCard event={ofB('declined')} calendar={calendar} variant="span" currentUserEmails={me} />);
|
||||
expect(screen.getByRole('button').getAttribute('aria-label')).toContain('participants.declined');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventContextMenu answers', () => {
|
||||
const base = {
|
||||
event: invitation('needs-action'),
|
||||
position: { x: 10, y: 10 },
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDuplicate: vi.fn(),
|
||||
onExportICS: vi.fn(),
|
||||
onCopyTitle: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
it('offers Accept, Maybe and Decline above the rest', () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<EventContextMenu {...base} menuRef={createRef()} onRespond={onRespond} responseStatus="needs-action" />);
|
||||
const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
|
||||
expect(items.slice(0, 3)).toEqual(['participants.accept', 'participants.maybe', 'participants.decline']);
|
||||
expect(items).toContain('events.duplicate');
|
||||
fireEvent.click(screen.getByText('participants.maybe'));
|
||||
expect(onRespond).toHaveBeenCalledWith('tentative');
|
||||
});
|
||||
|
||||
it('leaves out the answer already given', () => {
|
||||
render(<EventContextMenu {...base} menuRef={createRef()} onRespond={vi.fn()} responseStatus="tentative" />);
|
||||
expect(screen.queryByText('participants.maybe')).toBeNull();
|
||||
expect(screen.getByText('participants.accept')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows only the answers from the marker', () => {
|
||||
render(<EventContextMenu {...base} menuRef={createRef()} onRespond={vi.fn()} answersOnly />);
|
||||
expect(screen.getAllByRole('menuitem').map((item) => item.textContent))
|
||||
.toEqual(['participants.accept', 'participants.maybe', 'participants.decline']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarAgendaView invitations', () => {
|
||||
it('draws a hollow dot and a Reply button', () => {
|
||||
const onReplyEvent = vi.fn();
|
||||
const event = invitation('needs-action');
|
||||
render(
|
||||
<CalendarAgendaView
|
||||
focus={{ date: new Date(2026, 10, 5), nonce: 0 }}
|
||||
windowKey="agenda:2026-11-05"
|
||||
rangeStart={new Date(2026, 10, 1)}
|
||||
rangeEnd={new Date(2026, 10, 30)}
|
||||
events={[event, invitation('tentative', { id: 'ev-2', uid: 'inv-2' })]}
|
||||
calendars={[calendar]}
|
||||
onSelectEvent={vi.fn()}
|
||||
currentUserEmails={me}
|
||||
onReplyEvent={onReplyEvent}
|
||||
/>,
|
||||
);
|
||||
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-agenda-dot]')).map((dot) => dot.dataset.agendaDot);
|
||||
expect(dots).toEqual(['needs-action', 'tentative']);
|
||||
const reply = screen.getByRole('button', { name: 'reply' });
|
||||
fireEvent.click(reply);
|
||||
expect(onReplyEvent).toHaveBeenCalledWith(reply, event);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { CalendarMonthView, fitMonthWeek, monthRowHeight } from '../calendar-month-view';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// pim-6: at 1366x768 the month showed 4½ weeks and at 150 % (911x512) 2½:
|
||||
// every row kept a 146px minimum. The month's weeks now share the pane and
|
||||
// a day shows what fits, then "+n more".
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function allDay(id: string, start: string): CalendarEvent {
|
||||
return {
|
||||
id, '@type': 'Event', uid: id, title: 'Event ' + id, start, duration: 'P1D', showWithoutTime: true,
|
||||
calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('monthRowHeight', () => {
|
||||
const metrics = { overlayTop: 30, rowHeight: 26, fallback: 146 };
|
||||
it('shares the pane between the weeks of the month', () => {
|
||||
expect(monthRowHeight(422, 6, metrics)).toBe(70);
|
||||
expect(monthRowHeight(422, 5, metrics)).toBe(84);
|
||||
});
|
||||
it('keeps the date and one entry in a tiny pane', () => {
|
||||
expect(monthRowHeight(120, 6, metrics)).toBe(64);
|
||||
});
|
||||
it('falls back where nothing was measured', () => {
|
||||
expect(monthRowHeight(0, 6, metrics)).toBe(146);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitMonthWeek', () => {
|
||||
it('shows a full day whole and cuts only the fuller one', () => {
|
||||
// Monday has three events, Tuesday two, room for two rows.
|
||||
const segments = [
|
||||
{ startIndex: 0, span: 1, row: 0 }, { startIndex: 0, span: 1, row: 1 }, { startIndex: 0, span: 1, row: 2 },
|
||||
{ startIndex: 1, span: 1, row: 0 }, { startIndex: 1, span: 1, row: 1 },
|
||||
];
|
||||
const rows = [3, 2, 0, 0, 0, 0, 0];
|
||||
const { limits, hidden } = fitMonthWeek(segments, rows, [0, 0, 0, 0, 0, 0, 0], 2);
|
||||
expect(limits.slice(0, 2)).toEqual([1, 2]);
|
||||
expect(hidden.slice(0, 2)).toEqual([2, 0]);
|
||||
});
|
||||
|
||||
it('counts an event over several days as more where it doesn\'t fit', () => {
|
||||
// A two-day event in row 1 over Monday (full) and Tuesday.
|
||||
const segments = [
|
||||
{ startIndex: 0, span: 1, row: 0 }, { startIndex: 0, span: 1, row: 2 },
|
||||
{ startIndex: 0, span: 2, row: 1 },
|
||||
];
|
||||
const { limits, hidden, isShown } = fitMonthWeek(segments, [3, 2, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 2);
|
||||
expect(isShown(segments[2])).toBe(false);
|
||||
expect(hidden.slice(0, 2)).toEqual([2, 1]);
|
||||
expect(limits[1]).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the one row for an entry when the link goes beside the date', () => {
|
||||
const segments = [{ startIndex: 2, span: 1, row: 0 }, { startIndex: 2, span: 1, row: 1 }];
|
||||
const { limits, hidden, isShown } = fitMonthWeek(segments, [0, 0, 2, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 1, true);
|
||||
expect(limits[2]).toBe(1);
|
||||
expect(hidden[2]).toBe(1);
|
||||
expect(isShown(segments[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "+n more" alone where only one row fits', () => {
|
||||
const segments = [{ startIndex: 2, span: 1, row: 0 }, { startIndex: 2, span: 1, row: 1 }, { startIndex: 3, span: 1, row: 0 }];
|
||||
const { limits, hidden } = fitMonthWeek(segments, [0, 0, 2, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 1);
|
||||
expect(limits[2]).toBe(0);
|
||||
expect(hidden[2]).toBe(2);
|
||||
expect(limits[3]).toBe(1);
|
||||
expect(hidden[3]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarMonthView at 150 % (911x512)', () => {
|
||||
function renderAt(paneHeight: number, focus: Date, rangeStart: Date, rangeEnd: Date, events: CalendarEvent[] = []) {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
||||
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(paneHeight);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(33);
|
||||
render(
|
||||
<CalendarMonthView
|
||||
focus={{ date: focus, nonce: 0 }}
|
||||
windowKey={`month:${focus.toISOString().slice(0, 10)}`}
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
selectedDate={focus}
|
||||
events={events}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[data-week]'));
|
||||
}
|
||||
|
||||
it('fits all five weeks of September in the pane', () => {
|
||||
// The pane under the toolbar: 455px, 33 of them the weekday header.
|
||||
const rows = renderAt(455, new Date(2026, 8, 9), new Date(2026, 7, 31), new Date(2026, 9, 4));
|
||||
expect(rows).toHaveLength(5);
|
||||
const heights = rows.map((row) => parseInt(row.style.minHeight, 10));
|
||||
expect(heights.every((h) => h === 84)).toBe(true);
|
||||
expect(heights.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(455 - 33);
|
||||
});
|
||||
|
||||
it('fits six weeks of November and puts the "+n" of a full day beside its date', () => {
|
||||
const rows = renderAt(455, new Date(2026, 10, 4), new Date(2026, 9, 26), new Date(2026, 11, 6), [
|
||||
allDay('a', '2026-11-04T00:00:00'),
|
||||
allDay('b', '2026-11-04T00:00:00'),
|
||||
allDay('c', '2026-11-05T00:00:00'),
|
||||
]);
|
||||
expect(rows).toHaveLength(6);
|
||||
expect(rows.every((row) => row.style.minHeight === '70px')).toBe(true);
|
||||
// One row fits: Wednesday shows its first event and "+1" beside the
|
||||
// date, Thursday its one event.
|
||||
expect(screen.getByText('Event a')).toBeTruthy();
|
||||
expect(screen.queryByText('Event b')).toBeNull();
|
||||
expect(screen.getByText('Event c')).toBeTruthy();
|
||||
const more = screen.getAllByRole('button', { name: 'events.more' });
|
||||
expect(more).toHaveLength(1);
|
||||
expect(more[0].textContent).toBe('+1');
|
||||
expect(more[0].parentElement?.style.top).toBe('4px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TaskListView } from '../task-list-view';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
|
||||
// pim-24: "Call the plumber tomorrow" in "Add a task…" made a task with that
|
||||
// title and no due date. The date at the end is now the due date, shown as
|
||||
// a chip before the task is made.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 10, 4, 9, 0));
|
||||
useCalendarStore.setState({ hiddenTaskListIds: [] } as never);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function setup() {
|
||||
const onQuickCreate = vi.fn();
|
||||
render(<TaskListView tasks={[]} calendars={[]} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} onQuickCreate={onQuickCreate} />);
|
||||
const input = screen.getByLabelText('tasks.quick_add_placeholder');
|
||||
return { input, onQuickCreate };
|
||||
}
|
||||
|
||||
describe('quick task due date (pim-24)', () => {
|
||||
it('shows the date as a chip and makes the task due then, without the word', () => {
|
||||
const { input, onQuickCreate } = setup();
|
||||
fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } });
|
||||
expect(screen.getByTestId('quick-task-due')).toHaveTextContent('quick_task_due');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber', '2026-11-05');
|
||||
});
|
||||
|
||||
it('keeps the words in the title when the chip is dismissed', () => {
|
||||
const { input, onQuickCreate } = setup();
|
||||
fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'quick_task_due_remove' }));
|
||||
expect(screen.queryByTestId('quick-task-due')).toBeNull();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber tomorrow', null);
|
||||
});
|
||||
|
||||
it('makes a task without a date as before', () => {
|
||||
const { input, onQuickCreate } = setup();
|
||||
fireEvent.change(input, { target: { value: 'Water the plants' } });
|
||||
expect(screen.queryByTestId('quick-task-due')).toBeNull();
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onQuickCreate).toHaveBeenCalledWith('Water the plants', null);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ describe('participation status colours', () => {
|
||||
|
||||
it('colours a participant\'s answer the same way', () => {
|
||||
render(<ParticipantStatusBadge status="accepted" />);
|
||||
expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
|
||||
// In plain words (pim-19): ux_pim_event.status_accepted.
|
||||
expect(screen.getByText('status_accepted').className).toContain('text-green-700');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ const calendars = [{ id: 'cal', name: 'Privat', color: '#123456' }] as unknown a
|
||||
describe('TaskListView due dates', () => {
|
||||
it('writes the due date in the app language', () => {
|
||||
const task = { id: 't', title: 'Steuer', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2031-10-30', showWithoutTime: true } as unknown as CalendarTask;
|
||||
render(<TaskListView tasks={[task]} calendars={calendars} selectedCalendarIds={['cal']} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
render(<TaskListView tasks={[task]} calendars={calendars} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(screen.getByText('30. Okt.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Oct 30')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TaskListView, defaultTaskList, taskListsOf } from '../task-list-view';
|
||||
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
|
||||
import { TaskModal } from '../task-modal';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// pim-1: tasks in a task-only list ("To-do", as Thunderbird, Apple Reminders
|
||||
// and DAVx⁵ make them) never showed, while the sidebar counted them: the
|
||||
// list filtered by the grid's calendar selection, which leaves task-only
|
||||
// lists out. A task moved into To-do vanished on save.
|
||||
|
||||
function cal(id: string, name: string, extra: Partial<Calendar> = {}): Calendar {
|
||||
return {
|
||||
id, name, color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: false,
|
||||
includeInAvailability: 'all', timeZone: null, shareWith: null,
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
...extra,
|
||||
} as unknown as Calendar;
|
||||
}
|
||||
|
||||
function task(id: string, calendarId: string, extra: Partial<CalendarTask> = {}): CalendarTask {
|
||||
return {
|
||||
id, calendarIds: { [calendarId]: true }, '@type': 'Task', uid: id, title: id, description: '',
|
||||
due: null, start: null, duration: null, timeZone: null, showWithoutTime: true, progress: 'needs-action',
|
||||
priority: 0, privacy: 'public', keywords: null, categories: null, color: null, created: null,
|
||||
updated: '2026-11-01T00:00:00Z', recurrenceRules: null, alerts: null, relatedTo: null,
|
||||
...extra,
|
||||
} as CalendarTask;
|
||||
}
|
||||
|
||||
const home = cal('home', 'Home', { isDefault: true });
|
||||
const todo = cal('todo', 'To-do', { isTasksOnly: true });
|
||||
const work = cal('work', 'Work');
|
||||
|
||||
beforeEach(() => {
|
||||
// The grid shows Home and Work, as fetchCalendars leaves it: never To-do.
|
||||
useCalendarStore.setState({ calendars: [home, todo, work], selectedCalendarIds: ['home', 'work'], hiddenTaskListIds: [], viewMode: 'tasks' } as never);
|
||||
useSettingsStore.setState({ enableCalendarTasks: true } as never);
|
||||
useTaskStore.setState({ tasks: [] } as never);
|
||||
});
|
||||
|
||||
const listed = () => Array.from(document.querySelectorAll('span[title^="t-"]')).map((el) => el.getAttribute('title'));
|
||||
|
||||
describe('the task list (pim-1)', () => {
|
||||
const tasks = [
|
||||
task('t-open', 'todo', { due: '2020-01-01T10:00:00' }),
|
||||
task('t-done', 'todo', { progress: 'completed' }),
|
||||
task('t-work', 'work'),
|
||||
];
|
||||
|
||||
it('lists tasks of a task-only list in every filter', () => {
|
||||
const { rerender } = render(<TaskListView tasks={tasks} calendars={[home, todo, work]} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-done', 't-work']));
|
||||
rerender(<TaskListView tasks={tasks} calendars={[home, todo, work]} filter="pending" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-work']));
|
||||
rerender(<TaskListView tasks={tasks} calendars={[home, todo, work]} filter="overdue" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(['t-open']);
|
||||
rerender(<TaskListView tasks={tasks} calendars={[home, todo, work]} filter="completed" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(['t-done']);
|
||||
});
|
||||
|
||||
it('keeps a task moved into To-do in the list', () => {
|
||||
const { rerender } = render(<TaskListView tasks={[task('t-moved', 'work')]} calendars={[home, todo, work]} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(['t-moved']);
|
||||
rerender(<TaskListView tasks={[task('t-moved', 'todo')]} calendars={[home, todo, work]} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(['t-moved']);
|
||||
});
|
||||
|
||||
it('leaves out the lists the user unticked, not the calendars the grid hides', () => {
|
||||
useCalendarStore.setState({ hiddenTaskListIds: ['todo'], selectedCalendarIds: [] } as never);
|
||||
render(<TaskListView tasks={tasks} calendars={[home, todo, work]} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
|
||||
expect(listed()).toEqual(['t-work']);
|
||||
});
|
||||
|
||||
it('offers task-only lists and calendars holding tasks as task lists', () => {
|
||||
expect(taskListsOf([home, todo, work], tasks).map((c) => c.id)).toEqual(['todo', 'work']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sidebar in the tasks view (pim-1)', () => {
|
||||
const client = { getAccountId: () => 'a' } as unknown as IJMAPClient;
|
||||
|
||||
it('lists the task lists, ticked, and unticking one hides its tasks', () => {
|
||||
useTaskStore.setState({ tasks: [task('t-open', 'todo')] } as never);
|
||||
render(<CalendarSidebarPanel calendars={[home, todo, work]} selectedCalendarIds={['home', 'work']} onToggleVisibility={() => {}} client={client} />);
|
||||
const section = screen.getByTestId('task-lists-section');
|
||||
const item = within(section).getByRole('checkbox', { name: /To-do/ });
|
||||
expect(item).toHaveAttribute('aria-checked', 'true');
|
||||
// The event calendars aren't offered here: they only steer the grid.
|
||||
expect(screen.queryByTestId('calendar-item')).toBeNull();
|
||||
fireEvent.click(item);
|
||||
expect(useCalendarStore.getState().hiddenTaskListIds).toEqual(['todo']);
|
||||
expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['home', 'work']);
|
||||
});
|
||||
|
||||
it('shows the event calendars again outside the tasks view', () => {
|
||||
useCalendarStore.setState({ viewMode: 'month' } as never);
|
||||
render(<CalendarSidebarPanel calendars={[home, todo, work]} selectedCalendarIds={['home', 'work']} onToggleVisibility={() => {}} client={client} />);
|
||||
expect(screen.queryByTestId('task-lists-section')).toBeNull();
|
||||
expect(screen.getAllByTestId('calendar-item').map((el) => el.dataset.calendarName)).toEqual(['Home', 'Work']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('where a new task goes (pim-8)', () => {
|
||||
it('picks the first task-only list, else the default calendar', () => {
|
||||
expect(defaultTaskList([home, work, todo])?.id).toBe('todo');
|
||||
expect(defaultTaskList([work, home])?.id).toBe('home');
|
||||
expect(defaultTaskList([work])?.id).toBe('work');
|
||||
});
|
||||
|
||||
// A colleague's shared to-do list, or another login's, came before the
|
||||
// user's own default calendar.
|
||||
it("never picks someone else's or another login's list over the user's own", () => {
|
||||
const shared = { ...todo, id: 'shared-todo', isShared: true } as Calendar;
|
||||
const otherLogin = { ...todo, id: 'b:todo', localAccountId: 'login-b' } as Calendar;
|
||||
expect(defaultTaskList([shared, otherLogin, work, home])?.id).toBe('home');
|
||||
expect(defaultTaskList([shared, otherLogin])?.id).toBe('shared-todo');
|
||||
});
|
||||
|
||||
it('opens the task form on the task-only list', () => {
|
||||
render(<TaskModal calendars={[home, work, todo]} onSave={vi.fn()} onClose={() => {}} />);
|
||||
expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task status words (pim-19)', () => {
|
||||
it('says "Not started" and "In progress" and keeps the stored values', () => {
|
||||
render(<TaskModal calendars={[home]} onSave={vi.fn()} onClose={() => {}} />);
|
||||
const select = screen.getByLabelText('tasks.progress') as HTMLSelectElement;
|
||||
const options = Array.from(select.options).map((o) => [o.value, o.textContent]);
|
||||
expect(options.slice(0, 2)).toEqual([['needs-action', 'task_status_not_started'], ['in-process', 'task_status_in_progress']]);
|
||||
});
|
||||
});
|
||||
@@ -103,7 +103,8 @@ describe('task-list-actions', () => {
|
||||
makeTask('soon', { due: '2030-01-01' }),
|
||||
];
|
||||
// All is all of them: completed ones too, at the end.
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']);
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']);
|
||||
// The second argument names the task lists the user unticked (pim-1).
|
||||
expect(filterTasksForList(tasks, ['cal-2'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']);
|
||||
expect(filterTasksForList(tasks, ['cal-2'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
import { gridReplyState } from "@/lib/calendar-reply-state";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
|
||||
interface CalendarAgendaViewProps extends ScrollWindowViewProps {
|
||||
events: CalendarEvent[];
|
||||
@@ -24,6 +27,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps {
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
/** The "Reply" button of an unanswered invitation (pim-9). */
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
/** The user's calendar addresses, to mark events they declined (#1110). */
|
||||
currentUserEmails?: string[];
|
||||
@@ -51,12 +56,17 @@ export function CalendarAgendaView({
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onReplyEvent,
|
||||
timeFormat = "24h",
|
||||
currentUserEmails,
|
||||
onCreateEvent,
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tEvent = useTranslations("ux_pim_event");
|
||||
const tGrid = useTranslations("ux_pim_grid");
|
||||
const paint = useCvdColor();
|
||||
// An event of another signed-in login is read with that login's addresses.
|
||||
const userEmailsFor = useEventUserEmails(currentUserEmails ?? []);
|
||||
// Grid days / event dates are display dates (local fields = wall-clock in
|
||||
// the user's zone); the app-wide formatter would shift them again (#755).
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
@@ -248,20 +258,27 @@ export function CalendarAgendaView({
|
||||
// iTIP CANCEL marks the attendee's copy with status "cancelled"
|
||||
// instead of deleting it (#572); a declined invitation stays
|
||||
// listed too (#1110).
|
||||
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
const userEmails = userEmailsFor(ev);
|
||||
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, userEmails);
|
||||
// Not answered yet: a hollow dot and "Reply"; maybe: a dashed ring (pim-9).
|
||||
const replyState = isInactive ? null : gridReplyState(ev, userEmails);
|
||||
const locationName = ev.locations
|
||||
? Object.values(ev.locations)[0]?.name
|
||||
: null;
|
||||
const meta = [locationName, calendar?.name].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
const row = (
|
||||
<button
|
||||
key={ev.id}
|
||||
data-reply-state={replyState ?? undefined}
|
||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex flex-wrap min-[30rem]:flex-nowrap items-start gap-x-3 gap-y-0.5 px-2 rounded-md hover:bg-muted/60 transition-colors text-start"
|
||||
className={cn(
|
||||
"w-full flex flex-wrap min-[30rem]:flex-nowrap items-start gap-x-3 gap-y-0.5 px-2 rounded-md hover:bg-muted/60 transition-colors text-start",
|
||||
replyState === "needs-action" && onReplyEvent && "pe-24",
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* On a phone the time takes a line of its own: beside the date column it left the title two words. */}
|
||||
@@ -273,7 +290,12 @@ export function CalendarAgendaView({
|
||||
declined or cancelled (repos/branding/APP.md). */}
|
||||
<span
|
||||
className="w-[9px] h-[9px] mt-[5.5px] rounded-full flex-shrink-0"
|
||||
style={isInactive ? { boxShadow: `inset 0 0 0 1.5px ${color}` } : { backgroundColor: color }}
|
||||
style={replyState === "tentative"
|
||||
? { border: `1.5px dashed ${color}` }
|
||||
: isInactive || replyState
|
||||
? { boxShadow: `inset 0 0 0 1.5px ${color}` }
|
||||
: { backgroundColor: color }}
|
||||
data-agenda-dot={replyState ?? (isInactive ? "inactive" : "filled")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -284,6 +306,11 @@ export function CalendarAgendaView({
|
||||
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
|
||||
{ev.title || t("events.no_title")}
|
||||
</span>
|
||||
{replyState && (
|
||||
<span className="ms-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||
{tEvent(replyState === "tentative" ? "status_tentative" : "status_needs_action")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(meta || getParticipantCount(ev) > 0) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
|
||||
@@ -300,6 +327,23 @@ export function CalendarAgendaView({
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
if (replyState !== "needs-action" || !onReplyEvent) return row;
|
||||
// The answers, next to the row rather than inside its button.
|
||||
return (
|
||||
<div key={ev.id} className="relative">
|
||||
{row}
|
||||
<button
|
||||
type="button"
|
||||
data-reply-marker
|
||||
title={tGrid("reply_to_invitation")}
|
||||
onClick={(e) => { e.stopPropagation(); onReplyEvent(e.currentTarget, ev); }}
|
||||
className="absolute end-2 top-1 h-6 max-w-20 truncate rounded-xs px-2 text-xs font-semibold hover:opacity-90"
|
||||
style={{ backgroundColor: color, color: readableTextOn(color) }}
|
||||
>
|
||||
{tGrid("reply")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,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, filterTasksForList } from "@/components/calendar/task-list-view";
|
||||
import { TaskListView, filterTasksForList, defaultTaskList } 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";
|
||||
@@ -58,6 +58,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { descriptionWithNote } from "@/lib/calendar-description";
|
||||
@@ -69,7 +70,7 @@ import { getClientByLocalAccountId } from "@/stores/client-registry";
|
||||
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { canCreateEventsIn, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
|
||||
import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
|
||||
import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover";
|
||||
import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -228,6 +229,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
),
|
||||
[participantIdentities, identities, accountEmails]
|
||||
);
|
||||
// An event of another signed-in login is answered as that login (pim-12).
|
||||
const userEmailsFor = useEventUserEmails(currentUserEmails);
|
||||
const resolveContactName = useContactNameResolver();
|
||||
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
@@ -573,12 +576,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}
|
||||
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded, proInterfaceActive]);
|
||||
|
||||
// Pro shell only: aggregate calendars and events from every connected
|
||||
// account so the sidebar lists them all (and the views render their
|
||||
// events together). The hook is a no-op outside the embedded shell.
|
||||
// Aggregate calendars and events from every connected account so the
|
||||
// sidebar lists them all (and the views render their events together):
|
||||
// in the Pro shell, and with two or more signed-in logins (pim-12). The
|
||||
// standard instance behind the Pro shell leaves the loading to the shell.
|
||||
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||
isEmbedded ? dateRange?.start ?? null : null,
|
||||
isEmbedded ? dateRange?.end ?? null : null,
|
||||
isEmbedded || !proInterfaceActive ? dateRange?.start ?? null : null,
|
||||
isEmbedded || !proInterfaceActive ? dateRange?.end ?? null : null,
|
||||
);
|
||||
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
@@ -870,9 +874,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
closeContextMenu: closeEventContextMenu,
|
||||
menuRef: eventContextMenuRef,
|
||||
} = useContextMenu<CalendarEvent>();
|
||||
// The grid's "?" / "Reply" marker opens the answers alone (pim-9).
|
||||
const [answersOnlyMenu, setAnswersOnlyMenu] = useState(false);
|
||||
|
||||
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
closeDetail();
|
||||
setAnswersOnlyMenu(false);
|
||||
openEventContextMenu(e, event);
|
||||
}, [closeDetail, openEventContextMenu]);
|
||||
|
||||
@@ -1358,7 +1365,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
// One event: asked here, from every place Delete is offered (R3). A
|
||||
// series gets the scope question above instead.
|
||||
const title = eventToDelete?.title || t("events.no_title");
|
||||
const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails);
|
||||
const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, userEmailsFor(eventToDelete));
|
||||
const ok = await confirmAction({
|
||||
title: t("delete_dialog.event_title"),
|
||||
message: cancels
|
||||
@@ -1378,7 +1385,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
} catch (error) {
|
||||
toast.error(describeError(error, t("notifications.event_delete_error")));
|
||||
}
|
||||
}, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]);
|
||||
}, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, userEmailsFor, describeError, t]);
|
||||
|
||||
const submitRsvp = useCallback(async (
|
||||
eventId: string,
|
||||
@@ -1423,7 +1430,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
updates,
|
||||
editor,
|
||||
sendScheduling,
|
||||
userEmails: currentUserEmails,
|
||||
userEmails: userEmailsFor(event),
|
||||
resolveName: resolveContactName,
|
||||
}, { client, updateEvent, createEvent, findMasterEvent });
|
||||
done = saved !== "stopped";
|
||||
@@ -1438,7 +1445,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const deleted = await deleteOccurrence(scope, {
|
||||
event,
|
||||
sendScheduling,
|
||||
userEmails: currentUserEmails,
|
||||
userEmails: userEmailsFor(event),
|
||||
}, { client, updateEvent, deleteEvent, findMasterEvent });
|
||||
done = deleted !== "stopped";
|
||||
if (!deleted) {
|
||||
@@ -1461,7 +1468,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}
|
||||
toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error")));
|
||||
}
|
||||
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
|
||||
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, userEmailsFor, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
|
||||
|
||||
// A drag or resize. A series asks which occurrences it covers, as the
|
||||
// editor does; one event moves at once and can be moved back.
|
||||
@@ -1609,11 +1616,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
if (!detailEvent || !client) return;
|
||||
// A target names the event and participant: the owner's in a calendar
|
||||
// shared with the user, or the user's own copy (lib/calendar-delegate.ts).
|
||||
const participantId = target?.participantId ?? getUserParticipantId(detailEvent, currentUserEmails);
|
||||
const participantId = target?.participantId ?? getUserParticipantId(detailEvent, userEmailsFor(detailEvent));
|
||||
if (!participantId) return;
|
||||
closeDetail();
|
||||
await handleRsvp(target?.eventId ?? detailEvent.id, participantId, status, target?.sentBy);
|
||||
}, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]);
|
||||
}, [detailEvent, client, userEmailsFor, closeDetail, handleRsvp]);
|
||||
|
||||
// `/` and the palette's "Search events" go to the search box.
|
||||
const openSearch = useCallback(() => {
|
||||
@@ -1673,6 +1680,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
useAppCommands({
|
||||
"new-event": () => openCreateModal(),
|
||||
"new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
|
||||
"import-calendar": () => { if (!demoMode) setShowImportModal(true); },
|
||||
}, true);
|
||||
// Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
|
||||
const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
|
||||
@@ -1689,6 +1697,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" },
|
||||
"edit.search": normalizedViewMode !== "tasks",
|
||||
"file.newTask": enableCalendarTasks,
|
||||
"file.importCalendar": !demoMode,
|
||||
});
|
||||
|
||||
useRegisterCommands(tSidebar("calendar"), [
|
||||
@@ -1795,6 +1804,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
[editabilityContext],
|
||||
);
|
||||
|
||||
// Answering an invitation from the grid (pim-9), as the login the event is
|
||||
// in, the popover's way (handleRsvp: a series asks "this event or all").
|
||||
const gridAnswerFor = useCallback((event: CalendarEvent) => {
|
||||
const emails = userEmailsFor(event);
|
||||
if (!canUserRsvp(event, { ...editabilityContext, userCalendarAddresses: emails })) return null;
|
||||
const participantId = getUserParticipantId(event, emails);
|
||||
return participantId ? { participantId, status: event.participants?.[participantId]?.participationStatus ?? null } : null;
|
||||
}, [userEmailsFor, editabilityContext]);
|
||||
const handleRespondFromGrid = useCallback((event: CalendarEvent, status: CalendarParticipant['participationStatus']) => {
|
||||
const answer = gridAnswerFor(event);
|
||||
if (answer) void handleRsvp(event.id, answer.participantId, status);
|
||||
}, [gridAnswerFor, handleRsvp]);
|
||||
const handleReplyEvent = useCallback((anchor: HTMLElement, event: CalendarEvent) => {
|
||||
closeDetail();
|
||||
// Nothing to answer here (no right to): the event's card says why.
|
||||
if (!gridAnswerFor(event)) { handleSelectEvent(event, anchor.getBoundingClientRect()); return; }
|
||||
setAnswersOnlyMenu(true);
|
||||
openEventContextMenuFrom(anchor, event, "end");
|
||||
}, [closeDetail, gridAnswerFor, handleSelectEvent, openEventContextMenuFrom]);
|
||||
|
||||
// The event the user opened, marked in the grid (APP.md: the selected
|
||||
// event gets the primary ring).
|
||||
const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null;
|
||||
@@ -1819,10 +1848,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
// The plugin drawing the task view's list, if one offers it.
|
||||
const pluginTaskView = usePluginTaskView();
|
||||
const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds);
|
||||
// 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),
|
||||
[taskStore.tasks, selectedCalendarIds, taskStore.filter],
|
||||
() => filterTasksForList(taskStore.tasks, hiddenTaskListIds, taskStore.filter).map((task) => task.id),
|
||||
[taskStore.tasks, hiddenTaskListIds, taskStore.filter],
|
||||
);
|
||||
|
||||
// Tasks on the month, week and day grids follow the calendar filter like
|
||||
@@ -1953,6 +1983,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onReplyEvent={handleReplyEvent}
|
||||
onCreateAllDay={handleGridCreateAllDay}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
@@ -1980,6 +2011,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onReplyEvent={handleReplyEvent}
|
||||
onCreateAtTime={handleGridCreate}
|
||||
onCreateAllDay={handleGridCreateAllDay}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
@@ -2008,6 +2040,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onReplyEvent={handleReplyEvent}
|
||||
onCreateAtTime={handleGridCreate}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -2032,6 +2065,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onReplyEvent={handleReplyEvent}
|
||||
timeFormat={timeFormat}
|
||||
currentUserEmails={currentUserEmails}
|
||||
onCreateEvent={() => openCreateModal()}
|
||||
@@ -2042,20 +2076,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
<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];
|
||||
onQuickCreate={(title, due) => {
|
||||
// Into the first task-only list, else the default calendar,
|
||||
// else the first the user may write to (pim-8).
|
||||
const target = defaultTaskList(writableTaskCalendars);
|
||||
if (!client || !target) {
|
||||
toast.error(t("notifications.task_save_error"));
|
||||
return;
|
||||
}
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true }, ...(due ? { due, showWithoutTime: true } : {}) })
|
||||
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
|
||||
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
|
||||
}}
|
||||
@@ -2383,9 +2416,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* Desktop event panel */}
|
||||
{/* Desktop event panel: a sheet over the grid from the right, as
|
||||
on small screens, so the week keeps its width (pim-25). Above
|
||||
the views' sticky headers (z-50) inside this isolated row. */}
|
||||
{!isMobile && showEventModal && (
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<div data-testid="event-panel" className="absolute inset-y-0 end-0 z-[55] w-[400px] max-w-full border-s border-border bg-background shadow-xl overflow-hidden">
|
||||
<EventModal
|
||||
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
event={editEvent}
|
||||
@@ -2404,7 +2439,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onDirtyChange={setEventEditorDirty}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
currentUserEmails={userEmailsFor(editEvent)}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
maxParticipants={maxParticipants}
|
||||
isMobile={false}
|
||||
@@ -2477,6 +2512,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||
onPrint={printOff ? undefined : () => { void requestPrint({ kind: "calendar-event", event: eventContextMenu.data! }); }}
|
||||
editable={canEditEvent(eventContextMenu.data)}
|
||||
onRespond={gridAnswerFor(eventContextMenu.data) ? (status) => handleRespondFromGrid(eventContextMenu.data!, status) : undefined}
|
||||
responseStatus={gridAnswerFor(eventContextMenu.data)?.status}
|
||||
answersOnly={answersOnlyMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2541,7 +2579,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onRsvp={handleRsvpFromDetail}
|
||||
onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }}
|
||||
onMouseLeave={handleHoverLeave}
|
||||
currentUserEmails={currentUserEmails}
|
||||
currentUserEmails={userEmailsFor(detailEvent)}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -2567,7 +2605,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
|
||||
onDirtyChange={setEventEditorDirty}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
currentUserEmails={userEmailsFor(editEvent)}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
maxParticipants={maxParticipants}
|
||||
isMobile={true}
|
||||
@@ -2650,7 +2688,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
note={pendingScopeAction?.type === "delete" && pendingScopeAction.sendScheduling
|
||||
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
|
||||
&& userOrganizes(pendingScopeAction.event, userEmailsFor(pendingScopeAction.event))
|
||||
? t("participants.cancel_notification")
|
||||
: undefined}
|
||||
eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
|
||||
|
||||
@@ -34,6 +34,8 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
/** The "?" / "Reply" marker of an unanswered invitation (pim-9). */
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
@@ -80,6 +82,7 @@ export function CalendarDayView({
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onReplyEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
@@ -448,6 +451,7 @@ export function CalendarDayView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onReply={onReplyEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={key === visibleKey}
|
||||
/>
|
||||
@@ -579,6 +583,7 @@ export function CalendarDayView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onReply={onReplyEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={key === visibleKey}
|
||||
draggable={editable}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -36,6 +37,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
/** The "?" / "Reply" marker of an unanswered invitation (pim-9). */
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
/** A click on a day's empty space: a new all-day event there (wide screens). */
|
||||
onCreateAllDay?: (date: Date) => void;
|
||||
/** Open a day in the day view (its date number, or its "N more" link). */
|
||||
@@ -78,6 +81,67 @@ export function monthGridMetrics(isMobile: boolean, textScale: number) {
|
||||
return { overlayTop, rowHeight, chipHeight, baseRowMinHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* The height of a week on a wide screen (pim-6): the weeks of the month in
|
||||
* view share the pane, so the whole month shows without scrolling, at 150 %
|
||||
* zoom too. Never less than the date and one entry.
|
||||
*/
|
||||
export function monthRowHeight(
|
||||
availableHeight: number,
|
||||
weeksInMonth: number,
|
||||
{ overlayTop, rowHeight, fallback }: { overlayTop: number; rowHeight: number; fallback: number },
|
||||
): number {
|
||||
if (availableHeight <= 0) return fallback;
|
||||
return Math.max(overlayTop + 4 + rowHeight + 4, Math.floor(availableHeight / Math.max(1, weeksInMonth)));
|
||||
}
|
||||
|
||||
interface FitSegment { startIndex: number; span: number; row: number }
|
||||
|
||||
/**
|
||||
* What a fixed-height week shows of each day (pim-6). A day with no more
|
||||
* entries than `capacity` rows shows them all; a fuller one shows one row
|
||||
* fewer and "+n more" in the last. An event over several days shows only
|
||||
* where all its days have room for its row, and counts as more elsewhere.
|
||||
* `limits[d]` is the first row day d doesn't draw an entry in (its "+n more"
|
||||
* row when `hidden[d]` > 0). With `moreInHeader` the link sits beside the
|
||||
* date instead and every row shows an entry: a week of one row (150 % zoom)
|
||||
* shows each day's first event, not a lone "+2 more".
|
||||
*/
|
||||
export function fitMonthWeek(
|
||||
segments: FitSegment[],
|
||||
dayEventRows: number[],
|
||||
dayTaskCounts: number[],
|
||||
capacity: number,
|
||||
moreInHeader = false,
|
||||
): { limits: number[]; hidden: number[]; isShown: (segment: FitSegment) => boolean } {
|
||||
const limits = dayEventRows.map((rows, d) => (!moreInHeader && rows + dayTaskCounts[d] > capacity ? capacity - 1 : capacity));
|
||||
const isShown = (segment: FitSegment) => {
|
||||
for (let d = segment.startIndex; d < segment.startIndex + segment.span; d++) {
|
||||
if (segment.row >= limits[d]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const countHidden = () => limits.map((limit, d) => {
|
||||
const events = segments.filter((s) => d >= s.startIndex && d < s.startIndex + s.span && !isShown(s)).length;
|
||||
const tasks = dayTaskCounts[d] - Math.min(dayTaskCounts[d], Math.max(0, limit - dayEventRows[d]));
|
||||
return events + tasks;
|
||||
});
|
||||
let hidden = countHidden();
|
||||
// An event hidden for a neighbour's sake needs a "+n more" row here too.
|
||||
for (let pass = 0; !moreInHeader && pass < limits.length; pass++) {
|
||||
let changed = false;
|
||||
hidden.forEach((count, d) => {
|
||||
if (count > 0 && limits[d] === capacity) {
|
||||
limits[d] = capacity - 1;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (!changed) break;
|
||||
hidden = countHidden();
|
||||
}
|
||||
return { limits, hidden, isShown };
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
selectedDate,
|
||||
focus,
|
||||
@@ -96,6 +160,7 @@ export function CalendarMonthView({
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onReplyEvent,
|
||||
onCreateAllDay,
|
||||
onOpenDay,
|
||||
isMobile,
|
||||
@@ -111,6 +176,8 @@ export function CalendarMonthView({
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const describeError = useCalendarErrorMessage();
|
||||
// An event of another signed-in login is read with that login's addresses.
|
||||
const userEmailsFor = useEventUserEmails(currentUserEmails ?? []);
|
||||
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
|
||||
const timedStyle = useSettingsStore((state) => state.calendarMonthTimedEvents);
|
||||
// On mobile the month view collapses events to dots unless the user opted
|
||||
@@ -222,12 +289,20 @@ export function CalendarMonthView({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const availableHeight = Math.max(0, viewportHeight - headerHeight);
|
||||
const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(availableHeight / 6));
|
||||
// Wide screens: the weeks of the month navigated to fill the pane (pim-6),
|
||||
// also while scrolling freely, so the rows keep their height. Phones keep
|
||||
// six rows that grow with their chips.
|
||||
const weeksInMonth = Math.max(4, Math.round(getMonthGridDays(focus.date).length / 7));
|
||||
const rowMinHeight = isMobile
|
||||
? Math.max(baseRowMinHeight, Math.floor(availableHeight / 6))
|
||||
: monthRowHeight(availableHeight, weeksInMonth, { overlayTop, rowHeight, fallback: baseRowMinHeight });
|
||||
// On wide screens a week keeps its height: a day with more entries than
|
||||
// fit shows the first ones and a "N more" link (as in Google Calendar).
|
||||
// Phones keep growing the row to fit.
|
||||
const rowCapacity = Math.max(1, Math.floor((rowMinHeight - overlayTop - 4 - 8) / rowHeight));
|
||||
const capRows = showChips && !isMobile;
|
||||
// A week of one row has its "+n more" beside the date (pim-6).
|
||||
const moreInHeader = capRows && rowCapacity === 1;
|
||||
|
||||
// The month the user is looking at: the one with the most days in view. It
|
||||
// dims the other months' days and drives the title. One point on the screen
|
||||
@@ -400,19 +475,21 @@ export function CalendarMonthView({
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, userEmailsFor(event));
|
||||
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
|
||||
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
toast.error(describeError(error, t("notifications.event_move_error")));
|
||||
}
|
||||
}, [t, describeError, currentUserEmails, onMoveEvent]);
|
||||
}, [t, describeError, userEmailsFor, onMoveEvent]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={`${t("views.month")}, ${formatMonthYear(visibleMonthDate)}`}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none]"
|
||||
// At 150 % the pane's edge falls inside a device pixel, and the week
|
||||
// scrolled under the header showed a line of its events there.
|
||||
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none] [clip-path:inset(0.5px_0_0_0)]"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
@@ -431,25 +508,14 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
<div ref={topSentinelRef} data-testid="month-top-sentinel" className="h-px flex-shrink-0" />
|
||||
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => {
|
||||
const overflowing = capRows && contentRows > rowCapacity;
|
||||
// Rows drawn per day; the last one is taken by the "N more" link.
|
||||
const rowLimit = overflowing ? rowCapacity - 1 : Infinity;
|
||||
const hiddenPerDay = overflowing
|
||||
? week.map((_, dayIndex) => {
|
||||
const hiddenEvents = segments.filter((segment) =>
|
||||
segment.row >= rowLimit
|
||||
&& dayIndex >= segment.startIndex
|
||||
&& dayIndex < segment.startIndex + segment.span,
|
||||
).length;
|
||||
const hiddenTasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex >= rowLimit).length;
|
||||
return hiddenEvents + hiddenTasks;
|
||||
})
|
||||
: null;
|
||||
// A fixed-height week: each day shows what fits and "+n more" (pim-6).
|
||||
const fit = capRows ? fitMonthWeek(segments, dayEventRows, dayTasks.map((list) => list.length), rowCapacity, moreInHeader) : null;
|
||||
const hiddenPerDay = fit?.hidden ?? null;
|
||||
// Rows that grow with their content make room for the new event's preview.
|
||||
const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day)) ? 1 : 0;
|
||||
return (
|
||||
<div key={dayKey(week[0])} data-week={dayKey(week[0])} className="relative flex-shrink-0 border-b border-border" role="row" style={{
|
||||
minHeight: showChips && !overflowing
|
||||
minHeight: showChips && !capRows
|
||||
? Math.max(rowMinHeight, overlayTop + 4 + (contentRows + previewRows) * rowHeight + 8)
|
||||
: rowMinHeight,
|
||||
}}>
|
||||
@@ -534,7 +600,7 @@ export function CalendarMonthView({
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||
const evColor = paint(ev.color || cal?.color || "#3b82f6");
|
||||
const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
|
||||
const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, userEmailsFor(ev));
|
||||
return (
|
||||
<span
|
||||
key={ev.id}
|
||||
@@ -571,7 +637,8 @@ export function CalendarMonthView({
|
||||
dayIndex={dayIndex}
|
||||
week={week}
|
||||
segments={segments}
|
||||
rowLimit={rowLimit}
|
||||
rowLimit={fit ? fit.limits[dayIndex] : Infinity}
|
||||
isShown={fit?.isShown}
|
||||
rowCount={rowCount}
|
||||
dayEventRows={dayEventRows}
|
||||
dayTasks={dayTasks}
|
||||
@@ -592,10 +659,12 @@ export function CalendarMonthView({
|
||||
onHoverEvent={onHoverEvent}
|
||||
onHoverLeave={onHoverLeave}
|
||||
onContextMenuEvent={onContextMenuEvent}
|
||||
onReplyEvent={onReplyEvent}
|
||||
onToggleTaskComplete={onToggleTaskComplete}
|
||||
onSelectTask={onSelectTask}
|
||||
onShowMore={() => (onOpenDay ?? onSelectDate)(week[dayIndex])}
|
||||
moreLabel={t("events.more", { count: hiddenPerDay?.[dayIndex] ?? 0 })}
|
||||
moreInHeader={moreInHeader}
|
||||
paint={paint}
|
||||
/>
|
||||
)}
|
||||
@@ -623,14 +692,17 @@ type WeekSegment = ReturnType<typeof buildWeekSegments>[number];
|
||||
* across the following ones.
|
||||
*/
|
||||
function MonthCellEntries({
|
||||
dayIndex, week, segments, rowLimit, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight,
|
||||
dayIndex, week, segments, rowLimit, isShown, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight,
|
||||
isMobile, calendarMap, pendingPreview, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle,
|
||||
onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, paint,
|
||||
onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onReplyEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, moreInHeader = false, paint,
|
||||
}: {
|
||||
dayIndex: number;
|
||||
week: Date[];
|
||||
segments: WeekSegment[];
|
||||
/** This day's first row without an entry: its "N more" row when some are hidden. */
|
||||
rowLimit: number;
|
||||
/** Whether a fixed-height week has room for an event on all its days. */
|
||||
isShown?: (segment: WeekSegment) => boolean;
|
||||
rowCount: number;
|
||||
dayEventRows: number[];
|
||||
dayTasks: CalendarTask[][];
|
||||
@@ -653,10 +725,13 @@ function MonthCellEntries({
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
onSelectTask?: (task: CalendarTask) => void;
|
||||
onShowMore: () => void;
|
||||
moreLabel: string;
|
||||
/** The link goes beside the date ("+2"), named in full. */
|
||||
moreInHeader?: boolean;
|
||||
paint: (color: string) => string;
|
||||
}) {
|
||||
const day = week[dayIndex];
|
||||
@@ -675,7 +750,7 @@ function MonthCellEntries({
|
||||
onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
onContextMenu: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
};
|
||||
const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && segment.row < rowLimit);
|
||||
const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && (isShown ? isShown(segment) : segment.row < rowLimit));
|
||||
const tasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex < rowLimit);
|
||||
const preview = pendingPreview && isSameDay(pendingPreview.start, day) ? pendingPreview : null;
|
||||
|
||||
@@ -703,6 +778,7 @@ function MonthCellEntries({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onReply={onReplyEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
draggable={canEditEvent(segment.event)}
|
||||
tabbable={spanDays.some(inView)}
|
||||
@@ -730,7 +806,22 @@ function MonthCellEntries({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hidden > 0 && (
|
||||
{hidden > 0 && moreInHeader && (
|
||||
<div className="absolute z-10" style={{ top: 4, insetInlineEnd: 2, height: 24 }} {...own}>
|
||||
<button
|
||||
type="button"
|
||||
data-month-more
|
||||
tabIndex={inView(day) ? undefined : -1}
|
||||
onClick={onShowMore}
|
||||
aria-label={moreLabel}
|
||||
title={moreLabel}
|
||||
className="h-full px-1.5 rounded-xs text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hidden > 0 && !moreInHeader && (
|
||||
<div className="absolute z-10 px-0.5" style={place(rowLimit)} {...own}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, type CSSProperties } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { addDays, format, isSameDay, parseISO } from "date-fns";
|
||||
import type { Calendar, CalendarEvent, CalendarTask } from "@/lib/jmap/types";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import { useCalendarStore, type CalendarViewMode } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
@@ -98,6 +98,8 @@ export function CalendarPrint(props: CalendarPrintProps) {
|
||||
const locale = useLocale();
|
||||
const { formatMonthYear, formatWeekRange, formatFullDate } = useCalendarLocale();
|
||||
const { content, viewMode } = props;
|
||||
// The tasks view's own lists, as the list on screen (pim-1).
|
||||
const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds);
|
||||
|
||||
const calendarMap = useMemo(() => new Map(props.calendars.map((c) => [c.id, c])), [props.calendars]);
|
||||
|
||||
@@ -211,7 +213,7 @@ export function CalendarPrint(props: CalendarPrintProps) {
|
||||
<PrintRoot orientation="portrait" title={title}>
|
||||
{header(title)}
|
||||
<TaskListPrint
|
||||
tasks={filterTasksForList(props.tasks, props.selectedCalendarIds, props.taskFilter)}
|
||||
tasks={filterTasksForList(props.tasks, hiddenTaskListIds, props.taskFilter)}
|
||||
calendars={props.calendars}
|
||||
timeFormat={props.timeFormat}
|
||||
/>
|
||||
@@ -683,6 +685,7 @@ export function EventPrintSheet({
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const { formatDateRangeLabel } = useCalendarLocale();
|
||||
const formatTime = useTimeFormatter(timeFormat);
|
||||
const resolveContactName = useContactNameResolver();
|
||||
|
||||
@@ -690,12 +693,19 @@ export function EventPrintSheet({
|
||||
const end = getEventEndDate(event);
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(start, displayEnd);
|
||||
// Several days as the language writes a range (pim-13).
|
||||
const dayParts: Intl.DateTimeFormatOptions = { weekday: "short", day: "numeric", month: "short", year: "numeric" };
|
||||
const when = event.showWithoutTime
|
||||
? multiDay
|
||||
? `${formatEventDate(start)} – ${formatEventDate(displayEnd)} · ${t("events.all_day")}`
|
||||
? `${formatDateRangeLabel(start, displayEnd, dayParts)} · ${t("events.all_day")}`
|
||||
: `${formatEventDate(start)} · ${t("events.all_day")}`
|
||||
: multiDay
|
||||
? `${formatEventDate(start)} ${formatTime(start)} – ${formatEventDate(end)} ${formatTime(end)}`
|
||||
? formatDateRangeLabel(start, end, {
|
||||
...dayParts,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
...(timeFormat === "12h" ? { hour12: true } : { hourCycle: "h23" as const }),
|
||||
})
|
||||
: `${formatEventDate(start)} · ${formatTime(start)} – ${formatTime(end)}`;
|
||||
|
||||
const location = primaryLocationName(event);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useMemo, useRef, type KeyboardEvent } from "react";
|
||||
import { forwardRef, useCallback, useMemo, useRef, type KeyboardEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format } from "date-fns";
|
||||
import { Search, MapPin, Repeat } from "@/components/icons";
|
||||
@@ -18,6 +18,7 @@ import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
|
||||
import type { CalendarSearchResults as Results } from "@/hooks/use-calendar-event-search";
|
||||
import { getEventColor } from "./event-card";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
const RESULT_SELECTOR = "[data-search-result]";
|
||||
|
||||
@@ -34,6 +35,15 @@ interface CalendarSearchFieldProps {
|
||||
export const CalendarSearchField = forwardRef<HTMLInputElement, CalendarSearchFieldProps>(
|
||||
function CalendarSearchField({ value, onChange, onDismiss, autoFocus, className }, ref) {
|
||||
const t = useTranslations("calendar");
|
||||
const tGrid = useTranslations("ux_pim_grid");
|
||||
// A narrow field says "Search" rather than cut "Search events" (pim-21:
|
||||
// "Termine durchs" at 150 % in German). The name stays the full one.
|
||||
const [measureRef, placeholder] = useShortenedText([t("search.title"), tGrid("search_short")]);
|
||||
const inputRef = useCallback((node: HTMLInputElement | null) => {
|
||||
measureRef(node);
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}, [measureRef, ref]);
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
// Escape clears the search first, then leaves the field (R8).
|
||||
@@ -49,7 +59,7 @@ export const CalendarSearchField = forwardRef<HTMLInputElement, CalendarSearchFi
|
||||
return (
|
||||
<div role="search" className={className}>
|
||||
<ToolbarSearch
|
||||
ref={ref}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -57,7 +67,7 @@ export const CalendarSearchField = forwardRef<HTMLInputElement, CalendarSearchFi
|
||||
onClear={() => onChange("")}
|
||||
clearLabel={t("search.clear")}
|
||||
shortcut="/"
|
||||
placeholder={t("search.title")}
|
||||
placeholder={placeholder}
|
||||
aria-label={t("search.title")}
|
||||
autoComplete="off"
|
||||
data-calendar-search
|
||||
|
||||
@@ -18,7 +18,9 @@ import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { taskListsOf } from "@/components/calendar/task-list-view";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
@@ -93,6 +95,7 @@ export function CalendarSidebarPanel({
|
||||
multiAccountMode,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tUx = useTranslations("ux_pim_calendar");
|
||||
const paint = useCvdColor();
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
@@ -107,6 +110,11 @@ export function CalendarSidebarPanel({
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||
const updateCalendar = useCalendarStore((s) => s.updateCalendar);
|
||||
const viewMode = useCalendarStore((s) => s.viewMode);
|
||||
const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds);
|
||||
const toggleTaskListVisibility = useCalendarStore((s) => s.toggleTaskListVisibility);
|
||||
const [renaming, setRenaming] = useState<Calendar | null>(null);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
@@ -268,6 +276,35 @@ export function CalendarSidebarPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (cal: Calendar, name: string) => {
|
||||
if (!client || !name || name === cal.name) return;
|
||||
try {
|
||||
await updateCalendar(client, cal.id, { name });
|
||||
toast.success(tMgmt('calendar_updated'));
|
||||
} catch {
|
||||
toast.error(tMgmt('error_update'));
|
||||
}
|
||||
};
|
||||
|
||||
// The tasks view lists task lists, each with its own tick (pim-1): a
|
||||
// task-only list never shows in the grid, so the grid's selection can't
|
||||
// say which tasks to list.
|
||||
const inTasksView = enableCalendarTasks && viewMode === 'tasks';
|
||||
const taskLists = useMemo(() => (inTasksView ? taskListsOf(calendars, tasks) : []), [inTasksView, calendars, tasks]);
|
||||
const accountLabel = (localAccountId: string) => {
|
||||
const acct = localAccounts.find(a => a.id === localAccountId);
|
||||
return acct?.label || acct?.email || acct?.username || localAccountId;
|
||||
};
|
||||
const taskListGroups = useMemo(() => {
|
||||
if (!multiAccountMode) return [{ key: '__all__', label: '', lists: taskLists }];
|
||||
const groups = new Map<string, Calendar[]>();
|
||||
for (const cal of taskLists) {
|
||||
const key = cal.localAccountId || '__other__';
|
||||
groups.set(key, [...(groups.get(key) ?? []), cal]);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([key, lists]) => ({ key, label: key === '__other__' ? '' : key, lists }));
|
||||
}, [multiAccountMode, taskLists]);
|
||||
|
||||
const handleSetDefault = async (calendarId: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
@@ -280,8 +317,9 @@ export function CalendarSidebarPanel({
|
||||
|
||||
if (calendars.length === 0 && !onSubscribe) return null;
|
||||
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const renderCalendarItem = (cal: Calendar, asTaskList = false) => {
|
||||
const isVisible = asTaskList ? !hiddenTaskListIds.includes(cal.id) : selectedCalendarIds.includes(cal.id);
|
||||
const onToggle = asTaskList ? () => toggleTaskListVisibility(cal.id) : () => onToggleVisibility(cal.id);
|
||||
const color = paint(cal.color || "#3b82f6");
|
||||
// A company calendar can only be shown or hidden: no menu.
|
||||
const hasMenu = cal.isCompany ? false : isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
@@ -289,7 +327,7 @@ export function CalendarSidebarPanel({
|
||||
return (
|
||||
<div key={cal.id} className="relative group/cal">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onClick={onToggle}
|
||||
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||
{...(hasMenu ? {
|
||||
onTouchStart: (e: React.TouchEvent<HTMLButtonElement>) => { pressedRef.current = { calendar: cal, element: e.currentTarget }; longPress.onTouchStart(e); },
|
||||
@@ -299,7 +337,7 @@ export function CalendarSidebarPanel({
|
||||
} : {})}
|
||||
role="checkbox"
|
||||
aria-checked={isVisible}
|
||||
data-testid="calendar-item"
|
||||
data-testid={asTaskList ? "task-list-item" : "calendar-item"}
|
||||
data-calendar-name={cal.name}
|
||||
data-account={cal.accountName ?? ''}
|
||||
data-visible={isVisible}
|
||||
@@ -400,7 +438,9 @@ export function CalendarSidebarPanel({
|
||||
}
|
||||
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canCreate = onCreateEvent && !isBirthday && !cal.isTasksOnly && cal.myRights?.mayWriteOwn !== false;
|
||||
// The owner names a calendar: a sharee renaming it would rename it for everyone (pim-20).
|
||||
const canRename = !!client && !isBirthday && !cal.isShared;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault;
|
||||
const canChangeColor = !!onColorChange;
|
||||
@@ -408,7 +448,7 @@ export function CalendarSidebarPanel({
|
||||
const canResetColor = !!onResetColor && hasColorOverride;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const showSeparator = (canCreate || canRename || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
@@ -420,6 +460,13 @@ export function CalendarSidebarPanel({
|
||||
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canRename && (
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={tUx('rename_calendar')}
|
||||
onClick={() => { closeContextMenu(); setRenaming(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
@@ -456,14 +503,14 @@ export function CalendarSidebarPanel({
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
icon={Eraser}
|
||||
label={tMgmt('clear_events')}
|
||||
label={tUx('clear_all_events')}
|
||||
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={tMgmt('delete')}
|
||||
label={tUx('delete_calendar')}
|
||||
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||
destructive
|
||||
/>
|
||||
@@ -509,7 +556,7 @@ export function CalendarSidebarPanel({
|
||||
{renderSectionHeader(key, label, onAdd)}
|
||||
{!collapsedAccountGroups.has(key) && (
|
||||
<div>
|
||||
{list.map(renderCalendarItem)}
|
||||
{list.map((cal) => renderCalendarItem(cal))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -533,7 +580,22 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
{inTasksView ? (
|
||||
<div data-testid="task-lists-section">
|
||||
{renderSectionHeader('__task_lists__', tUx('task_lists'))}
|
||||
{!collapsedAccountGroups.has('__task_lists__') && taskListGroups.map((group) => (
|
||||
<div key={group.key} className={cn(group.label && "mt-1")}>
|
||||
{group.label && (
|
||||
<div className="ps-5 pe-3 py-0.5 text-[0.6875rem] font-medium text-muted-foreground flex items-center gap-1">
|
||||
<User className="w-3 h-3" aria-hidden="true" />
|
||||
<span className="truncate">{accountLabel(group.label)}</span>
|
||||
</div>
|
||||
)}
|
||||
{group.lists.map((cal) => renderCalendarItem(cal, true))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
<>
|
||||
{localAccountGroups.map((group, idx) => {
|
||||
const expanded = !collapsedAccountGroups.has(group.key);
|
||||
@@ -581,7 +643,7 @@ export function CalendarSidebarPanel({
|
||||
{t('my_calendars')}
|
||||
</div>
|
||||
<div>
|
||||
{owned.map(renderCalendarItem)}
|
||||
{owned.map((cal) => renderCalendarItem(cal))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -592,7 +654,7 @@ export function CalendarSidebarPanel({
|
||||
{sg.label}
|
||||
</div>
|
||||
<div>
|
||||
{sg.calendars.map(renderCalendarItem)}
|
||||
{sg.calendars.map((cal) => renderCalendarItem(cal))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -627,7 +689,7 @@ export function CalendarSidebarPanel({
|
||||
{group.accountName}
|
||||
</div>
|
||||
<div>
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
{group.calendars.map((cal) => renderCalendarItem(cal))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -638,6 +700,16 @@ export function CalendarSidebarPanel({
|
||||
|
||||
{renderCalendarMenu()}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog
|
||||
isOpen={!!renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSubmit={(name) => { const cal = renaming; setRenaming(null); if (cal) void handleRename(cal, name.trim()); }}
|
||||
title={tUx('rename_title')}
|
||||
placeholder={tMgmt('name_placeholder')}
|
||||
defaultValue={renaming?.name ?? ''}
|
||||
confirmText={tUx('rename_confirm')}
|
||||
validate={(value) => (value.trim() ? null : tUx('rename_empty'))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
/** The "?" / "Reply" marker of an unanswered invitation (pim-9). */
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
/** A click on a day in the all-day row: a new all-day event there. */
|
||||
onCreateAllDay?: (date: Date) => void;
|
||||
@@ -91,6 +93,7 @@ export function CalendarWeekView({
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onReplyEvent,
|
||||
onCreateAtTime,
|
||||
onCreateAllDay,
|
||||
firstDayOfWeek = 1,
|
||||
@@ -581,6 +584,7 @@ export function CalendarWeekView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onReply={onReplyEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={spanInView(segment.startIndex, segment.span)}
|
||||
/>
|
||||
@@ -724,6 +728,7 @@ export function CalendarWeekView({
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onReply={onReplyEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
draggable={editable}
|
||||
continuesBefore={clip.clippedStart}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { parseDuration } from "@/lib/calendar-event-normalization";
|
||||
import { format } from "date-fns";
|
||||
import { ChevronDown, ChevronUp, Users } from "@/components/icons";
|
||||
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
|
||||
import { gridReplyState, type GridReplyState } from "@/lib/calendar-reply-state";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useSettingsStore, type MonthTimedEventStyle } from "@/stores/settings-store";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
@@ -33,8 +35,17 @@ interface EventCardProps {
|
||||
continuesAfter?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** The user's calendar addresses, to tell whether they declined the event. */
|
||||
/**
|
||||
* The user's calendar addresses, to tell whether they declined or have yet
|
||||
* to answer the event. An event of another signed-in login is read with
|
||||
* that login's addresses.
|
||||
*/
|
||||
currentUserEmails?: string[];
|
||||
/**
|
||||
* The "?" / "Reply" marker of an invitation the user hasn't answered was
|
||||
* clicked: offer Accept, Maybe and Decline next to it (pim-9).
|
||||
*/
|
||||
onReply?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
/** False for a day scrolled out of view: Tab skips it (arrows still reach it). */
|
||||
tabbable?: boolean;
|
||||
/**
|
||||
@@ -97,6 +108,34 @@ export function eventFillStyle(
|
||||
return shadows.length > 0 ? { ...fill, boxShadow: shadows.join(", ") } : fill;
|
||||
}
|
||||
|
||||
/**
|
||||
* An invitation the user hasn't answered: the calendar colour as an outline
|
||||
* over a light hatch. One they answered "maybe": a dashed outline over a
|
||||
* light tint (pim-9).
|
||||
*/
|
||||
export function replyFillStyle(
|
||||
color: string,
|
||||
state: Exclude<GridReplyState, null>,
|
||||
{ separated, selected }: { separated?: boolean; selected?: boolean },
|
||||
): CSSProperties {
|
||||
const ring = selected ? "0 0 0 2px var(--color-primary)" : separated ? "0 0 0 1px var(--color-background)" : null;
|
||||
if (state === "tentative") {
|
||||
return {
|
||||
backgroundColor: `color-mix(in srgb, ${color} 14%, var(--color-background))`,
|
||||
color: "var(--color-foreground)",
|
||||
border: `1.5px dashed ${color}`,
|
||||
...(ring ? { boxShadow: ring } : {}),
|
||||
};
|
||||
}
|
||||
const tint = `color-mix(in srgb, ${color} 18%, var(--color-background))`;
|
||||
return {
|
||||
backgroundColor: "var(--color-background)",
|
||||
backgroundImage: `repeating-linear-gradient(135deg, ${tint} 0 4px, transparent 4px 8px)`,
|
||||
color: eventTextColor(color),
|
||||
boxShadow: [ring, `inset 0 0 0 1.5px ${color}`].filter(Boolean).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** The class that draws the light style's bar; declined and cancelled events keep their outline. */
|
||||
export function eventFillClass(tinted: boolean, inactive: boolean): string | undefined {
|
||||
return tinted && !inactive ? "start-bar" : undefined;
|
||||
@@ -117,8 +156,12 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true, timedStyle = "bar" }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, onReply, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true, timedStyle = "bar" }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tEvent = useTranslations("ux_pim_event");
|
||||
const tGrid = useTranslations("ux_pim_grid");
|
||||
// The addresses of the login the event is in (pim-12, pim-9).
|
||||
const userEmails = useEventUserEmails(currentUserEmails ?? [])(event);
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const paint = useCvdColor();
|
||||
const color = paint(getEventColor(event, calendar));
|
||||
@@ -146,9 +189,62 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
// deleting it (#572), and a declined invitation stays on the calendar too
|
||||
// (#1110) - render both struck through and dimmed.
|
||||
const isCancelled = event.status === "cancelled";
|
||||
const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails);
|
||||
const isDeclined = !isCancelled && isDeclinedByUser(event, userEmails);
|
||||
const isInactive = isCancelled || isDeclined;
|
||||
const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null;
|
||||
// An invitation not answered yet, or answered "maybe" (pim-9).
|
||||
const replyState = isInactive ? null : gridReplyState(event, userEmails);
|
||||
const statusLabel = isCancelled
|
||||
? t("detail.cancelled")
|
||||
: isDeclined
|
||||
? t("participants.declined")
|
||||
: replyState === "needs-action"
|
||||
? tEvent("status_needs_action")
|
||||
: replyState === "tentative" ? tEvent("status_tentative") : null;
|
||||
const fillStyle = (opts: { separated?: boolean }) => replyState
|
||||
? replyFillStyle(color, replyState, { ...opts, selected: isSelected })
|
||||
: eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted, ...opts });
|
||||
// No light-style bar over the outline of an open answer.
|
||||
const fillClass = eventFillClass(tinted, isInactive || replyState !== null);
|
||||
const showMarker = replyState === "needs-action";
|
||||
// "?" in a narrow event, "Reply" where there is room.
|
||||
const markerLabel = (
|
||||
<>
|
||||
<span aria-hidden="true" className="@min-[11rem]/reply:hidden">?</span>
|
||||
<span className="hidden @min-[11rem]/reply:inline">{tGrid("reply")}</span>
|
||||
</>
|
||||
);
|
||||
const markerClass = cn(
|
||||
"absolute z-[1] inline-flex items-center justify-center h-5 min-w-5 max-w-20 px-1 rounded-xs",
|
||||
"text-[0.625rem] font-semibold leading-none truncate",
|
||||
// A block's title runs along its top: the marker sits at the bottom.
|
||||
variant === "block" ? (continuesAfter ? "bottom-0.5 end-4" : "bottom-0.5 end-0.5") : "top-1/2 -translate-y-1/2 end-0.5",
|
||||
);
|
||||
const markerStyle: CSSProperties = { backgroundColor: color, color: readableTextOn(color) };
|
||||
const marker = !showMarker ? null : onReply ? (
|
||||
<button
|
||||
type="button"
|
||||
data-reply-marker
|
||||
aria-label={tGrid("reply_to_invitation")}
|
||||
title={tGrid("reply_to_invitation")}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
onClick={(e) => { e.stopPropagation(); onReply(e.currentTarget, event); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(markerClass, "cursor-pointer hover:opacity-90")}
|
||||
style={markerStyle}
|
||||
>
|
||||
{markerLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span data-reply-marker aria-hidden="true" className={markerClass} style={markerStyle}>{markerLabel}</span>
|
||||
);
|
||||
// Room for the marker at the end of a one-line card.
|
||||
const markerRoom = showMarker && variant !== "block" ? "pe-6 @min-[11rem]/reply:pe-[5.5rem]" : undefined;
|
||||
const withMarker = (card: React.ReactElement) => marker ? (
|
||||
<div className={cn("relative @container/reply w-full", variant !== "chip" && "h-full")} data-reply-state={replyState ?? undefined}>
|
||||
{card}
|
||||
{marker}
|
||||
</div>
|
||||
) : card;
|
||||
const ariaLabel = `${event.title || t("events.no_title")}, ${spokenTime}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent) => {
|
||||
@@ -216,7 +312,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
} : {};
|
||||
|
||||
if (variant === "chip") {
|
||||
return (
|
||||
return withMarker(
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
@@ -227,15 +323,17 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
data-reply-state={replyState ?? undefined}
|
||||
className={cn(
|
||||
"flex items-center w-full text-start text-xs font-medium px-1.5 py-0.5 rounded-xs truncate",
|
||||
"min-h-[44px] sm:min-h-0",
|
||||
"hover:opacity-90 transition-opacity",
|
||||
isBeingDragged && "opacity-50",
|
||||
eventFillClass(tinted, isInactive),
|
||||
fillClass,
|
||||
markerRoom,
|
||||
className
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted }), ...style }}
|
||||
style={{ ...fillStyle({}), ...style }}
|
||||
>
|
||||
{/* The whole width, aligned to the title's own start: a short English
|
||||
title in Arabic sat at the right, a cut one filled to the left. */}
|
||||
@@ -252,7 +350,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
// on the drawn day (#1449). Anything else stays a bar, so all-day and
|
||||
// multi-day events read as blocked days.
|
||||
const textStyle = (timedStyle === "text" || timedStyle === "dot")
|
||||
&& !event.showWithoutTime && !continuesBefore && !continuesAfter && !isInactive
|
||||
&& !event.showWithoutTime && !continuesBefore && !continuesAfter && !isInactive && !replyState
|
||||
? timedStyle : null;
|
||||
if (textStyle) {
|
||||
return (
|
||||
@@ -294,7 +392,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
return withMarker(
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
@@ -305,16 +403,18 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
data-reply-state={replyState ?? undefined}
|
||||
className={cn(
|
||||
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesBefore && "rounded-s-none",
|
||||
continuesAfter && "rounded-e-none pe-2",
|
||||
isBeingDragged && "opacity-50",
|
||||
eventFillClass(tinted, isInactive),
|
||||
fillClass,
|
||||
markerRoom,
|
||||
className
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted }), ...style }}
|
||||
style={{ ...fillStyle({}), ...style }}
|
||||
>
|
||||
{/* The title comes first: in a narrow day the time and the calendar's
|
||||
name left it no room, or one letter. */}
|
||||
@@ -333,7 +433,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return withMarker(
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
@@ -345,6 +445,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
data-reply-state={replyState ?? undefined}
|
||||
data-continues-before={continuesBefore || undefined}
|
||||
data-continues-after={continuesAfter || undefined}
|
||||
className={cn(
|
||||
@@ -358,10 +459,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
continuesAfter && "rounded-b-none",
|
||||
continuesBefore && "pe-4",
|
||||
isBeingDragged && "opacity-50",
|
||||
eventFillClass(tinted, isInactive),
|
||||
fillClass,
|
||||
markerRoom,
|
||||
className
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected, tinted }), ...style }}
|
||||
style={{ ...fillStyle({ separated: true }), ...style }}
|
||||
>
|
||||
{continuesBefore && <ChevronUp aria-hidden="true" className="absolute top-0.5 end-0.5 w-3 h-3 opacity-85" />}
|
||||
{continuesAfter && <ChevronDown aria-hidden="true" className="absolute bottom-0.5 end-0.5 w-3 h-3 opacity-85" />}
|
||||
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
Printer,
|
||||
Check,
|
||||
HelpCircle,
|
||||
X,
|
||||
} from "@/components/icons";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { buildCalendarPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { findMeetingLink } from "@/lib/event-links";
|
||||
@@ -42,6 +45,15 @@ interface EventContextMenuProps {
|
||||
onPrint?: () => void;
|
||||
/** False for an event the user can't change (a birthday, a subscription, a read-only share): no Edit or Delete. */
|
||||
editable?: boolean;
|
||||
/**
|
||||
* Answer the invitation from the grid (pim-9): Accept, Maybe and Decline
|
||||
* at the top. Only for an event the user is invited to.
|
||||
*/
|
||||
onRespond?: (status: CalendarParticipant["participationStatus"]) => void;
|
||||
/** The answer given so far; its item is left out. */
|
||||
responseStatus?: CalendarParticipant["participationStatus"] | null;
|
||||
/** Only the answers: the menu of the "?" / "Reply" marker. */
|
||||
answersOnly?: boolean;
|
||||
}
|
||||
|
||||
export function EventContextMenu({
|
||||
@@ -58,6 +70,9 @@ export function EventContextMenu({
|
||||
onDelete,
|
||||
onPrint,
|
||||
editable = true,
|
||||
onRespond,
|
||||
responseStatus,
|
||||
answersOnly = false,
|
||||
}: EventContextMenuProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
@@ -71,8 +86,36 @@ export function EventContextMenu({
|
||||
|
||||
const hasMeetingLink = !!findMeetingLink(event);
|
||||
|
||||
const answers = onRespond ? (
|
||||
<>
|
||||
{responseStatus !== "accepted" && (
|
||||
<ContextMenuItem icon={Check} label={t("participants.accept")} onClick={handle(() => onRespond("accepted"))} testId="grid-answer-accepted" />
|
||||
)}
|
||||
{responseStatus !== "tentative" && (
|
||||
<ContextMenuItem icon={HelpCircle} label={t("participants.maybe")} onClick={handle(() => onRespond("tentative"))} testId="grid-answer-tentative" />
|
||||
)}
|
||||
{responseStatus !== "declined" && (
|
||||
<ContextMenuItem icon={X} label={t("participants.decline")} onClick={handle(() => onRespond("declined"))} testId="grid-answer-declined" />
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (answersOnly && answers) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
{answers}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
{answers && (
|
||||
<>
|
||||
{answers}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{editable && <ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />}
|
||||
<ContextMenuItem icon={CopyPlus} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { eventZonedTimes, zonedTimesText } from "@/lib/event-time-zone";
|
||||
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||
import { formatEventAlertsLabel } from "./alert-list-editor";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from "@/lib/calendar-participants";
|
||||
import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { descriptionAsText } from "@/lib/calendar-description";
|
||||
@@ -147,6 +149,7 @@ export function EventDetailPopover({
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const tCommon = useTranslations("common");
|
||||
const tEvent = useTranslations("ux_pim_event");
|
||||
const locale = useLocale();
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -166,6 +169,11 @@ export function EventDetailPopover({
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
// An event on another zone's clock shows its own times too (pim-2).
|
||||
const zoned = eventZonedTimes(event, startDate, endDate);
|
||||
const zonedText = zoned
|
||||
? zonedTimesText(zoned, startDate, endDate, timeFormat === "12h" ? "h:mm a" : "HH:mm", locale)
|
||||
: null;
|
||||
|
||||
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
|
||||
// Re-computes the location link once the config is known (#903).
|
||||
@@ -363,6 +371,7 @@ export function EventDetailPopover({
|
||||
const hasParticipants = participants.length > 0;
|
||||
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const { formatDateRangeLabel } = useCalendarLocale();
|
||||
|
||||
const popover = (
|
||||
<div
|
||||
@@ -474,11 +483,9 @@ export function EventDetailPopover({
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
{/* One range as the language writes it (pim-13). */}
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
{formatDateRangeLabel(startDate, displayEndDate, { weekday: "short", day: "numeric", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
@@ -499,6 +506,11 @@ export function EventDetailPopover({
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
{zoned && zonedText && (
|
||||
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
|
||||
{tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
@@ -508,6 +520,10 @@ export function EventDetailPopover({
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||
) : zoned && zonedText ? (
|
||||
<div className="text-muted-foreground" data-testid="event-zone-times">
|
||||
{tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
|
||||
|
||||
+308
-204
@@ -19,7 +19,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow,
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
|
||||
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
|
||||
import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone";
|
||||
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { ParticipantAvailability } from "./participant-availability";
|
||||
@@ -41,6 +42,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients";
|
||||
@@ -279,6 +281,7 @@ export function EventModal({
|
||||
const labelIdBase = useId();
|
||||
const labelFor = (key: string) => `${labelIdBase}-${key}`;
|
||||
const tCommon = useTranslations("common");
|
||||
const tEvent = useTranslations("ux_pim_event");
|
||||
// The header's close goes to its start when the window's own close is at its end.
|
||||
const [header, setHeader] = useState<HTMLDivElement | null>(null);
|
||||
const closeFirst = useCloseBesideControls(header);
|
||||
@@ -295,6 +298,7 @@ export function EventModal({
|
||||
: "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]";
|
||||
const isEdit = !!event;
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const { formatDateRangeLabel } = useCalendarLocale();
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
|
||||
|
||||
// Only the organizer invites: someone else's invitation keeps its
|
||||
@@ -385,11 +389,37 @@ export function EventModal({
|
||||
const [virtualLocation, setVirtualLocation] = useState(
|
||||
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
|
||||
);
|
||||
const [startDate, setStartDate] = useState(formatDateInput(getInitialStart()));
|
||||
const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart()));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd()));
|
||||
const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd()));
|
||||
// The zone the date and time fields are in, and a save writes: the
|
||||
// event's own, the user's for a new event, none for a floating one. An
|
||||
// edit used to save every event in the user's zone (pim-2).
|
||||
const userTimeZone = getEffectiveTimeZone();
|
||||
const [timeZone, setTimeZone] = useState<string | null>(() => eventFormTimeZone(event, userTimeZone));
|
||||
const [showZonePicker, setShowZonePicker] = useState(false);
|
||||
const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone));
|
||||
const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone));
|
||||
const [startDate, setStartDate] = useState(formatDateInput(initialStartWall));
|
||||
const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall));
|
||||
const [endDate, setEndDate] = useState(formatDateInput(initialEndWall));
|
||||
const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall));
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false);
|
||||
// The zone the fields are on: none for an all-day event.
|
||||
const fieldZone = allDay ? null : timeZone;
|
||||
const zoneOptions = useMemo(() => {
|
||||
if (!showZonePicker) return [];
|
||||
const zones = listTimeZones();
|
||||
return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones;
|
||||
}, [showZonePicker, timeZone]);
|
||||
// The form's times on the user's clock, beside another zone's.
|
||||
const formYourTimes = useMemo(() => {
|
||||
if (!fieldZone || fieldZone === userTimeZone) return null;
|
||||
const from = zoneWallToDisplay(new Date(`${startDate}T${startTime || "00:00"}:00`), fieldZone);
|
||||
const to = zoneWallToDisplay(new Date(`${endDate}T${endTime || "00:00"}:00`), fieldZone);
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime())) return null;
|
||||
const time = (d: Date) => format(d, timeDisplayFmt);
|
||||
const onOtherDay = !isSameDay(from, new Date(`${startDate}T00:00:00`));
|
||||
const day = onOtherDay ? `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(from)} ` : "";
|
||||
return `${day}${time(from)}–${time(to)}`;
|
||||
}, [fieldZone, userTimeZone, startDate, startTime, endDate, endTime, timeDisplayFmt, locale]);
|
||||
|
||||
// Editing the start shifts the end with it, preserving the event's current
|
||||
// length (end - start) - so moving the start never leaves the end before it
|
||||
@@ -417,9 +447,16 @@ export function EventModal({
|
||||
|
||||
// Calendars offered as create/move targets: writable, non-subscription, plus
|
||||
// the event's current calendar so an in-place edit never blanks the select.
|
||||
// An existing event moves only within its own login: the update goes to
|
||||
// that login's server, which doesn't know another login's calendar ids.
|
||||
const eventLogin = useMemo(() => {
|
||||
const primary = event?.calendarIds ? getPrimaryCalendarId(event) : undefined;
|
||||
return primary ? { known: true, login: calendars.find(c => c.id === primary)?.localAccountId } : { known: false, login: undefined };
|
||||
}, [event, calendars]);
|
||||
const selectableCalendars = useMemo(
|
||||
() => calendars.filter(c => canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId),
|
||||
[calendars, isSubscriptionCalendar, calendarId]
|
||||
() => calendars.filter(c => (canCreateEventsIn(c, isSubscriptionCalendar) || c.id === calendarId)
|
||||
&& (!eventLogin.known || c.localAccountId === eventLogin.login)),
|
||||
[calendars, isSubscriptionCalendar, calendarId, eventLogin]
|
||||
);
|
||||
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
|
||||
if (!event?.recurrenceRules?.length) return "none";
|
||||
@@ -528,9 +565,9 @@ export function EventModal({
|
||||
// What the form holds against what it opened with: a form with changes
|
||||
// asks before X, Cancel or Escape drop them (R5).
|
||||
const formState = useMemo(() => ({
|
||||
title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
|
||||
title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone,
|
||||
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations,
|
||||
}), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
|
||||
}), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone,
|
||||
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations]);
|
||||
const [initialForm] = useState(formState);
|
||||
const isDirty = mode === "edit" && JSON.stringify(formState) !== JSON.stringify(initialForm);
|
||||
@@ -541,6 +578,7 @@ export function EventModal({
|
||||
const f = initialForm;
|
||||
setTitle(f.title); setDescription(f.description); setLocation(f.location); setVirtualLocation(f.virtualLocation);
|
||||
setStartDate(f.startDate); setStartTime(f.startTime); setEndDate(f.endDate); setEndTime(f.endTime); setAllDay(f.allDay);
|
||||
setTimeZone(f.timeZone); setShowZonePicker(false);
|
||||
setCalendarId(f.calendarId); setRecurrence(f.recurrence); setCustomRule(f.customRule); setShowRecurrenceEditor(false);
|
||||
setUseDefaultAlerts(f.useDefaultAlerts); setAlertRows(f.alertRows); setPreservedAlerts(f.preservedAlerts); setRefreshDefaults(false);
|
||||
setAttendees(f.attendees); setSendInvitations(f.sendInvitations);
|
||||
@@ -569,10 +607,10 @@ export function EventModal({
|
||||
const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]);
|
||||
|
||||
// The event window the attendees' free/busy is checked against. The
|
||||
// fields are a wall clock in the effective zone, as the save path labels
|
||||
// fields are a wall clock in the form's zone, as the save path labels
|
||||
// them, not in the browser's; an all-day event spans its whole days.
|
||||
const availabilityWindow = useMemo(() => {
|
||||
const zone = getEffectiveTimeZone();
|
||||
const zone = fieldZone ?? getEffectiveTimeZone();
|
||||
const instant = (value: string) => {
|
||||
const wall = parseLocalDateTime(value);
|
||||
if (!wall) return null;
|
||||
@@ -585,7 +623,7 @@ export function EventModal({
|
||||
start: startDate ? instant(startStr) : null,
|
||||
end: endDate ? instant(endStr) : null,
|
||||
};
|
||||
}, [startDate, startTime, endDate, endTime, allDay]);
|
||||
}, [startDate, startTime, endDate, endTime, allDay, fieldZone]);
|
||||
|
||||
// An existing event's own time, as saved: Suggest times doesn't count it
|
||||
// as busy (the attendees who have it in their calendar show it so).
|
||||
@@ -594,16 +632,16 @@ export function EventModal({
|
||||
: null), [event]);
|
||||
|
||||
// A suggested slot moves the event there; the fields are a wall clock in
|
||||
// the effective zone, so the instants are turned back into one.
|
||||
// the form's zone, so the instants are turned back into one.
|
||||
const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => {
|
||||
const zone = getEffectiveTimeZone();
|
||||
const zone = fieldZone ?? getEffectiveTimeZone();
|
||||
const from = toZonedDisplayDate(slot.start, zone);
|
||||
const to = toZonedDisplayDate(slot.end, zone);
|
||||
setStartDate(formatDateInput(from));
|
||||
setStartTime(formatTimeInput(from));
|
||||
setEndDate(formatDateInput(to));
|
||||
setEndTime(formatTimeInput(to));
|
||||
}, []);
|
||||
}, [fieldZone]);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
@@ -687,7 +725,7 @@ export function EventModal({
|
||||
description,
|
||||
start: startStr,
|
||||
duration,
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
timeZone: fieldZone,
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isAllDay: allDay,
|
||||
@@ -700,7 +738,7 @@ export function EventModal({
|
||||
isRecurring: recurrence !== "none",
|
||||
keywords: base?.keywords ?? [],
|
||||
};
|
||||
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
|
||||
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, fieldZone, location, attendees, recurrence]);
|
||||
const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
|
||||
@@ -708,17 +746,20 @@ export function EventModal({
|
||||
}, [formEventView]);
|
||||
const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
// Report live preview to parent for grid outline. The label as a string:
|
||||
// a new `t` on every render would report it again on each.
|
||||
const noTitle = t("events.no_title");
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const s = new Date(startStr);
|
||||
const e = new Date(endStr);
|
||||
// Where the grid shows it: the fields are on the form's clock.
|
||||
const s = zoneWallToDisplay(new Date(startStr), fieldZone);
|
||||
const e = zoneWallToDisplay(new Date(endStr), fieldZone);
|
||||
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
|
||||
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
|
||||
onPreviewChange({ start: s, end: e, title: title || noTitle, allDay, calendarId });
|
||||
return () => onPreviewChange(null);
|
||||
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
|
||||
}, [startDate, startTime, endDate, endTime, allDay, fieldZone, title, noTitle, calendarId, isEdit, onPreviewChange]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
if (!event?.participants) return null;
|
||||
@@ -755,6 +796,7 @@ export function EventModal({
|
||||
let duration: string;
|
||||
// The last day of an all-day event, the end of a timed one.
|
||||
let end: Date;
|
||||
let timedWhen: { start: Date; end: Date } | undefined;
|
||||
|
||||
if (allDay) {
|
||||
let inclusiveEnd = new Date(`${endDate}T00:00:00`);
|
||||
@@ -764,21 +806,32 @@ export function EventModal({
|
||||
duration = buildAllDayDuration(start, inclusiveEnd);
|
||||
end = inclusiveEnd;
|
||||
} else {
|
||||
const endStr = `${endDate}T${endTime}:00`;
|
||||
end = new Date(endStr);
|
||||
// Clock fields, not Dates: see wallFields.
|
||||
const startWall = wallFields(startDate, startTime);
|
||||
let endWall = wallFields(endDate, endTime);
|
||||
// An end at or before the start becomes an hour - but an event that
|
||||
// lasts no time (a deadline, a reminder) keeps doing so.
|
||||
const lastedNoTime = !!event && !event.showWithoutTime
|
||||
&& getEventEndDate(event).getTime() === getEventStartDate(event).getTime();
|
||||
if (end < start || (end.getTime() === start.getTime() && !lastedNoTime)) {
|
||||
end = new Date(start.getTime() + 3600000);
|
||||
const order = compareWall(endWall, startWall);
|
||||
if (order < 0 || (order === 0 && !lastedNoTime)) {
|
||||
endWall = addToWall(startWall, 3600000);
|
||||
}
|
||||
duration = buildDuration(start, end);
|
||||
// Exact time in the event's zone, not the browser's.
|
||||
duration = buildDuration(new Date(0), new Date(Math.max(0, zoneWallDurationMs(startWall, endWall, timeZone))));
|
||||
// The fields are the wall clock in the form's zone: the event's own, so
|
||||
// an edit keeps it, or the one the user picked.
|
||||
end = zoneFieldsToDisplay(endWall, timeZone);
|
||||
timedWhen = { start: zoneFieldsToDisplay(startWall, timeZone), end };
|
||||
}
|
||||
|
||||
// The form's wall-clock fields are in the user's effective zone (the
|
||||
// grid and prefill are display dates), so label them with that zone.
|
||||
const timeZone = getEffectiveTimeZone();
|
||||
// The editor state below is in display dates, as the rest of the calendar.
|
||||
const when = {
|
||||
allDay,
|
||||
start: allDay ? start : timedWhen!.start,
|
||||
end: allDay ? end : timedWhen!.end,
|
||||
};
|
||||
const timeZoneChanged = !!event && !allDay && timeZone !== initialForm.timeZone;
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
|
||||
@@ -879,7 +932,7 @@ export function EventModal({
|
||||
userEmail: currentUserEmails[0],
|
||||
userEmails: currentUserEmails,
|
||||
resolveName: resolveContactName,
|
||||
rescheduled: !!event && (changesWhen(event, { allDay, start, end }) || (ruleChanged && !event.recurrenceId)),
|
||||
rescheduled: !!event && (changesWhen(event, when) || timeZoneChanged || (ruleChanged && !event.recurrenceId)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -941,14 +994,15 @@ export function EventModal({
|
||||
await onSave(data, shouldSendScheduling, {
|
||||
initialAttendees,
|
||||
attendees: effectiveAttendees,
|
||||
when: { allDay, start, end },
|
||||
when,
|
||||
ruleChanged,
|
||||
...(timeZoneChanged ? { timeZoneChanged } : {}),
|
||||
});
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]);
|
||||
|
||||
// The user's own answer, and the owner's in a calendar shared with them
|
||||
// (lib/calendar-delegate.ts).
|
||||
@@ -1034,6 +1088,9 @@ export function EventModal({
|
||||
const alertLabel = formatEventAlertsLabel(event, calendars, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = paint(getEventColor(event, eventCalendar));
|
||||
// An event on another zone's clock shows its own times too (pim-2).
|
||||
const zoned = eventZonedTimes(event, startD, endD);
|
||||
const zonedText = zoned ? zonedTimesText(zoned, startD, endD, timeDisplayFmt, locale) : null;
|
||||
|
||||
const viewClose = (
|
||||
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
|
||||
@@ -1093,8 +1150,10 @@ export function EventModal({
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
{/* One range as the language writes it (pim-13). */}
|
||||
<div className="font-medium text-foreground">
|
||||
{formatDateRangeLabel(startD, displayEnd, { weekday: "short", day: "numeric", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
@@ -1117,6 +1176,11 @@ export function EventModal({
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
{zoned && zonedText && (
|
||||
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
|
||||
{tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1127,6 +1191,10 @@ export function EventModal({
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||
) : zoned && zonedText ? (
|
||||
<div className="text-muted-foreground" data-testid="event-zone-times">
|
||||
{tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
@@ -1284,19 +1352,188 @@ export function EventModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
|
||||
<textarea
|
||||
id={labelFor("description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("form.description")}
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-hidden"
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
|
||||
<input
|
||||
id={labelFor("start_date")}
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
|
||||
<input
|
||||
id={labelFor("start_time")}
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
|
||||
<input
|
||||
id={labelFor("end_date")}
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
|
||||
<input
|
||||
id={labelFor("end_time")}
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The zone the times are on (pim-2): a line when it isn't the
|
||||
user's, a small link when it is; both open the picker. A
|
||||
floating event stays without one. */}
|
||||
{fieldZone && (
|
||||
<div data-testid="event-time-zone">
|
||||
{showZonePicker ? (
|
||||
<>
|
||||
<label htmlFor={labelFor("time_zone")} className="text-sm font-medium mb-1 block">{tEvent("time_zone")}</label>
|
||||
<select
|
||||
id={labelFor("time_zone")}
|
||||
value={fieldZone}
|
||||
onChange={(e) => setTimeZone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{zoneOptions.map((zone) => (
|
||||
<option key={zone} value={zone}>{zone.replace(/_/g, " ")}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : fieldZone !== userTimeZone ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<button type="button" onClick={() => setShowZonePicker(true)} className="text-foreground hover:underline">
|
||||
{tEvent("time_zone_line", { zone: timeZoneCity(fieldZone) })}
|
||||
</button>
|
||||
{formYourTimes && (
|
||||
<span className="text-xs text-muted-foreground">{tEvent("your_time", { times: formYourTimes })}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-primary hover:underline">
|
||||
{tEvent("time_zone")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
<div
|
||||
key={w.key}
|
||||
className={
|
||||
w.severity === 'error'
|
||||
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
|
||||
: w.severity === 'info'
|
||||
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
|
||||
: 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2'
|
||||
}
|
||||
title={w.message}
|
||||
>
|
||||
{w.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id={labelFor("title-1")}
|
||||
value={recurrence}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as RecurrenceOption;
|
||||
if (value === "custom") {
|
||||
recurrenceBeforeCustomRef.current = recurrence;
|
||||
setRecurrence("custom");
|
||||
setShowRecurrenceEditor(true);
|
||||
} else {
|
||||
setRecurrence(value);
|
||||
setShowRecurrenceEditor(false);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
|
||||
</select>
|
||||
{recurrence === "custom" && !showRecurrenceEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecurrenceEditor(true)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("recurrence.edit_custom")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRecurrenceEditor && (
|
||||
<RecurrenceEditor
|
||||
rule={customRule}
|
||||
eventStart={(() => {
|
||||
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
|
||||
return isNaN(d.getTime()) ? displayNow() : d;
|
||||
})()}
|
||||
onSave={handleRecurrenceEditorSave}
|
||||
onCancel={handleRecurrenceEditorCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectableCalendars.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||
<select
|
||||
id={labelFor("calendar_select")}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{selectableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={labelFor("location")} className="text-sm font-medium mb-1 block">{t("form.location")}</label>
|
||||
<Input
|
||||
@@ -1394,151 +1631,22 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allDay"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="allDay" className="text-sm">{t("form.all_day_event")}</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
|
||||
<input
|
||||
id={labelFor("start_date")}
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
|
||||
<input
|
||||
id={labelFor("start_time")}
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
|
||||
<input
|
||||
id={labelFor("end_date")}
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
|
||||
<input
|
||||
id={labelFor("end_time")}
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
<div
|
||||
key={w.key}
|
||||
className={
|
||||
w.severity === 'error'
|
||||
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
|
||||
: w.severity === 'info'
|
||||
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
|
||||
: 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2'
|
||||
}
|
||||
title={w.message}
|
||||
>
|
||||
{w.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectableCalendars.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
||||
<select
|
||||
id={labelFor("calendar_select")}
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
{selectableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
{/* Only the organizer's messages: the editor never changes the
|
||||
user's own answer, which the answer buttons send. */}
|
||||
{attendees.length > 0 && userIsOrganizer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id={labelFor("title-1")}
|
||||
value={recurrence}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as RecurrenceOption;
|
||||
if (value === "custom") {
|
||||
recurrenceBeforeCustomRef.current = recurrence;
|
||||
setRecurrence("custom");
|
||||
setShowRecurrenceEditor(true);
|
||||
} else {
|
||||
setRecurrence(value);
|
||||
setShowRecurrenceEditor(false);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
<option value="custom">{customRuleSummary || t("recurrence.custom")}</option>
|
||||
</select>
|
||||
{recurrence === "custom" && !showRecurrenceEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecurrenceEditor(true)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("recurrence.edit_custom")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRecurrenceEditor && (
|
||||
<RecurrenceEditor
|
||||
rule={customRule}
|
||||
eventStart={(() => {
|
||||
const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`);
|
||||
return isNaN(d.getTime()) ? displayNow() : d;
|
||||
})()}
|
||||
onSave={handleRecurrenceEditorSave}
|
||||
onCancel={handleRecurrenceEditorCancel}
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sendInvitations"
|
||||
checked={sendInvitations}
|
||||
onChange={(e) => setSendInvitations(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="sendInvitations" className="text-sm">
|
||||
{t("participants.send_invitations")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
@@ -1572,22 +1680,18 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Only the organizer's messages: the editor never changes the
|
||||
user's own answer, which the answer buttons send. */}
|
||||
{attendees.length > 0 && userIsOrganizer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sendInvitations"
|
||||
checked={sendInvitations}
|
||||
onChange={(e) => setSendInvitations(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<label htmlFor="sendInvitations" className="text-sm">
|
||||
{t("participants.send_invitations")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
|
||||
<textarea
|
||||
id={labelFor("description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("form.description")}
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none focus:outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,9 @@ interface QuickCreatePopoverProps {
|
||||
}
|
||||
|
||||
const WIDTH = 340;
|
||||
// Wider for a calendar name that needs it ("Stalwart Calendar
|
||||
// (someone@example.org)" was cut off at 340 px, pim-21).
|
||||
const MAX_WIDTH = 480;
|
||||
const GAP = 8;
|
||||
const MARGIN = 12;
|
||||
|
||||
@@ -40,14 +43,14 @@ const MARGIN = 12;
|
||||
// popover sits beside it, or under it when neither side has room.
|
||||
const PLACEHOLDER_SELECTOR = "[data-pending-preview]";
|
||||
|
||||
function computePosition(anchor: DOMRect | null, height: number): { top: number; left: number } {
|
||||
export function computePosition(anchor: DOMRect | null, height: number, width = WIDTH): { top: number; left: number } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (!anchor) return { top: Math.max(MARGIN, (vh - height) / 2), left: Math.max(MARGIN, (vw - WIDTH) / 2) };
|
||||
if (!anchor) return { top: Math.max(MARGIN, (vh - height) / 2), left: Math.max(MARGIN, (vw - width) / 2) };
|
||||
const clampTop = (top: number) => Math.min(Math.max(MARGIN, top), vh - height - MARGIN);
|
||||
const clampLeft = (left: number) => Math.min(Math.max(MARGIN, left), vw - WIDTH - MARGIN);
|
||||
if (anchor.right + GAP + WIDTH + MARGIN <= vw) return { top: clampTop(anchor.top), left: anchor.right + GAP };
|
||||
if (anchor.left - GAP - WIDTH >= MARGIN) return { top: clampTop(anchor.top), left: anchor.left - GAP - WIDTH };
|
||||
const clampLeft = (left: number) => Math.min(Math.max(MARGIN, left), vw - width - MARGIN);
|
||||
if (anchor.right + GAP + width + MARGIN <= vw) return { top: clampTop(anchor.top), left: anchor.right + GAP };
|
||||
if (anchor.left - GAP - width >= MARGIN) return { top: clampTop(anchor.top), left: anchor.left - GAP - width };
|
||||
if (anchor.bottom + GAP + height + MARGIN <= vh) return { top: anchor.bottom + GAP, left: clampLeft(anchor.left) };
|
||||
return { top: Math.max(MARGIN, anchor.top - GAP - height), left: clampLeft(anchor.left) };
|
||||
}
|
||||
@@ -83,7 +86,7 @@ export function QuickCreatePopover({
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const anchor = document.querySelector(PLACEHOLDER_SELECTOR)?.getBoundingClientRect() ?? null;
|
||||
setPosition(computePosition(anchor, node.offsetHeight));
|
||||
setPosition(computePosition(anchor, node.offsetHeight, node.offsetWidth));
|
||||
};
|
||||
place();
|
||||
const frame = requestAnimationFrame(place);
|
||||
@@ -158,7 +161,9 @@ export function QuickCreatePopover({
|
||||
aria-label={t("quick_create.aria_label")}
|
||||
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl"
|
||||
style={{
|
||||
width: WIDTH,
|
||||
width: "max-content",
|
||||
minWidth: WIDTH,
|
||||
maxWidth: `min(${MAX_WIDTH}px, calc(100vw - ${2 * MARGIN}px))`,
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
visibility: position ? "visible" : "hidden",
|
||||
@@ -198,6 +203,7 @@ export function QuickCreatePopover({
|
||||
value={draft.calendarId}
|
||||
onChange={(e) => onChange({ ...draft, calendarId: e.target.value })}
|
||||
aria-label={t("form.calendar_select")}
|
||||
title={calendars.find((c) => c.id === draft.calendarId)?.name}
|
||||
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
|
||||
>
|
||||
{calendars.map((c) => (
|
||||
|
||||
@@ -28,12 +28,24 @@ export const STATUS_LABEL: Record<string, string> = {
|
||||
"needs-action": "participants.needs_action",
|
||||
};
|
||||
|
||||
/**
|
||||
* The statuses in plain words for the participant lists (pim-19): the
|
||||
* standard's "Needs action" reads "Not answered yet". Keys of ux_pim_event.
|
||||
*/
|
||||
export const PARTICIPANT_STATUS_WORDS: Record<string, string> = {
|
||||
accepted: "status_accepted",
|
||||
declined: "status_declined",
|
||||
tentative: "status_tentative",
|
||||
"needs-action": "status_needs_action",
|
||||
delegated: "status_delegated",
|
||||
};
|
||||
|
||||
/** A participant's answer, as a short coloured word. */
|
||||
export function ParticipantStatusBadge({ status, className }: { status: Status; className?: string }) {
|
||||
const t = useTranslations("calendar");
|
||||
const t = useTranslations("ux_pim_event");
|
||||
return (
|
||||
<span className={cn("text-xs flex-shrink-0", STATUS_TEXT[status] ?? "", className)}>
|
||||
{t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
|
||||
{t(PARTICIPANT_STATUS_WORDS[status] ?? PARTICIPANT_STATUS_WORDS["needs-action"])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useCallback, useId, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { parseISO, isBefore, isTomorrow } from "date-fns";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import { Check, Flag, CalendarDays, ListTodo, Plus } from "@/components/icons";
|
||||
import { Check, Flag, CalendarDays, ListTodo, Plus, X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { parseTaskDue } from "@/lib/task-due-parser";
|
||||
|
||||
interface TaskListViewProps {
|
||||
tasks: CalendarTask[];
|
||||
calendars: Calendar[];
|
||||
selectedCalendarIds: string[];
|
||||
filter: TaskViewFilter;
|
||||
onSelectTask: (task: CalendarTask) => void;
|
||||
onToggleComplete: (task: CalendarTask) => void;
|
||||
selectedTaskId?: string | null;
|
||||
onQuickCreate?: (title: string) => void;
|
||||
/** `due`: the date read from the end of the title (yyyy-MM-dd), or null. */
|
||||
onQuickCreate?: (title: string, due: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new task goes (pim-8): the first task-only list, else the default
|
||||
* calendar, else the first one. `calendars` are the ones the user may write.
|
||||
* The user's own calendars of the active login come first: a colleague's
|
||||
* shared list or another login's is never picked over them.
|
||||
*/
|
||||
export function defaultTaskList(calendars: Calendar[]): Calendar | undefined {
|
||||
const pick = (list: Calendar[]) => list.find((c) => c.isTasksOnly) ?? list.find((c) => c.isDefault) ?? list[0];
|
||||
return pick(calendars.filter((c) => !c.isShared && !c.localAccountId)) ?? pick(calendars);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task lists the tasks view offers (pim-1): every task-only calendar and
|
||||
* every calendar that holds a task, in the calendars' order.
|
||||
*/
|
||||
export function taskListsOf(calendars: Calendar[], tasks: CalendarTask[]): Calendar[] {
|
||||
const holding = new Set(tasks.flatMap((task) => Object.keys(task.calendarIds ?? {})));
|
||||
return calendars.filter((c) => c.isTasksOnly || holding.has(c.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add a task…": reads a date at the end of the title (pim-24) and shows it
|
||||
* as a chip before the task is made; the chip's × keeps the words in the
|
||||
* title instead.
|
||||
*/
|
||||
function QuickAddTask({ onQuickCreate }: { onQuickCreate: (title: string, due: string | null) => void }) {
|
||||
const t = useTranslations("calendar");
|
||||
const tUx = useTranslations("ux_pim_calendar");
|
||||
const locale = useLocale();
|
||||
const { dateTime: formatDate } = useDisplayDateFormatter();
|
||||
const dueId = useId();
|
||||
const [title, setTitle] = useState("");
|
||||
// The text whose date the user turned down with the chip's ×.
|
||||
const [declined, setDeclined] = useState<string | null>(null);
|
||||
const parsed = useMemo(
|
||||
() => (title.trim() && title !== declined ? parseTaskDue(title, locale, displayNow()) : null),
|
||||
[title, declined, locale],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
if (parsed) onQuickCreate(parsed.title, parsed.due);
|
||||
else onQuickCreate(trimmed, null);
|
||||
setTitle("");
|
||||
setDeclined(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && title.trim()) submit();
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
aria-label={t("tasks.quick_add_placeholder")}
|
||||
aria-describedby={parsed ? dueId : undefined}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{parsed && (
|
||||
<span
|
||||
id={dueId}
|
||||
data-testid="quick-task-due"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded-full bg-primary/10 ps-2 pe-0.5 py-0.5 text-xs text-primary"
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" aria-hidden="true" />
|
||||
{tUx("quick_task_due", { date: formatDate(parseISO(parsed.due), { weekday: "short", month: "short", day: "numeric" }) })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeclined(title)}
|
||||
className="rounded-full p-0.5 hover:bg-primary/15"
|
||||
aria-label={tUx("quick_task_due_remove", { words: parsed.matched })}
|
||||
title={tUx("quick_task_due_remove", { words: parsed.matched })}
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskPriorityIcon(priority: number) {
|
||||
@@ -66,15 +156,21 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
|
||||
};
|
||||
}
|
||||
|
||||
/** The tasks the list shows, in its order: filtered by calendar and status. "All" is all of them. */
|
||||
/**
|
||||
* The tasks the list shows, in its order: filtered by task list and status.
|
||||
* "All" is all of them. The lists are the tasks view's own (pim-1), not the
|
||||
* grid's calendar selection, which never holds a task-only list: a task
|
||||
* leaves the list only when the user unticks its list.
|
||||
*/
|
||||
export function filterTasksForList(
|
||||
tasks: CalendarTask[],
|
||||
selectedCalendarIds: string[],
|
||||
hiddenTaskListIds: string[],
|
||||
filter: TaskViewFilter,
|
||||
): CalendarTask[] {
|
||||
const hidden = new Set(hiddenTaskListIds);
|
||||
let result = tasks.filter(task => {
|
||||
const calIds = Object.keys(task.calendarIds);
|
||||
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||
const calIds = Object.keys(task.calendarIds ?? {});
|
||||
return calIds.length === 0 || calIds.some(id => !hidden.has(id));
|
||||
});
|
||||
|
||||
switch (filter) {
|
||||
@@ -118,7 +214,6 @@ export function filterTasksForList(
|
||||
export function TaskListView({
|
||||
tasks,
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
filter,
|
||||
onSelectTask,
|
||||
onToggleComplete,
|
||||
@@ -128,12 +223,12 @@ export function TaskListView({
|
||||
const t = useTranslations("calendar");
|
||||
const paint = useCvdColor();
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds);
|
||||
const { dateTime: formatDate } = useDisplayDateFormatter();
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() => filterTasksForList(tasks, selectedCalendarIds, filter),
|
||||
[tasks, selectedCalendarIds, filter],
|
||||
() => filterTasksForList(tasks, hiddenTaskListIds, filter),
|
||||
[tasks, hiddenTaskListIds, filter],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
|
||||
@@ -144,27 +239,7 @@ export function TaskListView({
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
aria-label={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onQuickCreate && <QuickAddTask onQuickCreate={onQuickCreate} />}
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||
@@ -175,27 +250,7 @@ export function TaskListView({
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto max-md:pb-20">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
aria-label={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onQuickCreate && <QuickAddTask onQuickCreate={onQuickCreate} />}
|
||||
<div className="divide-y divide-border">
|
||||
{filteredTasks.map(task => {
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { taskToView } from "@/lib/plugin-projection";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { canCreateEventsIn } from "@/lib/calendar-editability";
|
||||
import { defaultTaskList } from "@/components/calendar/task-list-view";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
@@ -61,6 +62,7 @@ export function TaskModal({
|
||||
isSubscriptionCalendar,
|
||||
}: TaskModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tUx = useTranslations("ux_pim_calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
// The header's close goes to its start when the window's own close is at its end.
|
||||
const [header, setHeader] = useState<HTMLDivElement | null>(null);
|
||||
@@ -74,7 +76,8 @@ export function TaskModal({
|
||||
// The calendars a task can go into: not read-only ones (Birthdays,
|
||||
// feeds), the same rule as the event editor.
|
||||
const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar));
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
// A new task goes to the first task-only list (pim-8).
|
||||
const defaultCalendarId = defaultTaskList(writableCalendars)?.id ?? calendars[0]?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState(task?.title ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
@@ -329,8 +332,9 @@ export function TaskModal({
|
||||
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||
{/* Plain words; the stored values stay the standard's (pim-19). */}
|
||||
<option value="needs-action">{tUx("task_status_not_started")}</option>
|
||||
<option value="in-process">{tUx("task_status_in_progress")}</option>
|
||||
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||
</select>
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('ContactDetail', () => {
|
||||
render(<ContactDetail contact={typed} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
// The test translator returns the key, so a translated label shows up as its key.
|
||||
expect(screen.getByText('form.context_work')).toBeInTheDocument();
|
||||
expect(screen.getByText('form.context_private · form.phone_cell, x-sat')).toBeInTheDocument();
|
||||
expect(screen.getByText('ux_pim_contact.context_private · form.phone_cell, x-sat')).toBeInTheDocument();
|
||||
// Custom vCard types are shown as stored.
|
||||
expect(screen.getByText('form.context_work, x-hq')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -579,3 +579,127 @@ describe('ContactForm', () => {
|
||||
expect(options).toContain('Team (userb@example.org)');
|
||||
});
|
||||
});
|
||||
|
||||
// pim-4: saving never rewrites what the user didn't touch.
|
||||
describe('ContactForm keeps what was not edited', () => {
|
||||
// As a card with a structured German address comes from ContactCard/get.
|
||||
const home = {
|
||||
'@type': 'Address',
|
||||
contexts: { private: true },
|
||||
countryCode: 'DE',
|
||||
full: 'Hauptstraße 1, 3. OG, 10000 Berlin',
|
||||
isOrdered: true,
|
||||
defaultSeparator: ', ',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Hauptstraße' },
|
||||
{ kind: 'separator', value: ' ' },
|
||||
{ kind: 'number', value: '1' },
|
||||
{ kind: 'apartment', value: '3. OG' },
|
||||
{ kind: 'postcode', value: '10000' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
],
|
||||
};
|
||||
const office = {
|
||||
components: [
|
||||
{ kind: 'number', value: '1' },
|
||||
{ kind: 'name', value: 'Main Street' },
|
||||
{ kind: 'locality', value: 'Springfield' },
|
||||
{ kind: 'region', value: 'IL' },
|
||||
{ kind: 'postcode', value: '62701' },
|
||||
{ kind: 'country', value: 'USA' },
|
||||
],
|
||||
contexts: { work: true },
|
||||
};
|
||||
const card: ContactCard = {
|
||||
...existingContact,
|
||||
nicknames: { nick: { name: 'Ali', contexts: { private: true } } },
|
||||
emails: { home: { address: 'alice@example.com', pref: 1, label: 'main' }, w: { address: 'alice@work.example', contexts: { work: true } } },
|
||||
addresses: { home, office } as ContactCard['addresses'],
|
||||
};
|
||||
|
||||
async function save(change: () => void) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm contact={card} onSave={onSave} onCancel={vi.fn()} />);
|
||||
change();
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
|
||||
return onSave.mock.calls[0][0] as Partial<ContactCard>;
|
||||
}
|
||||
|
||||
it('sends only the nickname when only the nickname changed', async () => {
|
||||
const data = await save(() => fireEvent.change(screen.getByDisplayValue('Ali'), { target: { value: 'Lissy' } }));
|
||||
expect(Object.keys(data)).toEqual(['nicknames']);
|
||||
// The nickname keeps its key and its contexts.
|
||||
expect(data.nicknames).toEqual({ nick: { name: 'Lissy', contexts: { private: true } } });
|
||||
});
|
||||
|
||||
it("shows street and number in the address's own order, German fields as Street, Postcode + City", () => {
|
||||
render(<ContactForm contact={card} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByDisplayValue('Hauptstraße 1')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('1 Main Street')).toBeInTheDocument();
|
||||
const berlin = screen.getByDisplayValue('Berlin');
|
||||
const postcode = screen.getByDisplayValue('10000');
|
||||
// Postcode comes before the city for Germany, after it for the US.
|
||||
expect(postcode.compareDocumentPosition(berlin) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const springfield = screen.getByDisplayValue('Springfield');
|
||||
const zip = screen.getByDisplayValue('62701');
|
||||
expect(springfield.compareDocumentPosition(zip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps an untouched address exactly as stored when another address is edited', async () => {
|
||||
const data = await save(() => fireEvent.change(screen.getByDisplayValue('62701'), { target: { value: '62702' } }));
|
||||
expect(data.addresses!.home).toBe(home);
|
||||
expect(data.addresses!.office.components!.find(c => c.kind === 'postcode')!.value).toBe('62702');
|
||||
expect('emails' in data).toBe(false);
|
||||
});
|
||||
|
||||
// An address with only parts the form has no field for shows as empty rows.
|
||||
// Editing another address dropped it from the save.
|
||||
it('keeps an address the form cannot show when another address is edited', async () => {
|
||||
const box = { components: [{ kind: 'postOfficeBox', value: 'Postfach 12 34' }], contexts: { work: true } };
|
||||
const textOnly = { full: 'c/o Example GmbH, somewhere' };
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm contact={{ ...card, addresses: { home, office, box, textOnly } as ContactCard['addresses'] }} onSave={onSave} onCancel={vi.fn()} />);
|
||||
fireEvent.change(screen.getByDisplayValue('62701'), { target: { value: '62702' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
|
||||
const data = onSave.mock.calls[0][0] as Partial<ContactCard>;
|
||||
expect(data.addresses!.box).toBe(box);
|
||||
expect(data.addresses!.textOnly).toBe(textOnly);
|
||||
});
|
||||
|
||||
it('saves an edited German address in German order and keeps the parts the form does not show', async () => {
|
||||
const data = await save(() => fireEvent.change(screen.getByDisplayValue('Hauptstraße 1'), { target: { value: 'Hauptstraße 12a' } }));
|
||||
const saved = data.addresses!.home;
|
||||
expect(saved.components!.map(c => `${c.kind}:${c.value}`)).toEqual([
|
||||
'name:Hauptstraße', 'separator: ', 'number:12a', 'apartment:3. OG', 'postcode:10000', 'separator: ', 'locality:Berlin', 'country:Germany',
|
||||
]);
|
||||
expect(saved).toMatchObject({ '@type': 'Address', countryCode: 'DE', contexts: { private: true }, isOrdered: true, defaultSeparator: ', ' });
|
||||
// The old one-line text would contradict the new parts.
|
||||
expect(saved.full).toBeUndefined();
|
||||
expect(data.addresses!.office).toBe(office);
|
||||
});
|
||||
|
||||
it('keeps an untouched email with its key, pref and label when another is edited', async () => {
|
||||
const data = await save(() => fireEvent.change(screen.getByDisplayValue('alice@work.example'), { target: { value: 'alice@new.example' } }));
|
||||
expect(data.emails!.home).toBe(card.emails!.home);
|
||||
expect(data.emails!.w).toEqual({ address: 'alice@new.example', contexts: { work: true } });
|
||||
});
|
||||
|
||||
it('still sends every field of a new contact, its address in the order of the country typed', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
|
||||
fireEvent.click(screen.getByText('addresses'));
|
||||
fireEvent.click(screen.getByText('add_address'));
|
||||
fireEvent.change(screen.getByPlaceholderText('street'), { target: { value: 'Hauptstraße 5' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('country'), { target: { value: 'Deutschland' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
|
||||
const data = onSave.mock.calls[0][0] as Partial<ContactCard>;
|
||||
expect('emails' in data && 'notes' in data && 'name' in data).toBe(true);
|
||||
expect(data.addresses!.a0).toMatchObject({ countryCode: 'DE' });
|
||||
expect(data.addresses!.a0.components!.map(c => `${c.kind}:${c.value}`)).toEqual(['name:Hauptstraße', 'separator: ', 'number:5', 'country:Deutschland']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
|
||||
import { ContactImportDialog } from '../contact-import-dialog';
|
||||
|
||||
// pim-11: a CSV from Outlook or Google goes through a column step, then the
|
||||
// usual preview with the duplicate check, into the address book chosen.
|
||||
|
||||
const OUTLOOK_DE = [
|
||||
'"Vorname";"Nachname";"Firma";"E-Mail-Adresse";"E-Mail-Typ";"Mobiltelefon";"Privat"',
|
||||
'"Jürgen";"Beispiel";"Muster GmbH";"juergen@example.de";"SMTP";"+49 170 1111111";"Aus"',
|
||||
'"Ada";"Lovelace";"";"ada@example.org";"SMTP";"";"Aus"',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
/** German Outlook's bytes: Windows-1252, so "ü" is one byte. */
|
||||
function cp1252File(text: string, name = 'kontakte.csv'): File {
|
||||
const bytes = Uint8Array.from([...text].map((ch) => ch.charCodeAt(0)));
|
||||
return new File([bytes], name, { type: 'text/csv' });
|
||||
}
|
||||
|
||||
const books = [
|
||||
{ id: 'b-personal', name: 'Personal', isDefault: true },
|
||||
{ id: 'b-work', name: 'Work' },
|
||||
] as AddressBook[];
|
||||
|
||||
const existing: ContactCard[] = [{ id: 'x', addressBookIds: {}, emails: { e: { address: 'ada@example.org' } } }];
|
||||
|
||||
describe('ContactImportDialog with a CSV file', () => {
|
||||
it('asks for the columns, then previews the cards and imports them into the book chosen', async () => {
|
||||
const onImport = vi.fn(async (cards: ContactCard[]) => cards.length);
|
||||
render(
|
||||
<ContactImportDialog
|
||||
existingContacts={existing}
|
||||
onImport={onImport}
|
||||
onClose={vi.fn()}
|
||||
initialFile={cp1252File(OUTLOOK_DE)}
|
||||
addressBooks={books}
|
||||
defaultAddressBookId="b-work"
|
||||
/>,
|
||||
);
|
||||
|
||||
const table = await screen.findByTestId('csv-columns');
|
||||
// Each column with an example and a field: known ones matched, the rest left out.
|
||||
expect(within(table).getByText('Jürgen')).toBeInTheDocument();
|
||||
expect(within(table).getByRole('combobox', { name: 'Vorname' })).toHaveValue('name.given');
|
||||
expect(within(table).getByRole('combobox', { name: 'E-Mail-Adresse' })).toHaveValue('email');
|
||||
expect(within(table).getByRole('combobox', { name: 'E-Mail-Typ' })).toHaveValue('skip');
|
||||
expect(within(table).getByRole('combobox', { name: 'Privat' })).toHaveValue('skip');
|
||||
// The user leaves out the company.
|
||||
fireEvent.change(within(table).getByRole('combobox', { name: 'Firma' }), { target: { value: 'skip' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'continue' }));
|
||||
|
||||
expect(await screen.findByText('Jürgen Beispiel')).toBeInTheDocument();
|
||||
// The existing address is marked as a duplicate and not ticked.
|
||||
expect(screen.getByText('import.duplicate')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveValue('b-work');
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'b-personal' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
|
||||
await waitFor(() => expect(onImport).toHaveBeenCalled());
|
||||
const [cards, bookId] = onImport.mock.calls[0] as unknown as [ContactCard[], string];
|
||||
expect(bookId).toBe('b-personal');
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0].name?.components).toEqual([{ kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: 'Beispiel' }]);
|
||||
expect(Object.values(cards[0].phones ?? {})).toEqual([{ number: '+49 170 1111111', features: { mobile: true } }]);
|
||||
expect(cards[0].organizations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('goes back from the preview to the columns', async () => {
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={cp1252File(OUTLOOK_DE)} />);
|
||||
await screen.findByTestId('csv-columns');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'continue' }));
|
||||
await screen.findByText('Ada Lovelace');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'back' }));
|
||||
expect(await screen.findByTestId('csv-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cannot continue with every column left out', async () => {
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={cp1252File('Foo,Bar\r\n1,2\r\n')} />);
|
||||
await screen.findByTestId('csv-columns');
|
||||
expect(screen.getByRole('button', { name: 'continue' })).toBeDisabled();
|
||||
expect(screen.getByText('no_columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('takes .csv in the file picker', () => {
|
||||
const { container } = render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(container.ownerDocument.querySelector('input[type="file"]')?.getAttribute('accept')).toContain('.csv');
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,7 @@ describe('ContactsApp', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
|
||||
fireEvent.click(screen.getByText('import.title'));
|
||||
fireEvent.click(screen.getByText('import_menu'));
|
||||
const dialog = screen.getByRole('dialog', { name: 'import.title' });
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
|
||||
@@ -273,7 +273,7 @@ describe('ContactsApp', () => {
|
||||
try {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more' }));
|
||||
fireEvent.click(screen.getByText('import.title'));
|
||||
fireEvent.click(screen.getByText('import_menu'));
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
} finally {
|
||||
window.matchMedia = matchMedia;
|
||||
@@ -287,7 +287,7 @@ describe('ContactsApp', () => {
|
||||
fireEvent.click(within(drawer()).getByText('Work'));
|
||||
expect(screen.getByText('empty_view_title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('empty_state_title')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /import.title/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /import_menu/ }));
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -299,12 +299,95 @@ describe('ContactsApp', () => {
|
||||
expect(consumePendingMailto()).toMatchObject({ to: [expect.stringContaining('alice@example.com')], inApp: true });
|
||||
});
|
||||
|
||||
it('says so when a duplicate fails', async () => {
|
||||
renderContacts();
|
||||
const addLocalContact = vi.fn(async () => { throw new Error('quota'); });
|
||||
act(() => { useContactStore.setState({ addLocalContact }); });
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.duplicate'));
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create'));
|
||||
describe('merging duplicates (pim-14)', () => {
|
||||
const anna1: ContactCard = {
|
||||
id: 'c-anna1', uid: 'urn:uuid:anna1', addressBookIds: {}, updated: '2026-01-01T00:00:00Z',
|
||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
|
||||
emails: { e0: { address: 'anna@home.example.net' } },
|
||||
addresses: { a0: { components: [{ kind: 'name', value: 'Hauptstraße 1' }, { kind: 'locality', value: 'Berlin' }], isOrdered: true } },
|
||||
};
|
||||
const anna2: ContactCard = {
|
||||
id: 'c-anna2', uid: 'urn:uuid:anna2', addressBookIds: {}, updated: '2026-06-01T00:00:00Z',
|
||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
|
||||
emails: { e0: { address: 'anna@example.net' } },
|
||||
};
|
||||
const club: ContactCard = { id: 'g-club', kind: 'group', addressBookIds: {}, name: { full: 'Club' }, members: { 'urn:uuid:anna1': true } };
|
||||
const lastToast = () => useToastStore.getState().toasts.at(-1);
|
||||
|
||||
it('merges two ticked contacts from the selection bar, keeps all their data and group, and Undo brings the other back', async () => {
|
||||
renderContacts([anna1, anna2, bob, club]);
|
||||
fireEvent.click(screen.getAllByText('Anna Example')[0]);
|
||||
fireEvent.click(screen.getAllByText('Anna Example')[1], { ctrlKey: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge_selected' }));
|
||||
const preview = await screen.findByTestId('merge-preview');
|
||||
expect(within(preview).getByText('anna@example.net')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Club')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
|
||||
|
||||
await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c-anna2', 'c-bob', 'g-club']));
|
||||
const kept = useContactStore.getState().contacts[0];
|
||||
expect(Object.values(kept.emails ?? {}).map((e) => e.address)).toEqual(['anna@example.net', 'anna@home.example.net']);
|
||||
expect(Object.values(kept.addresses ?? {})).toEqual([anna1.addresses!.a0]);
|
||||
expect(useContactStore.getState().contacts[2].members).toEqual({ 'urn:uuid:anna2': true });
|
||||
expect(lastToast()?.title).toBe('merged');
|
||||
|
||||
await act(async () => { lastToast()?.action?.onClick(); });
|
||||
await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id).sort()).toEqual(['c-anna1', 'c-anna2', 'c-bob', 'g-club']));
|
||||
const restored = useContactStore.getState().contacts;
|
||||
expect(Object.keys(restored.find((c) => c.id === 'c-anna2')!.emails ?? {})).toEqual(['e0']);
|
||||
expect(restored.find((c) => c.id === 'g-club')?.members).toEqual({ 'urn:uuid:anna1': true });
|
||||
});
|
||||
|
||||
it('lists possible duplicates over the list and merges a set from there', async () => {
|
||||
renderContacts([anna1, anna2, bob]);
|
||||
fireEvent.click(screen.getByText('possible_duplicates'));
|
||||
const view = screen.getByTestId('contact-duplicates');
|
||||
expect(within(view).getAllByText('Anna Example')).toHaveLength(2);
|
||||
expect(within(view).queryByText('Bob Jones')).toBeNull();
|
||||
fireEvent.click(within(view).getByRole('button', { name: /merge_selected/ }));
|
||||
await screen.findByTestId('merge-preview');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
|
||||
await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c-anna2', 'c-bob']));
|
||||
// Nothing left to merge: the line goes away.
|
||||
await waitFor(() => expect(screen.queryByText('possible_duplicates')).toBeNull());
|
||||
});
|
||||
|
||||
it('offers Merge with… instead of Duplicate, picks the other card, then merges', async () => {
|
||||
renderContacts([anna1, anna2, bob]);
|
||||
fireEvent.contextMenu(screen.getByText('Bob Jones'));
|
||||
expect(screen.queryByText('context_menu.duplicate')).toBeNull();
|
||||
fireEvent.click(screen.getByText('merge_with'));
|
||||
const list = await screen.findByTestId('merge-pick-list');
|
||||
fireEvent.click(within(list).getAllByText('Anna Example')[0]);
|
||||
await screen.findByTestId('merge-preview');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
|
||||
await waitFor(() => expect(useContactStore.getState().contacts).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('says so when the merge fails, and deletes nothing', async () => {
|
||||
renderContacts([anna1, anna2]);
|
||||
const updateLocalContact = vi.fn(async () => { throw new Error('quota'); });
|
||||
act(() => { useContactStore.setState({ updateLocalContact }); });
|
||||
fireEvent.click(screen.getByText('possible_duplicates'));
|
||||
fireEvent.click(within(screen.getByTestId('contact-duplicates')).getByRole('button', { name: /merge_selected/ }));
|
||||
await screen.findByTestId('merge-preview');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
|
||||
await waitFor(() => expect(lastToast()?.title).toBe('merge_failed'));
|
||||
expect(useContactStore.getState().contacts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("marks no member in the list on a group's page, even with a contact open before", () => {
|
||||
const team: ContactCard = { id: 'g-team', addressBookIds: {}, kind: 'group', name: { full: 'Team' }, members: { 'c-alice': true, 'c-bob': true } };
|
||||
renderContacts([alice, bob, team]);
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByText('Team'));
|
||||
expect(screen.getByText('groups.members_label')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-contact-id="c-alice"]')).toHaveAttribute('aria-selected', 'false');
|
||||
expect(document.querySelectorAll('[data-contact-id][aria-selected="true"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
|
||||
// pim-15: the desktop app's Edit > Search contacts and File > Import > Contacts…,
|
||||
// on the whole Contacts app with the shell around it stubbed out.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { requestAppCommand } from '@/lib/app-commands';
|
||||
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: 'c-alice',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const FAKE_CLIENT = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
|
||||
function renderContacts() {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
|
||||
useContactStore.setState({
|
||||
contacts: [alice],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
selectedContactIds: new Set(),
|
||||
searchQuery: '',
|
||||
supportsSync: false,
|
||||
contactsLoaded: true,
|
||||
});
|
||||
});
|
||||
return render(<ContactsApp />);
|
||||
}
|
||||
|
||||
function fakeBridge() {
|
||||
const setState = vi.fn();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState, onCommand: () => () => {} } } as unknown as BulwarkDesktopBridge;
|
||||
return { setState };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contacts in the desktop menus', () => {
|
||||
it('Edit > Search contacts is on offer and puts the cursor in the contacts search', async () => {
|
||||
const shell = fakeBridge();
|
||||
renderContacts();
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'edit.search': true });
|
||||
let ran = false;
|
||||
act(() => { ran = runDesktopCommand('edit.search'); });
|
||||
expect(ran).toBe(true);
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('searchbox', { name: 'search_placeholder' })[0]);
|
||||
});
|
||||
|
||||
it('File > Import > Contacts… opens the contacts import', () => {
|
||||
renderContacts();
|
||||
expect(screen.queryByRole('dialog', { name: 'import.title' })).toBeNull();
|
||||
let taken = false;
|
||||
act(() => { taken = requestAppCommand('import-contacts'); });
|
||||
expect(taken).toBe(true);
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,22 +17,23 @@ const alice: ContactCard = {
|
||||
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
|
||||
|
||||
describe('one action list per contact (R6)', () => {
|
||||
it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
|
||||
it('offers Merge with… in the right-click menu as in the detail ⋮, and no Duplicate', () => {
|
||||
const noop = vi.fn();
|
||||
render(
|
||||
<ContactList
|
||||
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
|
||||
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
|
||||
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
|
||||
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
|
||||
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onMergeContact={noop} onComposeContact={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('Alice'));
|
||||
const rightClick = menuLabels();
|
||||
expect(rightClick).toContain('context_menu.duplicate');
|
||||
expect(rightClick).toContain('merge_with');
|
||||
expect(rightClick).not.toContain('context_menu.duplicate');
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
|
||||
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
|
||||
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onMergeWith={noop} onAddToGroup={noop} onCompose={noop} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
|
||||
const detail = menuLabels();
|
||||
// The detail's ⋮ is the same list, without what the page has as buttons.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// Keys with their namespace, so a label read from ux_pim_contact can be told
|
||||
// from the old one in contacts.form.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
|
||||
}));
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], activeAccountId: null };
|
||||
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
|
||||
hook.getState = () => state;
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
|
||||
import { ContactForm } from '../contact-form';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import { ContactsSidebar } from '../contacts-sidebar';
|
||||
import { ContactList } from '../contact-list';
|
||||
import { ContactGroupDetail } from '../contact-group-detail';
|
||||
|
||||
const anna: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
|
||||
emails: { e0: { address: 'anna@example.net', contexts: { private: true } } },
|
||||
phones: { p0: { number: '+49 30 1', features: { voice: true }, contexts: { private: true } } },
|
||||
};
|
||||
const ben: ContactCard = {
|
||||
id: 'c2',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Ben' }, { kind: 'surname', value: 'Muster' }], isOrdered: true },
|
||||
emails: { e0: { address: 'ben@example.net' } },
|
||||
};
|
||||
|
||||
describe('pim-19: standard words read as people say them', () => {
|
||||
it('offers "Phone" and "Home" in the editor, storing voice and private as before', () => {
|
||||
render(<ContactForm contact={anna} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const phoneType = screen.getByDisplayValue('ux_pim_contact.phone_voice') as HTMLSelectElement;
|
||||
expect(phoneType.value).toBe('voice');
|
||||
expect(screen.getAllByDisplayValue('ux_pim_contact.context_private').map(s => (s as HTMLSelectElement).value)).toEqual(['private', 'private']);
|
||||
expect(screen.queryByText('contacts.form.phone_voice')).toBeNull();
|
||||
expect(screen.queryByText('contacts.form.context_private')).toBeNull();
|
||||
});
|
||||
|
||||
it('labels a voice phone "Phone" and a private one "Home" on the contact page', () => {
|
||||
render(<ContactDetail contact={anna} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('ux_pim_contact.context_private · ux_pim_contact.phone_voice')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pim-21: a long name in the contact header', () => {
|
||||
it('wraps to two lines and shows the whole name on hover', () => {
|
||||
const long = { ...anna, name: { full: 'Maximiliane Alexandra Friederike von Hohenzollern-Sigmaringen-Wittelsbach' } };
|
||||
render(<ContactDetail contact={long} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading).toHaveAttribute('title', long.name.full);
|
||||
expect(heading.className).toContain('line-clamp-2');
|
||||
expect(heading.className).not.toMatch(/\btruncate\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pim-26: the contact editor keeps rare fields under "More fields"', () => {
|
||||
it('collapses Gender, Calendar, Online services and Personal info when none has a value', () => {
|
||||
render(<ContactForm contact={anna} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText('ux_pim_contact.more_fields')).toBeInTheDocument();
|
||||
expect(screen.queryByText('contacts.form.gender_sex')).toBeNull();
|
||||
expect(screen.queryByText('contacts.form.calendar_uri')).toBeNull();
|
||||
expect(screen.queryByText('contacts.form.add_online_service')).toBeNull();
|
||||
expect(screen.queryByText('contacts.form.add_personal_info')).toBeNull();
|
||||
fireEvent.click(screen.getByText('ux_pim_contact.more_fields'));
|
||||
expect(screen.getByText('contacts.form.gender_sex')).toBeInTheDocument();
|
||||
expect(screen.getByText('contacts.form.calendar_uri')).toBeInTheDocument();
|
||||
expect(screen.getByText('contacts.form.add_online_service')).toBeInTheDocument();
|
||||
expect(screen.getByText('contacts.form.add_personal_info')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens "More fields" when one of them has a value', () => {
|
||||
render(<ContactForm contact={{ ...anna, calendarUri: 'https://cal.example/anna' }} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByDisplayValue('https://cal.example/anna')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function renderSidebar(addressBooks: AddressBook[]) {
|
||||
return render(
|
||||
<ContactsSidebar
|
||||
groups={[]}
|
||||
individuals={[]}
|
||||
addressBooks={addressBooks}
|
||||
activeCategory="all"
|
||||
onSelectCategory={vi.fn()}
|
||||
onCreateGroup={vi.fn()}
|
||||
onCreateContact={vi.fn()}
|
||||
onCreateAddressBook={vi.fn()}
|
||||
onImport={vi.fn()}
|
||||
onEditGroup={vi.fn()}
|
||||
onDeleteGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('pim-22: the Contacts sidebar', () => {
|
||||
it("leaves out the app's own Trusted Senders book, which Settings manages", () => {
|
||||
const { unmount } = renderSidebar([
|
||||
{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook,
|
||||
{ id: 'b2', name: 'Trusted Senders', isDefault: false } as AddressBook,
|
||||
]);
|
||||
expect(screen.getByText('Contacts')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Trusted Senders')).toBeNull();
|
||||
unmount();
|
||||
// Someone else's book with that name is theirs, not the app's list.
|
||||
renderSidebar([
|
||||
{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook,
|
||||
{ id: 'b3', name: 'Trusted Senders', isShared: true, accountId: 'acc-b', accountName: 'b@example.org' } as AddressBook,
|
||||
]);
|
||||
expect(screen.getByText('Trusted Senders')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pim-26: the Contacts menus', () => {
|
||||
it('ends "Import contacts" and "New address book" in "…", as they open a dialog', () => {
|
||||
renderSidebar([{ id: 'b1', name: 'Contacts', isDefault: true } as AddressBook]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'contacts.new' }));
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(within(menu).getByText('ux_pim_contact.import_menu')).toBeInTheDocument();
|
||||
expect(within(menu).getByText('ux_pim_contact.new_address_book_menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const listProps = {
|
||||
contacts: [anna, ben],
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
selectedContactIds: new Set<string>(),
|
||||
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(),
|
||||
};
|
||||
|
||||
describe('pim-17: right-clicking a contact marks whose menu is open', () => {
|
||||
it('gives the right-clicked row the hover colour while its menu is open', () => {
|
||||
render(<ContactList {...listProps} selectedContactId="c1" />);
|
||||
const row = (id: string) => document.querySelector(`[data-contact-id="${id}"]`) as HTMLElement;
|
||||
expect(row('c2')).not.toHaveAttribute('data-menu-open');
|
||||
fireEvent.contextMenu(row('c2'));
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
expect(row('c2')).toHaveAttribute('data-menu-open');
|
||||
expect(row('c2').className).toMatch(/(^|\s)bg-muted(\s|$)/);
|
||||
// The open contact stays as it is.
|
||||
expect(row('c1')).not.toHaveAttribute('data-menu-open');
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
expect(row('c2')).not.toHaveAttribute('data-menu-open');
|
||||
});
|
||||
});
|
||||
|
||||
describe("pim-18: a group's page manages its members", () => {
|
||||
it('shows "Remove from group" on every member row, not only on hover', () => {
|
||||
render(
|
||||
<ContactGroupDetail
|
||||
group={{ id: 'g1', addressBookIds: {}, kind: 'group', name: { full: 'Team' }, members: { c1: true, c2: true } }}
|
||||
members={[anna, ben]}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRemoveMember={vi.fn()}
|
||||
onSelectMember={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const remove = screen.getAllByRole('button', { name: 'contacts.groups.remove_member' });
|
||||
expect(remove).toHaveLength(2);
|
||||
for (const button of remove) expect(button.className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Download,
|
||||
Users,
|
||||
Trash2,
|
||||
Copy,
|
||||
Merge,
|
||||
Printer,
|
||||
FolderInput,
|
||||
CheckSquare,
|
||||
@@ -58,13 +58,16 @@ interface ContactContextMenuProps {
|
||||
inDetail?: boolean;
|
||||
/** Writes to the contact in the app's composer. */
|
||||
onCompose?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** "Merge with…": pick another card to merge this one with. */
|
||||
onMergeWith?: () => void;
|
||||
/** Move to another address book; undefined where there is none to move to. */
|
||||
onMoveTo?: () => void;
|
||||
onBatchMoveTo?: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
/** Two or more ticked: merge them into one. */
|
||||
onBatchMerge?: () => void;
|
||||
/** The contact is in an address book shared read-only: no Edit, Move or Delete. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -83,16 +86,18 @@ export function ContactContextMenu({
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onCompose,
|
||||
onDuplicate,
|
||||
onMergeWith,
|
||||
onMoveTo,
|
||||
onBatchMoveTo,
|
||||
inDetail = false,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
onBatchMerge,
|
||||
readOnly = false,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tMerge = useTranslations("ux_pim_cimport");
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
const printOff = useStrictestDevices().noPrint;
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
@@ -145,6 +150,9 @@ export function ContactContextMenu({
|
||||
{onBatchMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onBatchMoveTo)} />
|
||||
)}
|
||||
{onBatchMerge && (
|
||||
<ContextMenuItem icon={Merge} label={tMerge("merge_selected")} onClick={handle(onBatchMerge)} />
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("bulk.export")}
|
||||
@@ -192,11 +200,11 @@ export function ContactContextMenu({
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
{onMergeWith && !readOnly && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
icon={Merge}
|
||||
label={tMerge("merge_with")}
|
||||
onClick={handle(onMergeWith)}
|
||||
/>
|
||||
)}
|
||||
{onMoveTo && !readOnly && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAddressLines } from "@/lib/address-format";
|
||||
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
@@ -26,7 +27,8 @@ interface ContactDetailProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
/** "Merge with…" in the ⋮. */
|
||||
onMergeWith?: () => void;
|
||||
/** Move to another address book; undefined where there is none to move to. */
|
||||
onMoveTo?: () => void;
|
||||
/** Compose an email to this contact in the app (no OS mailto handoff). */
|
||||
@@ -40,14 +42,15 @@ interface ContactDetailProps {
|
||||
}
|
||||
|
||||
// Contexts and phone features the contact form offers, shown with the same
|
||||
// labels here. Anything else (custom vCard types) is shown as stored.
|
||||
// labels here. Anything else (custom vCard types) is shown as stored. The
|
||||
// standard's "private" reads "Home" and "voice" reads "Phone" (#pim-19).
|
||||
const CONTEXT_LABEL_KEYS: Record<string, string> = {
|
||||
work: "form.context_work",
|
||||
private: "form.context_private",
|
||||
private: "ux_pim_contact.context_private",
|
||||
};
|
||||
|
||||
const PHONE_FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
voice: "form.phone_voice",
|
||||
voice: "ux_pim_contact.phone_voice",
|
||||
cell: "form.phone_cell",
|
||||
mobile: "form.phone_cell",
|
||||
fax: "form.phone_fax",
|
||||
@@ -76,14 +79,16 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
}
|
||||
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onMergeWith, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tRoot = useTranslations();
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
|
||||
const moreMenu = useContextMenu<ContactCard>();
|
||||
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
|
||||
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
|
||||
const label = (labelKey: string) => (labelKey.startsWith("ux_pim_contact.") ? tRoot(labelKey) : t(labelKey));
|
||||
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? label(CONTEXT_LABEL_KEYS[key]) : key);
|
||||
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? label(PHONE_FEATURE_LABEL_KEYS[key]) : key);
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
|
||||
@@ -148,7 +153,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
|
||||
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{/* A long name takes two lines, then ends in "…" with the whole name on hover. */}
|
||||
<h2 title={name || undefined} className={cn("font-semibold line-clamp-2 break-words", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
@@ -213,7 +219,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
onCompose={onCompose}
|
||||
onDelete={onDelete}
|
||||
onAddToGroup={onAddToGroup}
|
||||
onDuplicate={onDuplicate}
|
||||
onMergeWith={onMergeWith}
|
||||
onMoveTo={onMoveTo}
|
||||
/>
|
||||
)}
|
||||
@@ -264,23 +270,14 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
})}
|
||||
|
||||
{addresses.map((a, i) => {
|
||||
const lines: string[] = [];
|
||||
if (a.full || a.fullAddress) {
|
||||
lines.push((a.full || a.fullAddress) as string);
|
||||
} else if (a.components && a.components.length > 0) {
|
||||
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
|
||||
if (joined) lines.push(joined);
|
||||
} else {
|
||||
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
|
||||
.map(s => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter(Boolean) as string[];
|
||||
lines.push(...parts);
|
||||
}
|
||||
// One line per part, in the order of the address's country:
|
||||
// "Hauptstraße 1" / "10000 Berlin" / "Germany".
|
||||
const lines = formatAddressLines(a, locale);
|
||||
return (
|
||||
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts, contextLabel) || t("detail.address_default_label")}>
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div className="text-sm space-y-0.5" data-testid="contact-address">
|
||||
{lines.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
<div key={idx} data-address-line="">{line}</div>
|
||||
))}
|
||||
{a.timeZone && (
|
||||
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Merge, Users } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import type { AddressBook, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactDuplicatesViewProps {
|
||||
groups: ContactCard[][];
|
||||
addressBooks: AddressBook[];
|
||||
onMerge: (cards: ContactCard[]) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}
|
||||
|
||||
/** "Possible duplicates": cards with the same name, address or phone, each set with its own Merge. */
|
||||
export function ContactDuplicatesView({ groups, addressBooks, onMerge, onOpen }: ContactDuplicatesViewProps) {
|
||||
const t = useTranslations("ux_pim_cimport");
|
||||
const bookNames = (card: ContactCard) => Object.keys(card.addressBookIds ?? {})
|
||||
.filter((id) => card.addressBookIds[id])
|
||||
.map((id) => addressBooks.find((b) => b.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="contact-duplicates">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">{t("duplicates_title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("duplicates_hint")}</p>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<EmptyState compact icon={Users} title={t("duplicates_none")} />
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{groups.map((group) => (
|
||||
<li key={group.map((c) => c.id).join("|")} className="border border-border rounded-md">
|
||||
<ul className="divide-y divide-border">
|
||||
{group.map((card) => {
|
||||
const name = getContactDisplayName(card);
|
||||
const email = getContactPrimaryEmail(card);
|
||||
const phone = Object.values(card.phones ?? {})[0]?.number;
|
||||
const book = bookNames(card);
|
||||
return (
|
||||
<li key={card.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(card.id)}
|
||||
className="w-full text-start px-3 py-2 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{name || email || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{[name ? email : "", phone, book].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex justify-end px-3 py-2 border-t border-border">
|
||||
<Button size="sm" variant="outline" onClick={() => onMerge(group)}>
|
||||
<Merge className="w-4 h-4 me-1.5" />
|
||||
{t("merge_selected")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react";
|
||||
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2, MoreHorizontal } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,38 +11,47 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
|
||||
import type { ContactSaveOptions } from "@/lib/contact-media";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
// Each row of a list field keeps the `key` of the card's entry it was read
|
||||
// from, so Save can send an entry the user didn't touch back unchanged.
|
||||
interface EmailEntry {
|
||||
key?: string;
|
||||
address: string;
|
||||
context: "work" | "private" | "";
|
||||
}
|
||||
|
||||
interface PhoneEntry {
|
||||
key?: string;
|
||||
number: string;
|
||||
context: "work" | "private" | "";
|
||||
feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | "";
|
||||
}
|
||||
|
||||
interface OnlineServiceEntry {
|
||||
key?: string;
|
||||
uri: string;
|
||||
service: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface AnniversaryEntry {
|
||||
key?: string;
|
||||
date: string;
|
||||
kind: "birth" | "death" | "wedding" | "other";
|
||||
}
|
||||
|
||||
interface PersonalInfoEntry {
|
||||
key?: string;
|
||||
value: string;
|
||||
kind: "expertise" | "hobby" | "interest" | "other";
|
||||
level: "high" | "medium" | "low" | "";
|
||||
}
|
||||
|
||||
interface AddressEntry {
|
||||
key?: string;
|
||||
street: string;
|
||||
locality: string;
|
||||
region: string;
|
||||
@@ -125,6 +134,69 @@ function mediaForPhotoEdit(
|
||||
return { media, photoKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* The map to save for a list field (emails, phones, addresses...). A row the
|
||||
* user didn't touch goes back exactly as the card had it, under its key. An
|
||||
* edited row is built on the card's entry (`previous`), so properties the
|
||||
* form doesn't show stay; a new row gets a key no other entry uses.
|
||||
*/
|
||||
function entriesToSave<E extends { key?: string }, V>(
|
||||
rows: E[],
|
||||
initialRows: E[],
|
||||
original: Record<string, V> | undefined,
|
||||
prefix: string,
|
||||
build: (row: E, previous: V | undefined, before: E | undefined) => V,
|
||||
): Record<string, V> {
|
||||
const out: Record<string, V> = {};
|
||||
const taken = new Set(rows.map(r => r.key).filter((k): k is string => !!k && !!original?.[k]));
|
||||
let n = 0;
|
||||
const freshKey = () => {
|
||||
let key = `${prefix}${n++}`;
|
||||
while (taken.has(key)) key = `${prefix}${n++}`;
|
||||
taken.add(key);
|
||||
return key;
|
||||
};
|
||||
for (const row of rows) {
|
||||
const previous = row.key ? original?.[row.key] : undefined;
|
||||
const before = previous !== undefined ? initialRows.find(r => r.key === row.key) : undefined;
|
||||
if (previous !== undefined && before && JSON.stringify(before) === JSON.stringify(row)) {
|
||||
out[row.key!] = previous;
|
||||
} else {
|
||||
out[previous !== undefined ? row.key! : freshKey()] = build(row, previous, before);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A row the card had and the user left as it was. */
|
||||
function isUntouchedRow<E extends { key?: string }>(row: E, initialRows: E[], original: Record<string, unknown> | undefined): boolean {
|
||||
if (!row.key || original?.[row.key] === undefined) return false;
|
||||
const before = initialRows.find(r => r.key === row.key);
|
||||
return !!before && JSON.stringify(before) === JSON.stringify(row);
|
||||
}
|
||||
|
||||
/** `contexts` for a row: the card's own when the select wasn't changed. */
|
||||
function contextsToSave(context: string, previous: Record<string, boolean> | undefined, before: { context: string } | undefined): Record<string, boolean> | undefined {
|
||||
if (before && before.context === context) return previous;
|
||||
return context ? { [context]: true } : undefined;
|
||||
}
|
||||
|
||||
/** A card's first entry of a map with one field changed, the others kept; undefined when none is left. */
|
||||
function firstEntryToSave<V extends object>(original: Record<string, V> | undefined, fallbackKey: string, value: V | null): Record<string, V> | undefined {
|
||||
const entries = Object.entries(original ?? {});
|
||||
const [firstKey, first] = entries[0] ?? [fallbackKey, undefined];
|
||||
const rest = entries.slice(1);
|
||||
const out: Record<string, V> = {};
|
||||
if (value) out[firstKey] = { ...(first ?? {}), ...value } as V;
|
||||
for (const [k, v] of rest) out[k] = v;
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/** Drops keys whose value is undefined, so a built entry has no empty properties. */
|
||||
function compact<T extends object>(obj: T): T {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T;
|
||||
}
|
||||
|
||||
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
@@ -220,6 +292,8 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
|
||||
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const tx = useTranslations("ux_pim_contact");
|
||||
const locale = useLocale();
|
||||
const isEditing = !!contact;
|
||||
// The header's close goes to its start when the window's own close is at its end.
|
||||
const [header, setHeader] = useState<HTMLDivElement | null>(null);
|
||||
@@ -282,25 +356,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
return pd;
|
||||
}
|
||||
|
||||
// Extract flat address fields from RFC 9553 components format
|
||||
function addressToFlat(a: ContactAddress): AddressEntry {
|
||||
if (a.components && a.components.length > 0) {
|
||||
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
|
||||
return {
|
||||
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
|
||||
locality: findComp('locality'),
|
||||
region: findComp('region'),
|
||||
postcode: findComp('postcode'),
|
||||
country: findComp('country'),
|
||||
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||
};
|
||||
}
|
||||
// The form's fields for an address: street name and house number in one
|
||||
// field, in the order of the address's country ("Hauptstraße 1").
|
||||
function addressToFlat(key: string, a: ContactAddress): AddressEntry {
|
||||
return {
|
||||
street: a.street || '',
|
||||
locality: a.locality || '',
|
||||
region: a.region || '',
|
||||
postcode: a.postcode || '',
|
||||
country: a.country || '',
|
||||
key,
|
||||
...addressToParts(a, locale),
|
||||
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||
};
|
||||
}
|
||||
@@ -317,7 +378,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
const [emails, setEmails] = useState<EmailEntry[]>(() => {
|
||||
if (contact?.emails) {
|
||||
return Object.values(contact.emails).map(e => ({
|
||||
return Object.entries(contact.emails).map(([key, e]) => ({
|
||||
key,
|
||||
address: e.address,
|
||||
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
|
||||
}));
|
||||
@@ -327,7 +389,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
|
||||
if (contact?.phones) {
|
||||
return Object.values(contact.phones).map(p => ({
|
||||
return Object.entries(contact.phones).map(([key, p]) => ({
|
||||
key,
|
||||
number: p.number,
|
||||
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
|
||||
// RFC 9553 calls it "mobile"; cards saved by older versions of this form say "cell".
|
||||
@@ -371,14 +434,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
||||
if (contact?.addresses) {
|
||||
return Object.values(contact.addresses).map(a => addressToFlat(a));
|
||||
return Object.entries(contact.addresses).map(([key, a]) => addressToFlat(key, a));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [onlineServices, setOnlineServices] = useState<OnlineServiceEntry[]>(() => {
|
||||
if (contact?.onlineServices) {
|
||||
return Object.values(contact.onlineServices).map(s => ({
|
||||
return Object.entries(contact.onlineServices).map(([key, s]) => ({
|
||||
key,
|
||||
uri: s.uri,
|
||||
service: s.service || "",
|
||||
label: s.label || "",
|
||||
@@ -389,7 +453,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
||||
if (contact?.anniversaries) {
|
||||
return Object.values(contact.anniversaries).map(a => ({
|
||||
return Object.entries(contact.anniversaries).map(([key, a]) => ({
|
||||
key,
|
||||
date: anniversaryDateToString(a.date),
|
||||
kind: a.kind,
|
||||
}));
|
||||
@@ -399,7 +464,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
const [personalInfoEntries, setPersonalInfoEntries] = useState<PersonalInfoEntry[]>(() => {
|
||||
if (contact?.personalInfo) {
|
||||
return Object.values(contact.personalInfo).map(p => ({
|
||||
return Object.entries(contact.personalInfo).map(([key, p]) => ({
|
||||
key,
|
||||
value: p.value,
|
||||
kind: p.kind,
|
||||
level: p.level || "",
|
||||
@@ -474,9 +540,37 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
|
||||
]);
|
||||
const [initialFields] = useState(fields);
|
||||
|
||||
// What each card property is built from. Saving an existing card sends only
|
||||
// the properties whose fields changed, so the rest stay exactly as stored,
|
||||
// structured address parts included (#pim-4).
|
||||
const personNamed = !!(givenName.trim() || surname.trim());
|
||||
const propertyFields: Record<string, unknown> = {
|
||||
name: [prefix, givenName, additionalName, surname, suffix, isOrg, isOrg || !personNamed ? organization : ""],
|
||||
nicknames: nickname,
|
||||
emails,
|
||||
phones,
|
||||
titles: [jobTitle, role],
|
||||
organizations: [organization, department],
|
||||
addresses,
|
||||
onlineServices,
|
||||
anniversaries,
|
||||
personalInfo: personalInfoEntries,
|
||||
keywords: [keywordsStr, categoryDraft],
|
||||
notes: note,
|
||||
speakToAs: [genderSex, genderIdentity],
|
||||
calendarUri: [calendarUri, freeBusyUri],
|
||||
freeBusyUri: [calendarUri, freeBusyUri],
|
||||
schedulingUri,
|
||||
addressBookIds: selectedBookId,
|
||||
};
|
||||
const [initialPropertyFields] = useState(() => JSON.stringify(propertyFields));
|
||||
const [initialRows] = useState(() => ({ emails, phones, addresses, onlineServices, anniversaries, personalInfoEntries, department }));
|
||||
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
const footerRef = useBottomControls();
|
||||
const [hasMoreFields] = useState(() => onlineServices.length > 0 || personalInfoEntries.length > 0
|
||||
|| !!(genderSex || genderIdentity) || !!(calendarUri || schedulingUri || freeBusyUri));
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -544,27 +638,21 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
}
|
||||
}
|
||||
|
||||
const emailsMap: Record<string, { address: string; contexts?: Record<string, boolean> }> = {};
|
||||
validEmails.forEach((entry, i) => {
|
||||
const obj: { address: string; contexts?: Record<string, boolean> } = { address: entry.address.trim() };
|
||||
if (entry.context) {
|
||||
obj.contexts = { [entry.context]: true };
|
||||
}
|
||||
emailsMap[`e${i}`] = obj;
|
||||
});
|
||||
type Email = NonNullable<ContactCard["emails"]>[string];
|
||||
type PhoneValue = NonNullable<ContactCard["phones"]>[string];
|
||||
const emailsMap = entriesToSave<EmailEntry, Email>(validEmails, initialRows.emails, contact?.emails, "e", (row, previous, before) => compact({
|
||||
...previous,
|
||||
address: row.address.trim(),
|
||||
contexts: contextsToSave(row.context, previous?.contexts, before),
|
||||
}));
|
||||
|
||||
const validPhones = phones.filter(p => p.number.trim());
|
||||
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> }> = {};
|
||||
validPhones.forEach((entry, i) => {
|
||||
const obj: { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> } = { number: entry.number.trim() };
|
||||
if (entry.context) {
|
||||
obj.contexts = { [entry.context]: true };
|
||||
}
|
||||
if (entry.feature) {
|
||||
obj.features = { [entry.feature]: true };
|
||||
}
|
||||
phonesMap[`p${i}`] = obj;
|
||||
});
|
||||
const phonesMap = entriesToSave<PhoneEntry, PhoneValue>(validPhones, initialRows.phones, contact?.phones, "p", (row, previous, before) => compact({
|
||||
...previous,
|
||||
number: row.number.trim(),
|
||||
contexts: contextsToSave(row.context, previous?.contexts, before),
|
||||
features: before && before.feature === row.feature ? previous?.features : row.feature ? { [row.feature]: true } : undefined,
|
||||
}));
|
||||
|
||||
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||
const nameComponents = [];
|
||||
@@ -594,41 +682,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||
if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" };
|
||||
|
||||
const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined;
|
||||
const firstOrg = Object.values(contact?.organizations ?? {})[0];
|
||||
const orgUnits = firstOrg && department === initialRows.department
|
||||
? firstOrg.units
|
||||
: department.trim() ? [{ name: department.trim() }] : undefined;
|
||||
|
||||
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
||||
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
||||
const components: Array<{ kind: string; value: string }> = [];
|
||||
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
|
||||
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
|
||||
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
|
||||
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
|
||||
if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
|
||||
const obj: Record<string, unknown> = { components, isOrdered: true, defaultSeparator: ", " };
|
||||
if (a.context) obj.contexts = { [a.context]: true };
|
||||
// @ts-expect-error - dynamic build
|
||||
addressesMap[`a${i}`] = obj;
|
||||
});
|
||||
// An address the user didn't touch goes back as the card had it. An edited
|
||||
// one is rebuilt in its country's order ("Hauptstraße" + "1", then
|
||||
// postcode and city), keeping the parts the form doesn't show.
|
||||
const addressesMap = entriesToSave<AddressEntry, ContactAddress>(
|
||||
// An address the form can't show (only `full` text, a PO box) has empty
|
||||
// rows here; untouched, it stays.
|
||||
addresses.filter(a => a.street.trim() || a.locality.trim() || a.region.trim() || a.postcode.trim() || a.country.trim()
|
||||
|| isUntouchedRow(a, initialRows.addresses, contact?.addresses)),
|
||||
initialRows.addresses, contact?.addresses, "a",
|
||||
(row, previous, before) => {
|
||||
const { components: previousComponents, full: _full, fullAddress: _fullAddress, street: _s, locality: _l, region: _r, postcode: _p, country: _c, ...kept } = previous ?? {};
|
||||
void _full; void _fullAddress; void _s; void _l; void _r; void _p; void _c;
|
||||
const countryCode = before && before.country === row.country
|
||||
? previous?.countryCode
|
||||
: countryCodeFromName(row.country) ?? undefined;
|
||||
return compact({
|
||||
...kept,
|
||||
components: partsToComponents(row, previousComponents ?? [], locale, countryCode),
|
||||
isOrdered: true,
|
||||
defaultSeparator: previous?.defaultSeparator ?? ", ",
|
||||
countryCode,
|
||||
contexts: contextsToSave(row.context, previous?.contexts, before),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const onlineServicesMap: Record<string, ContactOnlineService> = {};
|
||||
onlineServices.filter(s => s.uri.trim()).forEach((s, i) => {
|
||||
const obj: ContactOnlineService = { uri: s.uri.trim() };
|
||||
if (s.service.trim()) obj.service = s.service.trim();
|
||||
if (s.label.trim()) obj.label = s.label.trim();
|
||||
onlineServicesMap[`os${i}`] = obj;
|
||||
});
|
||||
const onlineServicesMap = entriesToSave<OnlineServiceEntry, ContactOnlineService>(
|
||||
onlineServices.filter(s => s.uri.trim()), initialRows.onlineServices, contact?.onlineServices, "os",
|
||||
(row, previous) => compact({
|
||||
...previous,
|
||||
uri: row.uri.trim(),
|
||||
service: row.service.trim() || undefined,
|
||||
label: row.label.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
||||
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
||||
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
|
||||
});
|
||||
const anniversariesMap = entriesToSave<AnniversaryEntry, ContactAnniversary>(
|
||||
anniversaries.filter(a => a.date.trim()), initialRows.anniversaries, contact?.anniversaries, "an",
|
||||
(row, previous) => ({ ...previous, date: stringToPartialDate(row.date.trim()), kind: row.kind }),
|
||||
);
|
||||
|
||||
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
||||
personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => {
|
||||
const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind };
|
||||
if (p.level) obj.level = p.level as "high" | "medium" | "low";
|
||||
personalInfoMap[`pi${i}`] = obj;
|
||||
});
|
||||
const personalInfoMap = entriesToSave<PersonalInfoEntry, ContactPersonalInfo>(
|
||||
personalInfoEntries.filter(p => p.value.trim()), initialRows.personalInfoEntries, contact?.personalInfo, "pi",
|
||||
(row, previous) => compact({
|
||||
...previous,
|
||||
value: row.value.trim(),
|
||||
kind: row.kind,
|
||||
level: (row.level || undefined) as ContactPersonalInfo["level"],
|
||||
}),
|
||||
);
|
||||
|
||||
const keywordsMap: Record<string, boolean> = {};
|
||||
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
|
||||
@@ -648,21 +756,18 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const data: Partial<ContactCard> = {
|
||||
name: nameValue,
|
||||
...(kindValue ? { kind: kindValue } : {}),
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
// The form edits the first nickname, note and organization; others stay.
|
||||
nicknames: firstEntryToSave(contact?.nicknames, "n0", nickname.trim() ? { name: nickname.trim() } : null),
|
||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
||||
organizations: orgName
|
||||
? { o0: { name: orgName, units: orgUnits } }
|
||||
: undefined,
|
||||
organizations: firstEntryToSave(contact?.organizations, "o0", orgName ? { name: orgName, units: orgUnits } : null),
|
||||
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
||||
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
|
||||
anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined,
|
||||
personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined,
|
||||
keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined,
|
||||
notes: note.trim()
|
||||
? { n0: { note: note.trim() } }
|
||||
: undefined,
|
||||
notes: firstEntryToSave(contact?.notes, "n0", note.trim() ? { note: note.trim() } : null),
|
||||
speakToAs: (genderSex.trim() || genderIdentity.trim())
|
||||
? {
|
||||
grammaticalGender: genderSex.trim() || undefined,
|
||||
@@ -675,6 +780,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
|
||||
};
|
||||
|
||||
// An existing card gets only what changed: every property left out stays
|
||||
// on the server exactly as it is.
|
||||
if (contact) {
|
||||
const before = JSON.parse(initialPropertyFields) as Record<string, unknown>;
|
||||
for (const [property, value] of Object.entries(propertyFields)) {
|
||||
if (JSON.stringify(value) === JSON.stringify(before[property])) delete data[property as keyof ContactCard];
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// `media` goes out only when the photo changed. An update without it
|
||||
@@ -916,7 +1030,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
<option value="private">{tx("context_private")}</option>
|
||||
</Select>
|
||||
{emails.length > 1 && (
|
||||
<IconButton label={t("remove_entry")} onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="shrink-0">
|
||||
@@ -966,7 +1080,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
aria-label={t("phone_type")}
|
||||
>
|
||||
<option value="">{t("phone_type")}</option>
|
||||
<option value="voice">{t("phone_voice")}</option>
|
||||
<option value="voice">{tx("phone_voice")}</option>
|
||||
<option value="mobile">{t("phone_cell")}</option>
|
||||
<option value="fax">{t("phone_fax")}</option>
|
||||
<option value="pager">{t("phone_pager")}</option>
|
||||
@@ -984,7 +1098,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
<option value="private">{tx("context_private")}</option>
|
||||
</Select>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
@@ -1026,31 +1140,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
{/* Addresses */}
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
|
||||
<X />
|
||||
</IconButton>
|
||||
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} aria-label={t("street")} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} aria-label={t("city")} />
|
||||
<Input value={addr.region} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} aria-label={t("region")} />
|
||||
{addresses.map((addr, i) => {
|
||||
const set = (name: keyof Omit<AddressEntry, "key">) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const n = [...addresses];
|
||||
n[i] = { ...n[i], [name]: e.target.value };
|
||||
setAddresses(n);
|
||||
};
|
||||
const original = addr.key ? contact?.addresses?.[addr.key] : undefined;
|
||||
const initialCountry = addr.key ? initialRows.addresses.find(a => a.key === addr.key)?.country : undefined;
|
||||
const style: AddressStyle = addressStyle(addr.country, initialCountry === addr.country ? original?.countryCode : null, locale);
|
||||
const field = (name: "locality" | "region" | "postcode", label: string, className?: string) => (
|
||||
<Input key={name} value={addr[name]} onChange={set(name)} placeholder={label} aria-label={label} className={className} />
|
||||
);
|
||||
const country = <Input key="country" value={addr.country} onChange={set("country")} placeholder={t("country")} aria-label={t("country")} />;
|
||||
const context = (
|
||||
<Select key="context" value={addr.context} onChange={set("context")} aria-label={t("context_label")}>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{tx("context_private")}</option>
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<div key={addr.key ?? `new-${i}`} data-address-layout={style.town} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
|
||||
<X />
|
||||
</IconButton>
|
||||
{/* Street and house number in one field, in the order the country writes them. */}
|
||||
<Input value={addr.street} onChange={set("street")} placeholder={t("street")} aria-label={t("street")} className="pe-10" />
|
||||
{style.town === "postcode-city" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{field("postcode", t("postcode"))}
|
||||
{field("locality", t("city"), "col-span-2")}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{field("region", t("region"))}
|
||||
{country}
|
||||
{context}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{field("locality", t("city"))}
|
||||
{field("region", t("region"))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{field("postcode", t("postcode"))}
|
||||
{country}
|
||||
{context}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input value={addr.postcode} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} aria-label={t("postcode")} />
|
||||
<Input value={addr.country} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} aria-label={t("country")} />
|
||||
<Select
|
||||
value={addr.context}
|
||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||
aria-label={t("context_label")}
|
||||
>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_address")}
|
||||
@@ -1058,37 +1202,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Online Services */}
|
||||
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={svc.uri}
|
||||
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
|
||||
placeholder={t("url_placeholder")}
|
||||
aria-label={t("online_service_url")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={svc.service}
|
||||
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
|
||||
placeholder={t("service_placeholder")}
|
||||
aria-label={t("service_placeholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_online_service")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Anniversaries */}
|
||||
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
@@ -1123,50 +1236,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Personal Info */}
|
||||
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
|
||||
<div className="space-y-2">
|
||||
{personalInfoEntries.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={pi.value}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
|
||||
placeholder={t("personal_info_placeholder")}
|
||||
aria-label={t("personal_info")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={pi.kind}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
|
||||
aria-label={t("personal_info_kind")}
|
||||
>
|
||||
<option value="expertise">{t("personal_expertise")}</option>
|
||||
<option value="hobby">{t("personal_hobby")}</option>
|
||||
<option value="interest">{t("personal_interest")}</option>
|
||||
<option value="other">{t("personal_other")}</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={pi.level}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
|
||||
aria-label={t("level")}
|
||||
>
|
||||
<option value="">{t("level")}</option>
|
||||
<option value="high">{t("level_high")}</option>
|
||||
<option value="medium">{t("level_medium")}</option>
|
||||
<option value="low">{t("level_low")}</option>
|
||||
</Select>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_personal_info")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
|
||||
<CategoryComboBox
|
||||
@@ -1181,45 +1250,6 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={fieldId("gender_sex")} className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
<Select id={fieldId("gender_sex")} value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||
<option value="">-</option>
|
||||
<option value="masculine">{t("gender_male")}</option>
|
||||
<option value="feminine">{t("gender_female")}</option>
|
||||
<option value="other">{t("gender_other")}</option>
|
||||
<option value="none">{t("gender_none")}</option>
|
||||
<option value="unknown">{t("gender_unknown")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("gender_identity")} className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
|
||||
<Input id={fieldId("gender_identity")} value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Calendar */}
|
||||
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor={fieldId("calendar_uri")} className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||
<Input id={fieldId("calendar_uri")} value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("scheduling_uri")} className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
|
||||
<Input id={fieldId("scheduling_uri")} value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("freebusy_uri")} className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
|
||||
<Input id={fieldId("freebusy_uri")} value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes */}
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
|
||||
<textarea
|
||||
@@ -1231,6 +1261,125 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Fields most cards don't use, collapsed unless one has a value. */}
|
||||
<FormSection icon={MoreHorizontal} title={tx("more_fields")} collapsible defaultOpen={hasMoreFields}>
|
||||
<div className="divide-y divide-border/60 -my-5">
|
||||
{/* Online Services */}
|
||||
<FormSection icon={Globe} title={t("online_services")}>
|
||||
<div className="space-y-2">
|
||||
{onlineServices.map((svc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={svc.uri}
|
||||
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
|
||||
placeholder={t("url_placeholder")}
|
||||
aria-label={t("online_service_url")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={svc.service}
|
||||
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
|
||||
placeholder={t("service_placeholder")}
|
||||
aria-label={t("service_placeholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_online_service")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Personal Info */}
|
||||
<FormSection icon={Heart} title={t("personal_info")}>
|
||||
<div className="space-y-2">
|
||||
{personalInfoEntries.map((pi, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={pi.value}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
|
||||
placeholder={t("personal_info_placeholder")}
|
||||
aria-label={t("personal_info")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={pi.kind}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
|
||||
aria-label={t("personal_info_kind")}
|
||||
>
|
||||
<option value="expertise">{t("personal_expertise")}</option>
|
||||
<option value="hobby">{t("personal_hobby")}</option>
|
||||
<option value="interest">{t("personal_interest")}</option>
|
||||
<option value="other">{t("personal_other")}</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={pi.level}
|
||||
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
|
||||
aria-label={t("level")}
|
||||
>
|
||||
<option value="">{t("level")}</option>
|
||||
<option value="high">{t("level_high")}</option>
|
||||
<option value="medium">{t("level_medium")}</option>
|
||||
<option value="low">{t("level_low")}</option>
|
||||
</Select>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||
<Plus className="w-3 h-3 me-1" />
|
||||
{t("add_personal_info")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
<FormSection icon={UserCircle} title={t("gender")}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={fieldId("gender_sex")} className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
<Select id={fieldId("gender_sex")} value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||
<option value="">-</option>
|
||||
<option value="masculine">{t("gender_male")}</option>
|
||||
<option value="feminine">{t("gender_female")}</option>
|
||||
<option value="other">{t("gender_other")}</option>
|
||||
<option value="none">{t("gender_none")}</option>
|
||||
<option value="unknown">{t("gender_unknown")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("gender_identity")} className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
|
||||
<Input id={fieldId("gender_identity")} value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Calendar */}
|
||||
<FormSection icon={Calendar} title={t("calendar")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor={fieldId("calendar_uri")} className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
|
||||
<Input id={fieldId("calendar_uri")} value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("scheduling_uri")} className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
|
||||
<Input id={fieldId("scheduling_uri")} value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={fieldId("freebusy_uri")} className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
|
||||
<Input id={fieldId("freebusy_uri")} value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,12 +135,9 @@ export function ContactGroupDetail({
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{/* Always shown: this list is where members are managed (#pim-18). */}
|
||||
<IconButton
|
||||
label={t("groups.remove_member", { name: mName })}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
|
||||
)}
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
>
|
||||
<UserMinus className="text-muted-foreground" />
|
||||
|
||||
@@ -7,16 +7,41 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import {
|
||||
CSV_TARGET_GROUPS, csvToContacts, decodeCsvBytes, isCsvFile, matchColumns, readCsv,
|
||||
type CsvColumn, type CsvTable, type CsvTarget,
|
||||
} from "@/lib/contacts/csv-import";
|
||||
import type { AddressBook, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { isOfflineNow } from "@/lib/offline/state";
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onImport: (contacts: ContactCard[], addressBookId?: string) => Promise<number>;
|
||||
onClose: () => void;
|
||||
/** A file opened from the OS: read at once, as if picked here. */
|
||||
initialFile?: File | null;
|
||||
/** Books the cards can go into; "Import into" shows when there are two or more. */
|
||||
addressBooks?: AddressBook[];
|
||||
/** The book open in Contacts: "Import into" starts there. */
|
||||
defaultAddressBookId?: string;
|
||||
}
|
||||
|
||||
/** The file's bytes; jsdom's File has no arrayBuffer(). */
|
||||
function readBytes(file: File): Promise<Uint8Array> {
|
||||
if (typeof file.arrayBuffer === "function") return file.arrayBuffer().then((b) => new Uint8Array(b));
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** "adr.home.street" → "t_street"; "phone.fax.home" → "t_phone_fax_home". */
|
||||
function targetKey(target: CsvTarget): string {
|
||||
if (target.startsWith("adr.")) return `t_${target.split(".")[2]}`;
|
||||
return `t_${target.replace(/\./g, "_")}`;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
@@ -24,8 +49,11 @@ export function ContactImportDialog({
|
||||
onImport,
|
||||
onClose,
|
||||
initialFile,
|
||||
addressBooks = [],
|
||||
defaultAddressBookId,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tCsv = useTranslations("ux_pim_cimport");
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
@@ -34,6 +62,26 @@ export function ContactImportDialog({
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A CSV file waits for its columns to be matched before the preview.
|
||||
const [csv, setCsv] = useState<{ table: CsvTable; fileName: string } | null>(null);
|
||||
const [columns, setColumns] = useState<CsvColumn[]>([]);
|
||||
const [matching, setMatching] = useState(false);
|
||||
const pickBook = () => {
|
||||
if (defaultAddressBookId && addressBooks.some((b) => b.id === defaultAddressBookId)) return defaultAddressBookId;
|
||||
return (addressBooks.find((b) => b.isDefault) ?? addressBooks[0])?.id ?? "";
|
||||
};
|
||||
const [bookId, setBookId] = useState(pickBook);
|
||||
|
||||
const showCards = useCallback((contacts: ContactCard[]) => {
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
}, [existingContacts]);
|
||||
|
||||
const readFile = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
@@ -44,6 +92,23 @@ export function ContactImportDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCsvFile(file)) {
|
||||
try {
|
||||
const table = readCsv(decodeCsvBytes(await readBytes(file)));
|
||||
if (table.headers.length === 0 || table.rows.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
setCsv({ table, fileName: file.name });
|
||||
setColumns(matchColumns(table));
|
||||
setMatching(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to read CSV:', error);
|
||||
setError(tCsv("parse_error"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
@@ -53,20 +118,12 @@ export function ContactImportDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
showCards(contacts);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
}, [t, tCsv, showCards]);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -98,13 +155,44 @@ export function ContactImportDialog({
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const setTarget = (index: number, target: CsvTarget) => {
|
||||
setColumns((cols) => cols.map((c) => (c.index === index ? { ...c, target } : c)));
|
||||
};
|
||||
|
||||
const anyColumnMatched = columns.some((c) => c.target !== "skip" && c.target !== "type");
|
||||
|
||||
const continueFromColumns = () => {
|
||||
if (!csv) return;
|
||||
const contacts = csvToContacts(csv.table, columns);
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setMatching(false);
|
||||
showCards(contacts);
|
||||
};
|
||||
|
||||
const backToColumns = () => {
|
||||
setParsed([]);
|
||||
setError(null);
|
||||
setMatching(true);
|
||||
};
|
||||
|
||||
const backToFile = () => {
|
||||
setCsv(null);
|
||||
setColumns([]);
|
||||
setMatching(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
const count = await onImport(toImport, addressBooks.length > 0 && bookId ? bookId : undefined);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
@@ -121,27 +209,51 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
);
|
||||
|
||||
const groupLabel = (id: string) => tCsv(`group_${id.replace(".", "_")}`);
|
||||
const optionLabel = (groupId: string, target: CsvTarget) => target.startsWith("adr.")
|
||||
? tCsv("address_field", { group: groupLabel(groupId), part: tCsv(targetKey(target)) })
|
||||
: tCsv(targetKey(target));
|
||||
|
||||
const footer = result !== null ? undefined : matching ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={backToFile} className="me-auto">
|
||||
{tCsv("back")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={continueFromColumns} disabled={!anyColumnMatched}>
|
||||
{tCsv("continue")}
|
||||
</Button>
|
||||
</>
|
||||
) : parsed.length > 0 ? (
|
||||
<>
|
||||
{csv && (
|
||||
<Button variant="outline" onClick={backToColumns} disabled={isImporting}>
|
||||
{tCsv("back")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground me-auto self-center">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t("import.title")}
|
||||
size="lg"
|
||||
// Picked cards are not thrown away by a stray click outside.
|
||||
dirty={parsed.length > 0 && result === null}
|
||||
footer={parsed.length > 0 && result === null ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground me-auto self-center">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
// Picked cards and matched columns are not thrown away by a stray click outside.
|
||||
dirty={(parsed.length > 0 || matching) && result === null}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-4 pt-4">
|
||||
{result !== null ? (
|
||||
@@ -154,12 +266,59 @@ export function ContactImportDialog({
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : matching && csv ? (
|
||||
<>
|
||||
{errorBanner}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{tCsv("columns_title")}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{tCsv("columns_hint", { file: csv.fileName })}</p>
|
||||
</div>
|
||||
<div className="border rounded-md max-h-96 overflow-y-auto" data-testid="csv-columns">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-background">
|
||||
<tr className="border-b text-start text-xs text-muted-foreground">
|
||||
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("column")}</th>
|
||||
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("sample")}</th>
|
||||
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("field")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{columns.map((col) => (
|
||||
<tr key={col.index} className={cn(col.target === "skip" && "text-muted-foreground")}>
|
||||
<th scope="row" className="px-3 py-1.5 font-normal text-start max-w-[12rem] truncate" title={col.header}>
|
||||
{col.header || "-"}
|
||||
</th>
|
||||
<td className="px-3 py-1.5 max-w-[12rem] truncate" title={col.sample}>{col.sample}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<select
|
||||
aria-label={col.header}
|
||||
value={col.target}
|
||||
onChange={(e) => setTarget(col.index, e.target.value as CsvTarget)}
|
||||
className="w-full text-sm bg-transparent border border-input rounded-md px-2 py-1 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors"
|
||||
>
|
||||
<option value="skip">{tCsv("skip")}</option>
|
||||
{CSV_TARGET_GROUPS.map((group) => (
|
||||
<optgroup key={group.id} label={groupLabel(group.id)}>
|
||||
{group.targets.map((target) => (
|
||||
<option key={target} value={target}>{optionLabel(group.id, target)}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!anyColumnMatched && <p className="text-sm text-muted-foreground">{tCsv("no_columns")}</p>}
|
||||
</>
|
||||
) : parsed.length === 0 ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
accept=".vcf,.vcard,.csv,text/csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -175,8 +334,8 @@ export function ContactImportDialog({
|
||||
)}
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
<p className="text-sm font-medium">{tCsv("file_hint")}</p>
|
||||
<p className="text-xs">{tCsv("file_types")}</p>
|
||||
</button>
|
||||
|
||||
{errorBanner}
|
||||
@@ -185,6 +344,23 @@ export function ContactImportDialog({
|
||||
<>
|
||||
{errorBanner}
|
||||
|
||||
{addressBooks.length > 1 && (
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<span className="font-medium shrink-0">{tCsv("import_into")}</span>
|
||||
<select
|
||||
value={bookId}
|
||||
onChange={(e) => setBookId(e.target.value)}
|
||||
className="flex-1 min-w-0 text-sm bg-transparent border border-input rounded-md px-2.5 py-1.5 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors"
|
||||
>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.found", { count: parsed.length })}
|
||||
|
||||
@@ -25,9 +25,11 @@ interface ContactListItemProps {
|
||||
onFocus?: () => void;
|
||||
/** Long-press on touch: the row's menu, as right-click does (R7). */
|
||||
onLongPress?: (row: HTMLElement) => void;
|
||||
/** The row's menu is open: it keeps the hover colour, so it's clear whose menu it is. */
|
||||
isMenuTarget?: boolean;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress }: ContactListItemProps) {
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress, isMenuTarget = false }: ContactListItemProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const longPress = useLongPress(() => {
|
||||
if (rowRef.current) onLongPress?.(rowRef.current);
|
||||
@@ -72,6 +74,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
aria-selected={isSelected || isChecked}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
data-contact-id={contact.id}
|
||||
data-menu-open={isMenuTarget || undefined}
|
||||
onFocus={onFocus}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
@@ -81,7 +84,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-inset",
|
||||
isSelected
|
||||
? "bg-selection shadow-sm"
|
||||
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||
: isMenuTarget ? "bg-muted shadow-sm" : "bg-background hover:bg-muted hover:shadow-sm",
|
||||
isChecked && !isSelected && "ring-2 ring-primary/20 bg-selection/60",
|
||||
)}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput, Upload, Printer } from "@/components/icons";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput, Upload, Printer, Merge } from "@/components/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -21,6 +21,7 @@ import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/st
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { isAppShortcutBlocked } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { ContactRowsSkeleton } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
|
||||
type TriState = boolean | null;
|
||||
@@ -126,7 +127,12 @@ interface ContactListProps {
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Opens the app's composer to the contact. */
|
||||
onComposeContact?: (contact: ContactCard) => void;
|
||||
onDuplicateContact?: (contact: ContactCard) => void;
|
||||
/** "Merge with…" for one contact. */
|
||||
onMergeContact?: (contact: ContactCard) => void;
|
||||
/** Two or more ticked: merge them. */
|
||||
onBulkMerge?: () => void;
|
||||
/** A line over the list, e.g. "Possible duplicates (2)". */
|
||||
banner?: React.ReactNode;
|
||||
/** Move contacts to another address book; undefined where there is none. */
|
||||
onMoveContacts?: (ids: string[]) => void;
|
||||
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||
@@ -165,7 +171,9 @@ export function ContactList({
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onComposeContact,
|
||||
onDuplicateContact,
|
||||
onMergeContact,
|
||||
onBulkMerge,
|
||||
banner,
|
||||
onMoveContacts,
|
||||
onMenuClick,
|
||||
phoneMenuItems,
|
||||
@@ -176,6 +184,8 @@ export function ContactList({
|
||||
printTitle,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tx = useTranslations("ux_pim_contact");
|
||||
const tMerge = useTranslations("ux_pim_cimport");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -389,6 +399,8 @@ export function ContactList({
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
// Edit > Search contacts in the desktop app's menus.
|
||||
useDesktopCommands({ "edit.search": () => searchRef.current?.focus() }, { "edit.search": true });
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
@@ -621,6 +633,12 @@ export function ContactList({
|
||||
<span className="hidden @md:inline">{t("address_books.move_to")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{onBulkMerge && selectedContactIds.size > 1 && (
|
||||
<Button variant="ghost" size="xs" onClick={onBulkMerge} aria-label={tMerge("merge_selected")} title={tMerge("merge_selected")} className="shrink-0">
|
||||
<Merge className="w-3.5 h-3.5 @md:me-1" />
|
||||
<span className="hidden @md:inline">{tMerge("merge_selected")}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} aria-label={t("bulk.export")} title={t("bulk.export")} className="shrink-0">
|
||||
{exporting
|
||||
? <Loader2 className="w-3.5 h-3.5 @md:me-1 animate-spin" />
|
||||
@@ -645,6 +663,8 @@ export function ContactList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSelection && banner}
|
||||
|
||||
{/* Contact list */}
|
||||
<div
|
||||
ref={listRef}
|
||||
@@ -697,7 +717,7 @@ export function ContactList({
|
||||
{onImport && (
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<Upload className="w-4 h-4 me-1.5" />
|
||||
{t("import.title")}
|
||||
{tx("import_menu")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -714,6 +734,7 @@ export function ContactList({
|
||||
isChecked={selectedContactIds.has(contact.id)}
|
||||
hasSelection={hasSelection}
|
||||
isFocused={contact.id === cursorId}
|
||||
isMenuTarget={contextMenu.isOpen && contextMenu.data?.id === contact.id}
|
||||
onFocus={() => setFocusedId(contact.id)}
|
||||
density={density}
|
||||
selectedContactIds={selectedContactIds}
|
||||
@@ -785,12 +806,13 @@ export function ContactList({
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
|
||||
onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined}
|
||||
onMergeWith={onMergeContact ? () => onMergeContact(contextMenu.data!) : undefined}
|
||||
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
|
||||
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
onBatchMerge={onBulkMerge}
|
||||
readOnly={isContactReadOnly?.(contextMenu.data) ?? false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactAddress, ContactCard } from "@/lib/jmap/types";
|
||||
import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
/** One line for an address, as its parts and separators say. */
|
||||
export function addressLine(address: ContactAddress): string {
|
||||
const components = address.components ?? [];
|
||||
if (components.length > 0) {
|
||||
let out = "";
|
||||
let lastWasValue = false;
|
||||
for (const c of components) {
|
||||
if (c.kind === "separator") {
|
||||
out += c.value;
|
||||
lastWasValue = false;
|
||||
continue;
|
||||
}
|
||||
if (!c.value) continue;
|
||||
if (lastWasValue) out += address.defaultSeparator ?? ", ";
|
||||
out += c.value.replace(/\s*\n\s*/g, ", ");
|
||||
lastWasValue = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country]
|
||||
.filter(Boolean).join(", ");
|
||||
return legacy || address.full || address.fullAddress || "";
|
||||
}
|
||||
|
||||
interface ContactMergeDialogProps {
|
||||
/** Two or more cards: the dialog opens on what the merge keeps. */
|
||||
cards?: ContactCard[];
|
||||
/** "Merge with…": the other card is picked first. */
|
||||
pickFor?: ContactCard;
|
||||
/** What can be picked: cards of the same account that can be changed. */
|
||||
candidates?: ContactCard[];
|
||||
/** Likely duplicates of `pickFor`, listed first. */
|
||||
suggested?: ContactCard[];
|
||||
/** All groups, to show the ones the kept card will be in. */
|
||||
groups: ContactCard[];
|
||||
onMerge: (plan: MergePlan) => Promise<boolean>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) {
|
||||
const name = getContactDisplayName(card);
|
||||
const email = getContactPrimaryEmail(card);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className="w-full flex flex-col items-start px-3 py-2 text-start hover:bg-muted transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium truncate max-w-full">{name || email || "-"}</span>
|
||||
{name && email && <span className="text-xs text-muted-foreground truncate max-w-full">{email}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Merge with…" and "Merge…": pick the other card if needed, then show what stays before merging. */
|
||||
export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) {
|
||||
const t = useTranslations("ux_pim_cimport");
|
||||
const tc = useTranslations("contacts");
|
||||
const [cards, setCards] = useState<ContactCard[] | null>(initialCards && initialCards.length > 1 ? initialCards : null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const suggestedIds = new Set(suggested.map((c) => c.id));
|
||||
return candidates
|
||||
.filter((c) => !suggestedIds.has(c.id))
|
||||
.filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q))
|
||||
.slice(0, 200);
|
||||
}, [candidates, suggested, query]);
|
||||
|
||||
if (!plan) {
|
||||
const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : "";
|
||||
const pick = (card: ContactCard) => { if (pickFor) setCards([pickFor, card]); };
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t("pick_title", { name })}
|
||||
footer={<Button variant="outline" onClick={onClose}>{tc("form.cancel")}</Button>}
|
||||
>
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pick_search")}
|
||||
aria-label={t("pick_search")}
|
||||
className="ps-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-md max-h-80 overflow-y-auto divide-y divide-border" data-testid="merge-pick-list">
|
||||
{suggested.length > 0 && !query.trim() && (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground bg-muted/40">{t("pick_suggested")}</div>
|
||||
{suggested.map((card) => <PickRow key={card.id} card={card} onPick={() => pick(card)} />)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div>
|
||||
{suggested.length > 0 && !query.trim() && (
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground bg-muted/40">{t("pick_all")}</div>
|
||||
)}
|
||||
{filtered.map((card) => <PickRow key={card.id} card={card} onPick={() => pick(card)} />)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && (suggested.length === 0 || query.trim()) && (
|
||||
<p className="px-3 py-6 text-sm text-center text-muted-foreground">{t("pick_none")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const r = plan.result;
|
||||
const keys = new Set([plan.keep, ...plan.others].flatMap(memberKeys));
|
||||
const groupNames = groups
|
||||
.filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k)))
|
||||
.map((g) => getContactDisplayName(g))
|
||||
.filter(Boolean);
|
||||
const rows: Array<{ label: string; values: string[] }> = [
|
||||
{ label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) },
|
||||
{ label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) },
|
||||
{ label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) },
|
||||
{ label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) },
|
||||
{
|
||||
label: t("group_org"),
|
||||
values: [
|
||||
...Object.values(r.organizations ?? {}).map((o) => [o.name, ...(o.units ?? []).map((u) => u.name)].filter(Boolean).join(", ")),
|
||||
...Object.values(r.titles ?? {}).map((ti) => ti.name),
|
||||
].filter(Boolean),
|
||||
},
|
||||
{ label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) },
|
||||
{ label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) },
|
||||
{ label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) },
|
||||
{ label: t("groups"), values: groupNames },
|
||||
].filter((row) => row.values.length > 0);
|
||||
|
||||
const merge = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (await onMerge(plan)) onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const otherNames = plan.others.map((c) => getContactDisplayName(c) || getContactPrimaryEmail(c) || "-").join(", ");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={busy ? () => {} : onClose}
|
||||
title={t("preview_title", { count: plan.others.length + 1 })}
|
||||
description={t("preview_hint")}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
{pickFor && !initialCards && (
|
||||
<Button variant="outline" onClick={() => setCards(null)} disabled={busy} className="me-auto">{t("back")}</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{tc("form.cancel")}</Button>
|
||||
<Button onClick={() => void merge()} disabled={busy}>{busy ? t("merging") : t("merge")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 pt-4" data-testid="merge-preview">
|
||||
<dl className="border rounded-md divide-y divide-border max-h-96 overflow-y-auto">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="grid grid-cols-[8rem_1fr] gap-3 px-3 py-2 text-sm">
|
||||
<dt className="text-muted-foreground">{row.label}</dt>
|
||||
<dd className="min-w-0">
|
||||
{row.values.map((value, i) => (
|
||||
<div key={i} className={cn("break-words", row.label === t("t_notes") && "whitespace-pre-line")}>{value}</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="text-sm text-muted-foreground">{t("preview_deleted", { names: otherNames })}</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "rea
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus, Printer } from "@/components/icons";
|
||||
import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus, Printer, Merge } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
@@ -18,6 +18,9 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { ContactMergeDialog } from "@/components/contacts/contact-merge-dialog";
|
||||
import { ContactDuplicatesView } from "@/components/contacts/contact-duplicates-view";
|
||||
import { findDuplicateGroups, ownerKey, type MergePlan } from "@/lib/contacts/merge";
|
||||
import { AddressBookPickerDialog } from "@/components/contacts/address-book-picker-dialog";
|
||||
import { DirectoryBrowser, directoryKey, filterDirectory } from "@/components/contacts/directory-browser";
|
||||
import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail";
|
||||
@@ -86,7 +89,8 @@ type View =
|
||||
| "group-create"
|
||||
| "group-edit"
|
||||
| "bulk-add-to-group"
|
||||
| "directory-detail";
|
||||
| "directory-detail"
|
||||
| "duplicates";
|
||||
|
||||
export interface ContactsAppProps {
|
||||
/** Path segments after `/contacts` (`['<contactId>', 'edit']`). */
|
||||
@@ -101,8 +105,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
|
||||
const printOff = useStrictestDevices().noPrint;
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tUxContact = useTranslations("ux_pim_contact");
|
||||
const tCommon = useTranslations("common");
|
||||
const tShell = useTranslations("shell");
|
||||
const tMerge = useTranslations("ux_pim_cimport");
|
||||
useDocumentTitle(tSidebar("contacts"));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
@@ -147,11 +153,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
setDefaultAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
mergeContacts,
|
||||
undoMergeContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
// "Merge…" (two or more cards) and "Merge with…" (one card, the other picked in the dialog).
|
||||
const [mergeCards, setMergeCards] = useState<ContactCard[] | null>(null);
|
||||
const [mergePickFor, setMergePickFor] = useState<ContactCard | null>(null);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
@@ -579,12 +590,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[], addressBookId?: string) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
importedContacts
|
||||
importedContacts,
|
||||
addressBookId,
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
// "Import into" starts at the address book on screen.
|
||||
const openBookId = typeof activeCategory === "object" && "addressBookId" in activeCategory ? activeCategory.addressBookId : undefined;
|
||||
|
||||
const handleSelectContact = (id: string) => {
|
||||
setSelectedContact(id);
|
||||
@@ -685,37 +699,69 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
setView("detail");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _uid; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client && source.media) {
|
||||
// A blobId names the bytes embedded in the source card; the copy gets
|
||||
// the bytes themselves.
|
||||
data.media = await inlineContactMedia(source.media, {
|
||||
client: getContactPhotoClient(source.localAccountId),
|
||||
accountId: source.accountId,
|
||||
localAccountId: source.localAccountId,
|
||||
}, "keep");
|
||||
}
|
||||
let created: boolean;
|
||||
try {
|
||||
created = supportsSync && client
|
||||
? await createContact(client, data)
|
||||
: await addLocalContact({
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate contact:", error);
|
||||
toast.error(t("toast.error_create"));
|
||||
// Likely duplicates among the cards that can be changed, for "Possible duplicates (n)".
|
||||
const duplicateGroups = useMemo(
|
||||
() => findDuplicateGroups(individuals.filter((c) => !isContactReadOnly(c))),
|
||||
[individuals, isContactReadOnly],
|
||||
);
|
||||
const mergeable = useCallback((cards: ContactCard[]) => cards.length > 1
|
||||
&& cards.every((c) => c.kind !== "group" && !isContactReadOnly(c))
|
||||
&& new Set(cards.map(ownerKey)).size === 1, [isContactReadOnly]);
|
||||
const handleMergeCards = useCallback((cards: ContactCard[]) => {
|
||||
if (!mergeable(cards)) {
|
||||
toast.error(tMerge("merge_not_possible"));
|
||||
return;
|
||||
}
|
||||
if (!created) return;
|
||||
toast.success(t("toast.created"));
|
||||
showNewestContact();
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, showNewestContact]);
|
||||
setMergeCards(cards);
|
||||
}, [mergeable, tMerge]);
|
||||
const handleBulkMerge = () => handleMergeCards(contacts.filter((c) => selectedContactIds.has(c.id)));
|
||||
|
||||
const runMerge = useCallback(async (plan: MergePlan): Promise<boolean> => {
|
||||
const sync = supportsSync && !!client;
|
||||
try {
|
||||
let { others, patch } = plan;
|
||||
if (sync) {
|
||||
// Photos still in blobs go with their cards: the merged card and Undo get the bytes.
|
||||
const source = (card: ContactCard) => ({
|
||||
client: getContactPhotoClient(card.localAccountId),
|
||||
accountId: card.accountId,
|
||||
localAccountId: card.localAccountId,
|
||||
});
|
||||
others = await Promise.all(others.map(async (card) => card.media
|
||||
? { ...card, media: await inlineContactMedia(card.media, source(card), "drop") }
|
||||
: card));
|
||||
if (patch.media) {
|
||||
const own = plan.keep.media ?? {};
|
||||
const added = Object.fromEntries(Object.entries(patch.media).filter(([key]) => !(key in own)));
|
||||
patch = { ...patch, media: { ...own, ...(await inlineContactMedia(added, source(plan.keep), "drop")) } };
|
||||
}
|
||||
}
|
||||
const undo = await mergeContacts(sync ? client : null, { ...plan, others, patch });
|
||||
if (!undo) return false;
|
||||
// From "Possible duplicates" the list stays, for the next set.
|
||||
if (view !== "duplicates") {
|
||||
setSelectedContact(plan.keep.id);
|
||||
setView("detail");
|
||||
}
|
||||
// Not every card went: say so, and still offer Undo for what changed.
|
||||
(undo.incomplete ? toast.error : toast.success)(tMerge(undo.incomplete ? "merge_failed" : "merged"), {
|
||||
action: {
|
||||
label: tMerge("undo"),
|
||||
onClick: () => {
|
||||
void undoMergeContacts(sync ? client : null, undo).then((ok) => {
|
||||
if (ok) toast.info(tMerge("undone"));
|
||||
else toast.error(tMerge("undo_failed"));
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to merge contacts:", error);
|
||||
toast.error(tMerge("merge_failed"));
|
||||
return false;
|
||||
}
|
||||
}, [supportsSync, client, mergeContacts, undoMergeContacts, setSelectedContact, tMerge, view]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
|
||||
const created = supportsSync && client
|
||||
@@ -971,12 +1017,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
};
|
||||
|
||||
// File > New contact in the desktop app's menus, from any app.
|
||||
useAppCommands({ "new-contact": () => guard(handleCreateNew)() }, contactsEnabled);
|
||||
useAppCommands({ "new-contact": () => guard(handleCreateNew)(), "import-contacts": () => setShowImportDialog(true) }, contactsEnabled);
|
||||
|
||||
// The command palette's Contacts group (Ctrl/⌘+K).
|
||||
useRegisterCommands(tSidebar("contacts"), [
|
||||
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
|
||||
{ id: "import", label: t("import.title"), icon: Upload, run: () => setShowImportDialog(true) },
|
||||
{ id: "find-duplicates", label: tMerge("find_duplicates"), icon: Merge, run: () => guard(() => setView("duplicates"))() },
|
||||
{ id: "new-group", label: t("groups.create"), icon: UsersRound, run: () => guard(handleCreateGroup)() },
|
||||
{ id: "new-address-book", label: t("address_books.create"), icon: BookPlus, run: () => setCreatingAddressBook(true), when: () => !!client },
|
||||
], contactsEnabled);
|
||||
@@ -1076,6 +1123,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
/>
|
||||
);
|
||||
|
||||
case "duplicates":
|
||||
return (
|
||||
<ContactDuplicatesView
|
||||
groups={duplicateGroups}
|
||||
addressBooks={addressBooks}
|
||||
onMerge={handleMergeCards}
|
||||
onOpen={(id) => { setSelectedContact(id); setView("detail"); }}
|
||||
/>
|
||||
);
|
||||
|
||||
case "bulk-add-to-group":
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -1144,9 +1201,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
? () => handleAddContactToGroup(selectedContact.id)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
onMergeWith={
|
||||
selectedContact
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
? () => setMergePickFor(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
|
||||
@@ -1408,7 +1465,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<ContactList
|
||||
contacts={displayedContacts}
|
||||
isContactReadOnly={isContactReadOnly}
|
||||
selectedContactId={selectedContactId}
|
||||
// A group's page opens no member: none is marked in the list (#pim-18).
|
||||
selectedContactId={view === "group-detail" || view === "group-edit" ? null : selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={guard(handleSelectContact)}
|
||||
@@ -1426,7 +1484,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={guard(handleAddContactToGroup)}
|
||||
onComposeContact={handleComposeContact}
|
||||
onDuplicateContact={(contact) => void handleDuplicateContact(contact)}
|
||||
onMergeContact={(contact) => setMergePickFor(contact)}
|
||||
onBulkMerge={handleBulkMerge}
|
||||
banner={duplicateGroups.length > 0 && view !== "duplicates" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={guard(() => setView("duplicates"))}
|
||||
className="w-full flex items-center gap-2 px-4 py-1.5 border-b border-border text-start text-xs font-medium text-primary hover:bg-muted transition-colors"
|
||||
>
|
||||
<Merge className="w-3.5 h-3.5" />
|
||||
{tMerge("possible_duplicates", { count: duplicateGroups.length })}
|
||||
</button>
|
||||
) : undefined}
|
||||
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
emptyView={activeCategory !== "all"}
|
||||
@@ -1435,7 +1504,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// On the phone these are otherwise only in the drawer's "+".
|
||||
<>
|
||||
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
|
||||
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { close(); setShowImportDialog(true); }} />
|
||||
<ContextMenuItem icon={Upload} label={tUxContact("import_menu")} onClick={() => { close(); setShowImportDialog(true); }} />
|
||||
<ContextMenuItem icon={Merge} label={tMerge("find_duplicates")} onClick={() => { close(); guard(() => setView("duplicates"))(); }} />
|
||||
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />}
|
||||
</>
|
||||
)}
|
||||
@@ -1602,6 +1672,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onImport={handleImportContacts}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
@@ -1609,6 +1681,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
/>
|
||||
)}
|
||||
{(mergeCards || mergePickFor) && (
|
||||
<ContactMergeDialog
|
||||
cards={mergeCards ?? undefined}
|
||||
pickFor={mergePickFor ?? undefined}
|
||||
candidates={mergePickFor ? individuals.filter((c) => c.id !== mergePickFor.id && !isContactReadOnly(c) && ownerKey(c) === ownerKey(mergePickFor)) : undefined}
|
||||
suggested={mergePickFor ? (duplicateGroups.find((g) => g.some((c) => c.id === mergePickFor.id)) ?? []).filter((c) => c.id !== mergePickFor.id) : undefined}
|
||||
groups={groups}
|
||||
onMerge={runMerge}
|
||||
onClose={() => { setMergeCards(null); setMergePickFor(null); }}
|
||||
/>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
|
||||
export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||
@@ -90,10 +90,18 @@ function saveCollapsed(state: Record<string, boolean>) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's own "Trusted Senders" book: the senders whose images load, kept
|
||||
* and managed in Settings, not a list of people. The sidebar leaves it out.
|
||||
*/
|
||||
export function isTrustedSendersBook(book: AddressBook): boolean {
|
||||
return !book.isShared && book.name === TRUSTED_SENDERS_BOOK_NAME;
|
||||
}
|
||||
|
||||
export function ContactsSidebar({
|
||||
groups,
|
||||
individuals,
|
||||
addressBooks,
|
||||
addressBooks: allAddressBooks,
|
||||
activeCategory,
|
||||
onSelectCategory,
|
||||
onCreateGroup,
|
||||
@@ -117,7 +125,9 @@ export function ContactsSidebar({
|
||||
multiAccountMode,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tx = useTranslations("ux_pim_contact");
|
||||
const router = useRouter();
|
||||
const addressBooks = useMemo(() => allAddressBooks.filter(b => !isTrustedSendersBook(b)), [allAddressBooks]);
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, openContextMenuFrom: openGroupMenuFrom, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, openContextMenuFrom: openBookMenuFrom, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
|
||||
@@ -289,12 +299,12 @@ export function ContactsSidebar({
|
||||
<ContextMenuItem icon={UserPlus} label={t("create_new")} onClick={() => { newMenu.closeContextMenu(); onCreateContact(); }} />
|
||||
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { newMenu.closeContextMenu(); onCreateGroup(); }} />
|
||||
{onCreateAddressBook && (
|
||||
<ContextMenuItem icon={BookPlus} label={t("address_books.create")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
|
||||
<ContextMenuItem icon={BookPlus} label={tx("new_address_book_menu")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
|
||||
)}
|
||||
{onImport && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
|
||||
<ContextMenuItem icon={Upload} label={tx("import_menu")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
|
||||
@@ -136,6 +136,36 @@ describe('DesktopHost', () => {
|
||||
expect(state['section.mail']).toBeUndefined();
|
||||
});
|
||||
|
||||
// pim-15: File > Import has Mail…, Calendar… and Contacts….
|
||||
it('File > Import > Calendar… and Contacts… open that app with its import', async () => {
|
||||
const { peekParkedAppCommand } = await import('@/lib/app-commands');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.import': true, 'file.importCalendar': true, 'file.importContacts': true });
|
||||
shell.run('file.importCalendar');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
expect(peekParkedAppCommand()).toBe('import-calendar');
|
||||
shell.run('file.importContacts');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
expect(peekParkedAppCommand()).toBe('import-contacts');
|
||||
});
|
||||
|
||||
// pim-15: Edit > Search searches what's on screen, so mail's search is only Mail's.
|
||||
it.each([
|
||||
['/', true],
|
||||
['/settings', true],
|
||||
['/calendar', false],
|
||||
['/contacts', false],
|
||||
['/files', false],
|
||||
])('on %s, the search in Edit goes to mail: %s', async (pathname, mail) => {
|
||||
route.pathname = pathname;
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]['edit.search']).toBe(mail);
|
||||
});
|
||||
|
||||
it('runs a command from what\'s on the page first', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
|
||||
@@ -95,6 +95,17 @@ export function DesktopHost() {
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"file.import": () => openMailImport(),
|
||||
// File > Import > Calendar… and Contacts…: that app's own import, once it's on screen.
|
||||
"file.importCalendar": () => {
|
||||
if (requestAppCommand("import-calendar")) return;
|
||||
parkAppCommand("import-calendar");
|
||||
routerRef.current.push("/calendar");
|
||||
},
|
||||
"file.importContacts": () => {
|
||||
if (requestAppCommand("import-contacts")) return;
|
||||
parkAppCommand("import-contacts");
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
@@ -159,9 +170,12 @@ export function DesktopHost() {
|
||||
"file.newTask": signedIn,
|
||||
"file.newContact": signedIn,
|
||||
"file.import": signedIn && !popout,
|
||||
"file.importCalendar": signedIn && !popout,
|
||||
"file.importContacts": signedIn && !popout,
|
||||
"file.settings": signedIn,
|
||||
[`section.${section}`]: true,
|
||||
"edit.search": signedIn && !popout,
|
||||
// Search mail. Calendar, Contacts and Files search themselves, and say so while they can.
|
||||
"edit.search": signedIn && !popout && (section === "mail" || section === "settings"),
|
||||
"go.inbox": signedIn,
|
||||
"go.mail": signedIn,
|
||||
"go.calendar": signedIn,
|
||||
|
||||
@@ -122,6 +122,7 @@ vi.mock('next-intl', () => ({
|
||||
decline: 'Decline',
|
||||
select_calendar: 'Select calendar',
|
||||
participant_limit: 'The server allows at most {max} participants per event.',
|
||||
answered_accepted: 'You accepted on {date}.',
|
||||
};
|
||||
|
||||
let message = strings[key] ?? key;
|
||||
@@ -132,6 +133,7 @@ vi.mock('next-intl', () => ({
|
||||
}
|
||||
return message;
|
||||
},
|
||||
useLocale: () => 'en-US',
|
||||
useFormatter: () => ({
|
||||
dateTime: (date: Date) => date.toISOString(),
|
||||
}),
|
||||
@@ -389,6 +391,46 @@ describe('CalendarInvitationBanner', () => {
|
||||
expect(mocks.pushMock).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
// pim-23: an answered invitation kept asking "Respond to let the
|
||||
// organizer know"; it now says what the user answered, and when.
|
||||
it('says when the user accepted instead of asking for an answer', async () => {
|
||||
const participants = {
|
||||
attendee: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
mocks.calendarState.events = [
|
||||
{ id: 'event-1', uid: 'uid-1', start: '2026-03-20T09:00:00Z', updated: '2026-03-18T12:00:00Z', participants },
|
||||
];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{ uid: 'uid-1', title: 'Team Sync', start: '2026-03-20T09:00:00Z', participants },
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('You accepted on Wed, Mar 18.')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('request_info')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still asks for an answer while the invitation is unanswered', async () => {
|
||||
const participants = {
|
||||
attendee: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
};
|
||||
mocks.calendarState.events = [
|
||||
{ id: 'event-1', uid: 'uid-1', start: '2026-03-20T09:00:00Z', updated: '2026-03-18T12:00:00Z', participants },
|
||||
];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{ uid: 'uid-1', title: 'Team Sync', start: '2026-03-20T09:00:00Z', participants },
|
||||
]);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('request_info')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/^You accepted/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the invitation summary visible after importing', async () => {
|
||||
mocks.calendarState.importEvents.mockResolvedValue(1);
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from '@/components/icons';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { InvitationPrintSync } from '@/lib/print/invitation-print';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
getInvitationTrustAssessment,
|
||||
formatEventSummary,
|
||||
formatInvitationSchedule,
|
||||
invitationAnsweredAt,
|
||||
invitationAnsweredKey,
|
||||
findParticipantByEmails,
|
||||
extractMethodFromRawIcs,
|
||||
type EventSummary,
|
||||
@@ -51,6 +53,7 @@ import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { RecipientPopover } from './recipient-popover';
|
||||
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
|
||||
import { useAccountOwner } from '@/components/calendar/answer-bars';
|
||||
import { resolveTimeZone } from '@/lib/timezone';
|
||||
|
||||
interface InvitationChangeItem {
|
||||
label: string;
|
||||
@@ -378,8 +381,9 @@ type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
|
||||
|
||||
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
|
||||
const t = useTranslations('email_viewer.calendar_invitation');
|
||||
const tEvent = useTranslations('ux_pim_event');
|
||||
const describeError = useCalendarErrorMessage();
|
||||
const format = useFormatter();
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
||||
@@ -441,6 +445,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
? { sentBy: `mailto:${identityEmails[0]}` }
|
||||
: undefined;
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
// The user's zone, as getEffectiveTimeZone resolves it.
|
||||
const userTimeZone = resolveTimeZone(useSettingsStore((s) => s.timeZone));
|
||||
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
|
||||
calendars: state.calendars,
|
||||
supportsCalendar: state.supportsCalendar,
|
||||
@@ -455,6 +461,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const [state, setState] = useState<BannerState>('loading');
|
||||
const [parsedEvent, setParsedEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
const [rsvpStatus, setRsvpStatus] = useState<string | null>(null);
|
||||
const [answeredAt, setAnsweredAt] = useState<Date | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -653,7 +660,23 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const senderAddress = email.from?.[0]?.email?.toLowerCase();
|
||||
const userIsSender = Boolean(senderAddress && identityEmails.includes(senderAddress));
|
||||
const bannerTitle = getBannerTitle(t, method);
|
||||
const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
|
||||
// Once answered, the banner says so instead of asking (pim-23).
|
||||
const answeredDate = myParticipant
|
||||
? invitationAnsweredAt(
|
||||
rsvpStatus ? answeredAt : null,
|
||||
myParticipant.participant,
|
||||
currentUserParticipant ? existingEvent?.updated : null,
|
||||
)
|
||||
: null;
|
||||
const answeredKey = myParticipant && supportsCalendar ? invitationAnsweredKey(method, currentRsvp, !!answeredDate) : null;
|
||||
const answeredInfo = answeredKey
|
||||
? tEvent(answeredKey, {
|
||||
date: answeredDate
|
||||
? new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: userTimeZone }).format(answeredDate)
|
||||
: '',
|
||||
})
|
||||
: null;
|
||||
const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
|
||||
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null;
|
||||
const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null;
|
||||
const participationLabel = getParticipationLabel(t, currentRsvp);
|
||||
@@ -736,6 +759,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
// 'stopped': a plugin stopped it and said why; keep the old answer.
|
||||
if (outcome === 'answered') {
|
||||
setRsvpStatus(status);
|
||||
setAnsweredAt(new Date());
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
} else if (outcome === 'added') {
|
||||
@@ -795,11 +819,11 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
}
|
||||
};
|
||||
|
||||
const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule(
|
||||
eventSummary,
|
||||
(date, options) => format.dateTime(date, options),
|
||||
timeFormat === '12h',
|
||||
);
|
||||
const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule(eventSummary, {
|
||||
locale,
|
||||
timeZone: userTimeZone,
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
|
||||
const proposedChanges = method === 'counter' && proposalTarget && parsedEvent
|
||||
? buildInvitationChangeItems(t, proposalTarget, parsedEvent, formatSchedule)
|
||||
@@ -1012,7 +1036,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
{summary.start && (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
<span className="truncate" data-testid="invitation-when">
|
||||
{formatSchedule(summary)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -74,6 +74,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p
|
||||
filesFolder: filesDropTarget()?.folder ?? null,
|
||||
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
|
||||
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
|
||||
contactsScreen: matchSurface(pathname)?.surface === "contacts",
|
||||
importFolder: folder?.name ?? null,
|
||||
offline: isOfflineNow(account?.serverUrl),
|
||||
keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// pim-15: the desktop app's Edit > Search files.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
|
||||
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;
|
||||
|
||||
function renderBrowser() {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[NOTES]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
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()}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
onNavigate={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onDownload={noop}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
});
|
||||
|
||||
describe('Files in the desktop menus', () => {
|
||||
it('Edit > Search files is on offer and puts the cursor in the files search', async () => {
|
||||
const setState = vi.fn();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState, onCommand: () => () => {} } } as unknown as BulwarkDesktopBridge;
|
||||
renderBrowser();
|
||||
await waitFor(() => expect(setState).toHaveBeenCalled());
|
||||
expect(setState.mock.lastCall![0]).toMatchObject({ 'edit.search': true });
|
||||
let ran = false;
|
||||
act(() => { ran = runDesktopCommand('edit.search'); });
|
||||
expect(ran).toBe(true);
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox', { name: 'search_placeholder' }));
|
||||
});
|
||||
});
|
||||
@@ -198,20 +198,23 @@ describe('FileBrowser', () => {
|
||||
expect(within(tree()).getByText('Documents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a folder from its name and selects it from the row', () => {
|
||||
// pim-3: the name is plain text, as in a file manager. A click selects, a
|
||||
// double-click opens.
|
||||
it('selects a folder from its name and opens it on a double-click', () => {
|
||||
const { onNavigate, onSetSelection } = renderBrowser();
|
||||
const row = within(list()).getByText('Documents').closest('[role=row]')!;
|
||||
fireEvent.click(row);
|
||||
fireEvent.click(within(list()).getByText('Documents'));
|
||||
expect(onSetSelection).toHaveBeenCalledWith(new Set(['Documents']));
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(list()).getByText('Documents'));
|
||||
fireEvent.doubleClick(within(list()).getByText('Documents'));
|
||||
expect(onNavigate).toHaveBeenCalledWith('/Documents', 'f-docs');
|
||||
});
|
||||
|
||||
it('previews a text file when its name is clicked', () => {
|
||||
it('previews a text file when its name is double-clicked', () => {
|
||||
const { onPreviewFile } = renderBrowser();
|
||||
fireEvent.click(within(list()).getByText('notes.txt'));
|
||||
expect(onPreviewFile).not.toHaveBeenCalled();
|
||||
fireEvent.doubleClick(within(list()).getByText('notes.txt'));
|
||||
expect(onPreviewFile).toHaveBeenCalledWith('notes.txt');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { FileGridView } from '../file-views';
|
||||
import { splitNameForMiddleCut } from '../file-item-parts';
|
||||
import type { FileResource } from '@/stores/file-store';
|
||||
|
||||
// pim-21: long names in the grid are cut in the middle, so the extension and
|
||||
// the part that tells similar names apart stay in view.
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('splitNameForMiddleCut', () => {
|
||||
it('keeps the end of the name and the extension', () => {
|
||||
expect(splitNameForMiddleCut('IMG_20250611_1003.png')).toEqual({ head: 'IMG_20250611_', tail: '1003.png' });
|
||||
expect(splitNameForMiddleCut('meeting-notes-2026-q3.md')).toEqual({ head: 'meeting-notes-202', tail: '6-q3.md' });
|
||||
expect(splitNameForMiddleCut('Großes Angebot – final (v3).txt')).toEqual({ head: 'Großes Angebot – final ', tail: '(v3).txt' });
|
||||
});
|
||||
|
||||
it('leaves short names whole', () => {
|
||||
expect(splitNameForMiddleCut('a.txt')).toEqual({ head: 'a.txt', tail: '' });
|
||||
expect(splitNameForMiddleCut('readme.txt')).toEqual({ head: 'readme.txt', tail: '' });
|
||||
});
|
||||
|
||||
it('cuts names without an extension near the end too', () => {
|
||||
expect(splitNameForMiddleCut('Quarterly reports archive')).toEqual({ head: 'Quarterly reports arc', tail: 'hive' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the grid', () => {
|
||||
it('cuts the start of a long name, keeps its end, and has the whole name as title', () => {
|
||||
const resource = {
|
||||
id: 'i', name: 'IMG_20250611_1003.png', serverName: 'IMG_20250611_1003.png', isDirectory: false, contentType: 'image/png',
|
||||
contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b', parentId: null,
|
||||
} as FileResource;
|
||||
render(
|
||||
<FileGridView
|
||||
resources={[resource]}
|
||||
selected={new Set()}
|
||||
focusedName={null}
|
||||
tabStopName={null}
|
||||
dropTarget={null}
|
||||
cutNames={new Set()}
|
||||
starredPaths={new Set()}
|
||||
pathOf={() => '/'}
|
||||
touch={false}
|
||||
iconOptions={{ showIcons: true, coloredIcons: true, showThumbnails: false }}
|
||||
getImageUrl={async () => ''}
|
||||
handlers={{
|
||||
onSelectClick: vi.fn(), onToggleSelect: vi.fn(), onOpen: vi.fn(), onContextMenu: vi.fn(), onShare: vi.fn(),
|
||||
canShare: () => false, dragProps: () => ({}), onFocusItem: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const name = screen.getByTitle('IMG_20250611_1003.png');
|
||||
const [head, tail] = Array.from(name.children) as HTMLElement[];
|
||||
expect(head.textContent).toBe('IMG_20250611_');
|
||||
expect(head.className).toContain('truncate');
|
||||
expect(tail.textContent).toBe('1003.png');
|
||||
expect(tail.className).toContain('shrink-0');
|
||||
expect(name.textContent).toBe('IMG_20250611_1003.png');
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,11 @@ function tap(el: Element, x = 10, y = 10) {
|
||||
describe('ImagePreviewModal', () => {
|
||||
it('closes when the area around the image is clicked', async () => {
|
||||
const { onClose, box } = await renderViewer();
|
||||
// Not in the first 400 ms: that is still the click that opened it (pim-3).
|
||||
const later = Date.now() + 1000;
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(later);
|
||||
tap(box);
|
||||
now.mockRestore();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UploadConflictDialog } from '../upload-conflict-dialog';
|
||||
import { FileNoPreview } from '../file-no-preview';
|
||||
import { EmptyFolder } from '../file-empty-state';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { sharedNodeLink } from '../share-message';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
// The share dialog itself isn't ours: a stand-in that adds and changes a share.
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({
|
||||
ShareCollectionDialog: ({ onShare }: { onShare: (principalId: string, rights: unknown) => Promise<void> }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => void onShare('p-new', { mayRead: true })}>add new</button>
|
||||
<button type="button" onClick={() => void onShare('p-old', { mayRead: true, mayWrite: true })}>change old</button>
|
||||
<button type="button" onClick={() => void onShare('p-new', null)}>remove</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('the upload conflict question (pim-10)', () => {
|
||||
const conflict = { name: 'report.txt', kind: 'file' as const };
|
||||
|
||||
it('offers Replace, Keep both, Skip and Cancel, and "for all" only with more conflicts left', () => {
|
||||
const answer = vi.fn();
|
||||
const { rerender } = render(<UploadConflictDialog question={{ conflict, remaining: 1, answer }} />);
|
||||
expect(screen.getByRole('alertdialog', { name: 'conflict_title' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'keep_both' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'keep', applyToAll: false });
|
||||
|
||||
rerender(<UploadConflictDialog question={{ conflict, remaining: 3, answer }} />);
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'apply_to_all' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'replace' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'replace', applyToAll: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skip' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'skip', applyToAll: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
|
||||
expect(answer).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('explains Replace for a folder as a merge', () => {
|
||||
render(<UploadConflictDialog question={{ conflict: { name: 'Docs', kind: 'folder' }, remaining: 1, answer: vi.fn() }} />);
|
||||
expect(screen.getByText('conflict_folder_hint')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pane for a file with no preview (pim-16)', () => {
|
||||
const budget = {
|
||||
id: 'x', name: 'budget-2026.xlsx', serverName: 'budget-2026.xlsx', isDirectory: false, contentType: 'application/octet-stream',
|
||||
contentLength: 1600, lastModified: '', blobId: 'b', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
it('shows the name, size and Download, and Open in default app only when the desktop app can', () => {
|
||||
const onDownload = vi.fn();
|
||||
const onOpenInApp = vi.fn();
|
||||
const { rerender } = render(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} />);
|
||||
const pane = screen.getByTestId('file-no-preview');
|
||||
expect(pane).toHaveTextContent('budget-2026.xlsx');
|
||||
expect(pane).toHaveTextContent('1.6 KB');
|
||||
expect(screen.queryByRole('button', { name: 'open_in_app' })).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'download' }).at(-1)!);
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
|
||||
rerender(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} onOpenInApp={onOpenInApp} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open_in_app' }));
|
||||
expect(onOpenInApp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"Upload files…" ends in "…" (pim-26)', () => {
|
||||
it('in an empty folder too', () => {
|
||||
render(<EmptyFolder isRoot canCreate onUpload={vi.fn()} onCreateFolder={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'files_upload_files' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'files_new_folder' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('after sharing with someone new (pim-26)', () => {
|
||||
it('reports who was added, not a change or a removal', async () => {
|
||||
const report = {
|
||||
id: 'r1', name: 'report.pdf', serverName: 'report.pdf', isDirectory: false, contentType: 'application/pdf', contentLength: 5,
|
||||
lastModified: '', blobId: 'b', parentId: null, shareWith: { 'p-old': { mayRead: true } },
|
||||
} as unknown as FileResource;
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const onShare = vi.fn(async () => {});
|
||||
const onShared = vi.fn();
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/" resources={[report]} isLoading={false} error={null} selectedResources={new Set()} uploadProgress={null} clipboard={null}
|
||||
onNavigate={vi.fn()} onSetSelection={vi.fn()} onToggleSelect={vi.fn()} onPreviewFile={vi.fn()} onPreviewImage={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} onCreateFile={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" sharingEnabled onShare={onShare} onShared={onShared}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTitle('report.pdf'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'files_share' }));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'change old' })); });
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove' })); });
|
||||
expect(onShared).not.toHaveBeenCalled();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add new' })); });
|
||||
expect(onShare).toHaveBeenLastCalledWith('r1', 'p-new', { mayRead: true });
|
||||
expect(onShared).toHaveBeenCalledWith(expect.objectContaining({ id: 'r1' }), 'p-new');
|
||||
});
|
||||
|
||||
it('links the person to the node as it is listed for them, a file with its preview', () => {
|
||||
expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'n7', name: 'report.pdf', isDirectory: false }))
|
||||
.toBe('https://mail.example/files?shared=acc1%3An7&preview=report.pdf');
|
||||
expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'd2', name: 'Team', isDirectory: true }))
|
||||
.toBe('https://mail.example/files?shared=acc1%3Ad2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { FileGridView, FileListView, type FileItemHandlers } from '../file-views';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { BACKDROP_GRACE_MS } from '../preview-shell';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// pim-3: a double-click opens a file and the preview stays open; the name is
|
||||
// plain text in both views (a click selects). pim-16: a file with no preview
|
||||
// and no editor opens a pane with Download instead of downloading at once.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
const res = (id: string, name: string, extra: Partial<FileResource> = {}): FileResource => ({
|
||||
id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra,
|
||||
} as FileResource);
|
||||
const notes = res('n', 'notes.txt');
|
||||
const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null });
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>;
|
||||
function handlers(): FileItemHandlers & { onOpen: Spy; onSelectClick: Spy } {
|
||||
return {
|
||||
onSelectClick: vi.fn(),
|
||||
onToggleSelect: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onContextMenu: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onShare: vi.fn(),
|
||||
canShare: () => false,
|
||||
dragProps: () => ({}),
|
||||
onFocusItem: vi.fn(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
const viewProps = (h: FileItemHandlers) => ({
|
||||
resources: [docs, notes],
|
||||
selected: new Set<string>(),
|
||||
focusedName: null,
|
||||
tabStopName: null,
|
||||
dropTarget: null,
|
||||
cutNames: new Set<string>(),
|
||||
starredPaths: new Set<string>(),
|
||||
pathOf: (r: FileResource) => `/${r.name}`,
|
||||
touch: false,
|
||||
iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false },
|
||||
getImageUrl: async () => '',
|
||||
handlers: h,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('a click on a name selects, a double-click opens (pim-3)', () => {
|
||||
it('in the list', () => {
|
||||
const h = handlers();
|
||||
render(<FileListView {...viewProps(h)} sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
|
||||
for (const name of ['notes.txt', 'Documents']) {
|
||||
const row = screen.getByTitle(name).closest('[role="row"]')!;
|
||||
expect(within(row as HTMLElement).queryByRole('button', { name })).toBeNull();
|
||||
fireEvent.click(screen.getByTitle(name));
|
||||
expect(h.onOpen).not.toHaveBeenCalled();
|
||||
expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything());
|
||||
fireEvent.doubleClick(screen.getByTitle(name));
|
||||
expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name }));
|
||||
h.onOpen.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('in the grid', () => {
|
||||
const h = handlers();
|
||||
render(<FileGridView {...viewProps(h)} />);
|
||||
for (const name of ['notes.txt', 'Documents']) {
|
||||
fireEvent.click(screen.getByTitle(name));
|
||||
expect(h.onOpen).not.toHaveBeenCalled();
|
||||
expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything());
|
||||
fireEvent.doubleClick(screen.getByTitle(name));
|
||||
expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name }));
|
||||
h.onOpen.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('on touch a tap on the name still opens', () => {
|
||||
const h = handlers();
|
||||
render(<FileListView {...viewProps(h)} touch sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('notes.txt'));
|
||||
expect(h.onOpen).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.txt' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preview stays open after the click that opened it (pim-3)', () => {
|
||||
it('ignores a backdrop click in the first 400 ms', () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const onClose = vi.fn();
|
||||
render(<FilePreviewModal name="notes.txt" onClose={onClose} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} />);
|
||||
const backdrop = screen.getByRole('dialog');
|
||||
now += BACKDROP_GRACE_MS - 50;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
now += 100;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function renderBrowser(props: Partial<React.ComponentProps<typeof FileBrowser>> = {}) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const h = {
|
||||
onPreviewFile: vi.fn(),
|
||||
onPreviewImage: vi.fn(),
|
||||
onDownload: vi.fn(async () => {}),
|
||||
onShowFileInfo: vi.fn(),
|
||||
onSetSelection: vi.fn(),
|
||||
};
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[docs, notes, budget]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
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}
|
||||
onCreateFile={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"
|
||||
{...h}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('a file with no preview and no editor (pim-16)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
|
||||
});
|
||||
|
||||
it('opens the pane instead of downloading', () => {
|
||||
const h = renderBrowser();
|
||||
fireEvent.doubleClick(screen.getByTitle('budget.xlsx'));
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
expect(h.onDownload).not.toHaveBeenCalled();
|
||||
fireEvent.doubleClick(screen.getByTitle('notes.txt'));
|
||||
expect(h.onPreviewFile).toHaveBeenCalledWith('notes.txt');
|
||||
});
|
||||
|
||||
it('has Open in its menu, and in the desktop app Open in default app', () => {
|
||||
const onOpenInApp = vi.fn();
|
||||
const h = renderBrowser({ canOpenInApp: (r) => r.name === 'budget.xlsx', onOpenInApp });
|
||||
fireEvent.contextMenu(screen.getByTitle('budget.xlsx'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'open' }));
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
fireEvent.contextMenu(screen.getByTitle('budget.xlsx'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'open_in_app' }));
|
||||
expect(onOpenInApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'budget.xlsx' }));
|
||||
// A text file previews: no extra Open there.
|
||||
fireEvent.contextMenu(screen.getByTitle('notes.txt'));
|
||||
expect(screen.queryByRole('menuitem', { name: 'open' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Enter opens the row Tab put the focus on', () => {
|
||||
const h = renderBrowser();
|
||||
const row = document.querySelector<HTMLElement>('[data-resource="budget.xlsx"]')!;
|
||||
fireEvent.focus(row);
|
||||
fireEvent.keyDown(row, { key: 'Enter' });
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
|
||||
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink,
|
||||
} from "@/components/icons";
|
||||
import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -45,6 +45,7 @@ import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { FilesListingSkeleton } from "@/components/boot-skeleton/files-skeleton";
|
||||
|
||||
const NO_FORMATS: string[] = [];
|
||||
@@ -138,6 +139,13 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** A file with no preview and no editor: shows its icon, size and Download (pim-16). Left out: it downloads. */
|
||||
onShowFileInfo?: (name: string) => void;
|
||||
/** The desktop app can open this file in its program. */
|
||||
canOpenInApp?: (resource: FileResource) => boolean;
|
||||
onOpenInApp?: (resource: FileResource) => void;
|
||||
/** A principal was given access to a node they couldn't see before. */
|
||||
onShared?: (resource: FileResource, principalId: string) => void;
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
@@ -220,9 +228,14 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onShowFileInfo,
|
||||
canOpenInApp,
|
||||
onOpenInApp,
|
||||
onShared,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
// Menu entries that open a dialog end in "…".
|
||||
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
|
||||
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
|
||||
@@ -517,10 +530,12 @@ export function FileBrowser({
|
||||
onPreviewImage(resource.name);
|
||||
} else if (isPreviewable(resource.name)) {
|
||||
onPreviewFile(resource.name);
|
||||
} else if (onShowFileInfo) {
|
||||
onShowFileInfo(resource.name);
|
||||
} else {
|
||||
void onDownload(resource.name);
|
||||
}
|
||||
}, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onDownload]);
|
||||
}, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onShowFileInfo, onDownload]);
|
||||
|
||||
const previewResource = useCallback((resource: FileResource) => {
|
||||
if (isImageFile(resource.name)) onPreviewImage(resource.name);
|
||||
@@ -754,6 +769,9 @@ export function FileBrowser({
|
||||
return Math.max(1, tops.filter((top) => top === tops[0]).length);
|
||||
}, [focusedName]);
|
||||
|
||||
// Edit > Search files in the desktop app's menus.
|
||||
useDesktopCommands({ "edit.search": () => searchInputRef.current?.focus() }, { "edit.search": !accountPickerMode });
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target) || isAppShortcutBlocked(e)) return;
|
||||
@@ -810,9 +828,14 @@ export function FileBrowser({
|
||||
setRenameTarget(singleSelected.name);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && singleSelected) {
|
||||
// Enter opens the selected item, or the one Tab put the focus on.
|
||||
const enterTarget = singleSelected
|
||||
?? (selected.length === 0 && focusedName && (e.target as HTMLElement | null)?.dataset?.resource === focusedName
|
||||
? displayResources.find((r) => r.name === focusedName) ?? null
|
||||
: null);
|
||||
if (e.key === "Enter" && enterTarget) {
|
||||
e.preventDefault();
|
||||
openResource(singleSelected);
|
||||
openResource(enterTarget);
|
||||
return;
|
||||
}
|
||||
if (e.key === " " && focusedName) {
|
||||
@@ -852,7 +875,7 @@ export function FileBrowser({
|
||||
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
|
||||
showDetails, isNarrow, toggleDetails,
|
||||
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
|
||||
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
|
||||
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, displayResources,
|
||||
]);
|
||||
|
||||
// ---- Context menu ----------------------------------------------------------
|
||||
@@ -943,6 +966,13 @@ export function FileBrowser({
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
{/* Neither previewed nor edited here: Open shows what there is (pim-16). */}
|
||||
{!isPreviewable(resource.name) && !(isOfficeEditable?.(resource.name) && onEditFile) && onShowFileInfo && (
|
||||
<ContextMenuItem icon={File} label={t("open")} onClick={() => { closeMenu(); onShowFileInfo(resource.name); }} />
|
||||
)}
|
||||
{canOpenInApp?.(resource) && onOpenInApp && (
|
||||
<ContextMenuItem icon={ExternalLink} label={tUx("open_in_app")} onClick={() => { closeMenu(); onOpenInApp(resource); }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canKeepOffline(resource) && (
|
||||
@@ -1501,7 +1531,10 @@ export function FileBrowser({
|
||||
shareWith={shareTarget.shareWith}
|
||||
ownAccountId={ownAccountId || ""}
|
||||
onShare={async (principalId, rights) => {
|
||||
const added = !!rights && !shareTarget.shareWith?.[principalId];
|
||||
await onShare(shareTarget.id, principalId, rights as FileNodeRights | null);
|
||||
// Someone new can see it now: offer to tell them (pim-26).
|
||||
if (added) onShared?.(shareTarget, principalId);
|
||||
}}
|
||||
onClose={() => setShareTargetId(null)}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface EmptyFolderProps {
|
||||
/** What an empty folder shows: what to do next, and that dropping files works. */
|
||||
export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) {
|
||||
const t = useTranslations("files");
|
||||
// Both open a picker or a dialog, so they end in "…" as the menus' entries do.
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
return (
|
||||
<EmptyState
|
||||
className="h-full"
|
||||
@@ -25,11 +27,11 @@ export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: Emp
|
||||
<>
|
||||
<Button size="sm" onClick={onUpload} className="gap-1.5">
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("upload_files")}
|
||||
{tMenu("files_upload_files")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
{t("new_folder")}
|
||||
{tMenu("files_new_folder")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
@@ -203,6 +203,34 @@ export function readDraggedNames(e: React.DragEvent): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file name split for cutting in the middle: the start gives way, the end
|
||||
* stays, so "IMG_20250611_1003.png" reads "IMG_20250…1003.png" and the
|
||||
* extension and the part that tells similar names apart stay in view. Short
|
||||
* names and folders' names (no extension) keep a shorter tail.
|
||||
*/
|
||||
export function splitNameForMiddleCut(name: string): { head: string; tail: string } {
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot > 0 && name.length - dot <= 10 ? name.slice(dot) : "";
|
||||
const tailLength = Math.min(ext.length + 4, Math.floor(name.length / 2));
|
||||
if (name.length < 12 || tailLength <= 0) return { head: name, tail: "" };
|
||||
return { head: name.slice(0, name.length - tailLength), tail: name.slice(name.length - tailLength) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A name cut in the middle when there is no room (pim-21). The whole name is
|
||||
* the title; screen readers read it as one text.
|
||||
*/
|
||||
export function MiddleCutName({ name, title, className }: { name: string; title?: string; className?: string }) {
|
||||
const { head, tail } = splitNameForMiddleCut(name);
|
||||
return (
|
||||
<span className={cn("flex min-w-0", className)} title={title ?? name} dir="auto">
|
||||
<span className="truncate whitespace-pre">{head}</span>
|
||||
{tail && <span className="shrink-0 whitespace-pre">{tail}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a shared context menu as a dropdown under a button. */
|
||||
export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Download, ExternalLink } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { FileSize, ResourceIcon } from "@/components/files/file-item-parts";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
|
||||
const ICON_OPTIONS = { showIcons: true, coloredIcons: true };
|
||||
|
||||
/**
|
||||
* A file the app can't show and no office editor opens (a spreadsheet with
|
||||
* no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with
|
||||
* Download and, in the desktop app, Open in the program the computer uses
|
||||
* for it (pim-16). It used to download at once, with no word.
|
||||
*/
|
||||
export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: {
|
||||
resource: FileResource;
|
||||
onClose: () => void;
|
||||
/** Left out when a device policy turned downloads off. */
|
||||
onDownload?: () => void;
|
||||
/** Only in the desktop app, for a file it may open. */
|
||||
onOpenInApp?: () => void;
|
||||
neighbours?: PreviewNeighbours;
|
||||
}) {
|
||||
const t = useTranslations("files");
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
return (
|
||||
<PreviewShell name={resource.name} onClose={onClose} onDownload={onDownload} neighbours={neighbours}>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
data-testid="file-no-preview"
|
||||
className="bg-background rounded-lg p-8 max-w-sm w-full flex flex-col items-center gap-3 text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ResourceIcon resource={resource} size="xl" options={ICON_OPTIONS} />
|
||||
<p className="text-sm font-medium break-all" dir="auto">{resource.name}</p>
|
||||
<p className="text-xs text-muted-foreground"><FileSize bytes={resource.contentLength} /></p>
|
||||
<p className="text-sm text-muted-foreground">{tUx("no_preview")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-1">
|
||||
{onOpenInApp && (
|
||||
<Button size="sm" onClick={onOpenInApp} className="gap-1.5">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{tUx("open_in_app")}
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button size="sm" variant={onOpenInApp ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
|
||||
<Download className="w-4 h-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewShell>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
|
||||
@@ -233,6 +233,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
|
||||
// Back closes the preview instead of leaving Files.
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
|
||||
const fileType = resolvedFileType;
|
||||
// The browser's own PDF viewer has Download and Print buttons of its own.
|
||||
@@ -412,7 +413,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<div className="absolute inset-0" {...noMediaSavingProps(downloadsOff)}>
|
||||
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
|
||||
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import {
|
||||
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
@@ -262,21 +262,16 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
|
||||
) : icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (touch && selecting) handlers.onToggleSelect(resource);
|
||||
else if (e.ctrlKey || e.metaKey || e.shiftKey) handlers.onSelectClick(resource, e);
|
||||
else handlers.onOpen(resource);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
|
||||
{/* Plain text, as in grid view and a file manager: a click selects
|
||||
the row, a double-click or Enter opens it, a tap on touch opens
|
||||
it (useTouchPress). A link here opened on the first click of a
|
||||
double-click, and the second closed the preview again (pim-3). */}
|
||||
<span
|
||||
className={cn("truncate text-start", resource.isDirectory && "font-medium")}
|
||||
title={note ? `${resource.name}\n${note}` : resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
</span>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
@@ -427,16 +422,9 @@ function FolderTile({ resource, isSelected, isFocused, isTabStop, isDropTarget,
|
||||
</span>
|
||||
)}
|
||||
<ResourceIcon resource={resource} size="sm" options={iconOptions} />
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 truncate text-start text-sm font-medium"
|
||||
title={resource.name}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate text-start text-sm font-medium" title={resource.name}>
|
||||
{resource.name}
|
||||
</button>
|
||||
</span>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
<IconButton
|
||||
@@ -493,16 +481,7 @@ function FileTile({ resource, isSelected, isFocused, isTabStop, isCut, selecting
|
||||
)}
|
||||
<div className="flex items-center gap-1 ps-2.5 pe-1 py-1.5 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="block w-full truncate text-start text-sm"
|
||||
title={resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
<MiddleCutName name={resource.name} className="w-full text-start text-sm" />
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">
|
||||
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
|
||||
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store";
|
||||
import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
|
||||
import { isTrashLocation } from "@/lib/files-trash";
|
||||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||||
@@ -29,6 +29,15 @@ import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { WopiEditor } from "@/components/files/wopi-editor";
|
||||
import { UploadConflictDialog, type UploadConflictQuestion } from "@/components/files/upload-conflict-dialog";
|
||||
import { FileNoPreview } from "@/components/files/file-no-preview";
|
||||
import { sharedNodeLink } from "@/components/files/share-message";
|
||||
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
|
||||
import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useEditAttachment } from "@/hooks/use-edit-attachment";
|
||||
import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts";
|
||||
import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
@@ -141,6 +150,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<string | null>(null);
|
||||
// A file with no preview and no editor: its icon, size and Download (pim-16).
|
||||
const [infoFile, setInfoFile] = useState<string | null>(null);
|
||||
// WOPI document editing (#425): name of the file open in the editor overlay.
|
||||
const [editFile, setEditFile] = useState<string | null>(null);
|
||||
const wopiStatus = useWopiStatus(filesEnabled);
|
||||
@@ -380,6 +391,22 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||
|
||||
// A name that is taken in the target folder: asked one at a time (pim-10).
|
||||
const [conflictQuestion, setConflictQuestion] = useState<UploadConflictQuestion | null>(null);
|
||||
const resolveConflicts = useCallback<UploadConflictResolver>((conflicts) => resolveConflictsInTurn(
|
||||
conflicts,
|
||||
(conflict, index, total) => new Promise<ConflictAnswer>((resolve) => {
|
||||
setConflictQuestion({
|
||||
conflict,
|
||||
remaining: total - index,
|
||||
answer: (answer) => {
|
||||
setConflictQuestion(null);
|
||||
resolve(answer);
|
||||
},
|
||||
});
|
||||
}),
|
||||
), []);
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
@@ -390,12 +417,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFiles(files));
|
||||
showUploadReport(await uploadFiles(files, { resolveConflicts }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
@@ -407,12 +434,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFolder(files));
|
||||
showUploadReport(await uploadFolder(files, { resolveConflicts }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
try {
|
||||
@@ -648,6 +675,27 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
const handleShowFileInfo = useCallback((name: string) => {
|
||||
setInfoFile(name);
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
// The desktop app opens a file in the program the computer uses for it,
|
||||
// the way Edit opens an attachment (pim-16). Nothing in a browser.
|
||||
const filesLocalAccount = useFileStore((s) => s.currentAccountId) ?? activeAccountId ?? "";
|
||||
const { canEdit: canOpenNameInApp, edit: openInDesktopApp } = useEditAttachment(filesLocalAccount);
|
||||
const canOpenInApp = useCallback((r: FileResource) => !r.isDirectory && !r.isSymlink && canOpenNameInApp(r.name), [canOpenNameInApp]);
|
||||
const handleOpenInApp = useCallback((r: FileResource) => {
|
||||
void openInDesktopApp({
|
||||
key: `files:${filesLocalAccount}:${r.id}`,
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
account: filesLocalAccount,
|
||||
source: { kind: "file" },
|
||||
load: async () => (await getFileContent(r.name)).blob,
|
||||
});
|
||||
}, [openInDesktopApp, filesLocalAccount, getFileContent]);
|
||||
|
||||
const handleRenameNode = useCallback(async (id: string, newName: string) => {
|
||||
try {
|
||||
if (!await renameNode(id, newName)) return;
|
||||
@@ -719,6 +767,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
if (isOfficeEditable(name)) setEditFile(name);
|
||||
else if (isImageFile(name)) setPreviewImage(name);
|
||||
else if (isFilePreviewable(name)) setPreviewFile(name);
|
||||
else setInfoFile(name);
|
||||
}, [revealNode, addRecentFile, isOfficeEditable, tDeepLink]);
|
||||
|
||||
// The permalink for the folder on screen. In the Pro shell only the focused
|
||||
@@ -776,6 +825,36 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
// After sharing with someone new: "Send a message" opens the composer to
|
||||
// them with a link to it (pim-26). The server doesn't tell them.
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
const router = useRouter();
|
||||
const handleShared = useCallback((resource: FileResource, principalId: string) => {
|
||||
if (!storeClient || !filesAccountId) return;
|
||||
void loadPrincipalDirectory(storeClient).then(({ principals }) => {
|
||||
const person = principals.find((p) => p.id === principalId);
|
||||
const email = person?.email?.trim();
|
||||
if (!person || !email) return;
|
||||
const link = sharedNodeLink(window.location.origin, filesAccountId, resource);
|
||||
toast.success(tUx("shared_with", { name: resource.name, person: person.name || email }), {
|
||||
action: {
|
||||
label: tUx("send_message"),
|
||||
onClick: () => {
|
||||
savePendingMailto({
|
||||
to: [formatRecipient(person.name && person.name !== email ? person.name : "", email)],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: tUx("share_mail_subject", { name: resource.name }),
|
||||
body: tUx("share_mail_body", { name: resource.name, link }),
|
||||
inApp: true,
|
||||
});
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [storeClient, filesAccountId, tUx, router]);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -949,6 +1028,10 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
onShowFileInfo={handleShowFileInfo}
|
||||
canOpenInApp={canOpenInApp}
|
||||
onOpenInApp={handleOpenInApp}
|
||||
onShared={handleShared}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -990,6 +1073,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A file with no preview (pim-16) */}
|
||||
{infoFile && (() => {
|
||||
const resource = resources.find((r) => r.name === infoFile);
|
||||
if (!resource) return null;
|
||||
return (
|
||||
<FileNoPreview
|
||||
resource={resource}
|
||||
onClose={() => setInfoFile(null)}
|
||||
onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)}
|
||||
onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* WOPI document editor overlay (#425) */}
|
||||
{editFile && (() => {
|
||||
const editResource = resources.find(r => r.name === editFile);
|
||||
@@ -1028,6 +1125,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<UploadConflictDialog question={conflictQuestion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
@@ -35,6 +35,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
|
||||
// Back closes the preview instead of leaving Files, as for every other preview.
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
let revoke: string | null = null;
|
||||
@@ -76,7 +77,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && !error && (
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
|
||||
)}
|
||||
{imageUrl && !error && <Watermark text={watermark} />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
@@ -14,6 +14,25 @@ export interface PreviewNeighbours {
|
||||
onGo: (name: string) => void;
|
||||
}
|
||||
|
||||
/** A backdrop click this soon after the preview opened is part of the click that opened it. */
|
||||
export const BACKDROP_GRACE_MS = 400;
|
||||
|
||||
/**
|
||||
* `onClose` for a click on the area around the content. The rest of the
|
||||
* click that opened the preview (a double-click's second click, a
|
||||
* triple-click) is ignored, so it can't close the preview again (pim-3).
|
||||
*/
|
||||
export function useBackdropClose(onClose: () => void): () => void {
|
||||
const openedAt = useRef(0);
|
||||
useEffect(() => {
|
||||
openedAt.current = Date.now();
|
||||
}, []);
|
||||
return useCallback(() => {
|
||||
if (Date.now() - openedAt.current < BACKDROP_GRACE_MS) return;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
}
|
||||
|
||||
interface PreviewShellProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
@@ -34,6 +53,7 @@ interface PreviewShellProps {
|
||||
*/
|
||||
export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) {
|
||||
const t = useTranslations("files");
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
// Tab stays in the preview and the focus goes back to the file that was
|
||||
// opened when it closes. Escape is the listener below.
|
||||
const shellRef = useFocusTrap({ isActive: true, restoreFocus: true });
|
||||
@@ -86,7 +106,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/80"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
onClick={closeOnBackdrop ? closeFromBackdrop : undefined}
|
||||
>
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appPath, buildSharedFilesPath } from "@/lib/deep-links";
|
||||
|
||||
/**
|
||||
* The link a person a file or folder was shared with opens it by. The node
|
||||
* is theirs to see under "<owner account>:<node id>" (the id a drive shared
|
||||
* with them is listed by); a file opens in its preview there.
|
||||
*/
|
||||
export function sharedNodeLink(
|
||||
origin: string,
|
||||
ownerAccountId: string,
|
||||
node: { id: string; name: string; isDirectory: boolean },
|
||||
): string {
|
||||
const shared = `${ownerAccountId}:${node.id}`;
|
||||
return `${origin}${appPath(buildSharedFilesPath(shared, node.isDirectory ? null : node.name))}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import type { ConflictAnswer, UploadConflict, UploadConflictChoice } from "@/lib/files-upload-conflicts";
|
||||
|
||||
export interface UploadConflictQuestion {
|
||||
conflict: UploadConflict;
|
||||
/** How many conflicts are left, this one included. */
|
||||
remaining: number;
|
||||
answer: (answer: ConflictAnswer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "<name> already exists." Replace, Keep both or Skip, and with more than
|
||||
* one conflict left, "Do this for all n conflicts" (pim-10). Closing it
|
||||
* (Escape, Cancel) calls off the whole upload.
|
||||
*/
|
||||
export function UploadConflictDialog({ question }: { question: UploadConflictQuestion | null }) {
|
||||
const t = useTranslations("ux_pim_files");
|
||||
const tConfirm = useTranslations("confirm_dialog");
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const checkboxId = useId();
|
||||
const replaceRef = useRef<HTMLButtonElement>(null);
|
||||
// Each question starts unticked.
|
||||
useEffect(() => setApplyToAll(false), [question]);
|
||||
if (!question) return null;
|
||||
const { conflict, remaining, answer } = question;
|
||||
const choose = (choice: UploadConflictChoice) => answer({ choice, applyToAll: applyToAll && remaining > 1 });
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => answer(null)}
|
||||
role="alertdialog"
|
||||
title={t("conflict_title", { name: conflict.name })}
|
||||
description={conflict.kind === "folder" ? t("conflict_folder_hint") : t("conflict_file_hint")}
|
||||
showClose={false}
|
||||
initialFocus={replaceRef}
|
||||
testId="upload-conflict-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => answer(null)} className="sm:me-auto">{tConfirm("cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => choose("skip")}>{t("skip")}</Button>
|
||||
<Button variant="outline" onClick={() => choose("keep")}>{t("keep_both")}</Button>
|
||||
<Button ref={replaceRef} onClick={() => choose("replace")}>{t("replace")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{remaining > 1 && (
|
||||
<label htmlFor={checkboxId} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
id={checkboxId}
|
||||
type="checkbox"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t("apply_to_all", { count: remaining })}
|
||||
</label>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +164,7 @@ export {
|
||||
IconMapPin as MapPin,
|
||||
IconMaximize as Maximize2,
|
||||
IconMenu2 as Menu,
|
||||
IconArrowMerge as Merge,
|
||||
IconMessage as MessageSquare,
|
||||
IconMessages as MessagesSquare,
|
||||
IconMinimize as Minimize2,
|
||||
|
||||
@@ -47,15 +47,25 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
// The reminders open from the calendar's "Edit…" form (pim-20).
|
||||
function openReminders(index = 0) {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'edit_calendar' })[index]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'default_alerts_open' }));
|
||||
}
|
||||
|
||||
describe('Calendar default reminders settings', () => {
|
||||
it('offers default reminders only for calendars the user owns', () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'edit_calendar' })[0]);
|
||||
expect(screen.getByRole('button', { name: 'default_alerts_open' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('cancel'));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'edit_calendar' })[1]);
|
||||
expect(screen.queryByRole('button', { name: 'default_alerts_open' })).toBeNull();
|
||||
});
|
||||
|
||||
it('saves both lists with their actions and keeps alerts it cannot show', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByLabelText('default_alerts'));
|
||||
openReminders();
|
||||
|
||||
// Timed list first, then all-day.
|
||||
const [addTimed, addAllDay] = screen.getAllByText('alerts.add');
|
||||
@@ -89,7 +99,7 @@ describe('Calendar default reminders settings', () => {
|
||||
],
|
||||
} as never);
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getAllByLabelText('default_alerts')[0]);
|
||||
openReminders(0);
|
||||
|
||||
const [addTimed, addAllDay] = screen.getAllByText('alerts.add');
|
||||
fireEvent.click(addTimed);
|
||||
@@ -115,7 +125,7 @@ describe('Calendar default reminders settings', () => {
|
||||
})],
|
||||
} as never);
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByLabelText('default_alerts'));
|
||||
openReminders();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('alerts.remove'));
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
|
||||
@@ -33,7 +33,8 @@ describe('CalendarManagementSettings rename', () => {
|
||||
|
||||
function openEditor() {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByTitle('edit'));
|
||||
// One visible "Edit…" per row (pim-20).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit_calendar' }));
|
||||
}
|
||||
|
||||
it('sends the new name without the colour it did not change, for Stalwart 1.0', async () => {
|
||||
@@ -73,6 +74,25 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
|
||||
const url = await screen.findByText('https://host.example/stalwart/.well-known/caldav');
|
||||
// Left to right in Arabic too: the start of the address stays in view.
|
||||
expect(url).toHaveAttribute('dir', 'ltr');
|
||||
// Once for the account, not under each calendar (pim-26).
|
||||
expect(screen.getAllByText('https://host.example/stalwart/.well-known/caldav')).toHaveLength(1);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// pim-26: the account's address repeated under every calendar.
|
||||
it('shows the account address once, not under each calendar', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 502 }));
|
||||
useAuthStore.setState({
|
||||
client: { getAccountId: () => 'acct-1' }, serverUrl: 'https://host.example', username: 'alice@example.com',
|
||||
} as never);
|
||||
const second = { ...appleColored, id: 'cal-2', name: 'Work', isDefault: false } as Calendar;
|
||||
useCalendarStore.setState({ calendars: [appleColored, second], icalSubscriptions: [], updateCalendar: vi.fn() } as never);
|
||||
|
||||
render(<CalendarManagementSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('calendar-use-in-other-apps'));
|
||||
await screen.findByText('https://host.example/.well-known/caldav');
|
||||
expect(screen.getAllByText('https://host.example/.well-known/caldav')).toHaveLength(1);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -88,9 +108,10 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Phone: seven icons in a row hid the calendar names. The row actions are
|
||||
// also in a ⋮ menu (right-click too), the same list as the icons, and every
|
||||
// icon is a named button with the touch hit area.
|
||||
// Seven icons on hover hid what they did and pushed the names aside on a
|
||||
// phone (pim-20). Each row shows "Edit…" (name, colour, then reminders and
|
||||
// sharing) and a ⋮ (right-click too) with the order and the destructive
|
||||
// actions.
|
||||
describe('CalendarManagementSettings row actions', () => {
|
||||
const work = {
|
||||
id: 'work', name: 'Work', color: '#ff0000', isDefault: false, isShared: false, shareWith: null, sortOrder: 1,
|
||||
@@ -111,9 +132,9 @@ describe('CalendarManagementSettings row actions', () => {
|
||||
fireEvent.click(more[1]); // Work, not the default calendar
|
||||
const menu = await screen.findByRole('menu');
|
||||
const items = Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent);
|
||||
expect(items).toEqual(['edit', 'default_alerts', 'share', 'clear_events', 'move_up', 'move_down', 'delete']);
|
||||
expect(items).toEqual(['edit_calendar', 'move_up', 'move_down', 'clear_all_events', 'delete_calendar']);
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'delete' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'delete_calendar' }));
|
||||
expect(await screen.findByRole('alertdialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -121,13 +142,22 @@ describe('CalendarManagementSettings row actions', () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'row_actions' })[0]);
|
||||
const menu = await screen.findByRole('menu');
|
||||
expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent)).not.toContain('delete');
|
||||
expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent)).not.toContain('delete_calendar');
|
||||
});
|
||||
|
||||
it('names every row icon', () => {
|
||||
it('shows one Edit button per row and no arrows or hover icons', () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
for (const name of ['move_up', 'move_down', 'edit', 'default_alerts', 'share', 'clear_events', 'delete', 'change_color']) {
|
||||
expect(screen.getAllByRole('button', { name }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: 'edit_calendar' })).toHaveLength(2);
|
||||
for (const name of ['move_up', 'move_down', 'edit', 'default_alerts', 'share', 'clear_events', 'delete']) {
|
||||
expect(screen.queryAllByRole('button', { name })).toHaveLength(0);
|
||||
}
|
||||
expect(screen.getAllByRole('button', { name: 'change_color' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens reminders and sharing from the Edit form', () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'edit_calendar' })[1]);
|
||||
expect(screen.getByRole('button', { name: 'default_alerts_open' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'share_open' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,29 +40,37 @@ beforeEach(() => {
|
||||
const row = (name: string) => screen.getAllByTestId('calendar-settings-item').find((el) => el.dataset.calendarName === name)!;
|
||||
const buttonIn = (name: string, label: string) =>
|
||||
Array.from(row(name).querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === label)!;
|
||||
// Up and down left the row for its ⋮ menu (pim-20): the drag handle stays.
|
||||
async function menuItemFor(name: string, label: string) {
|
||||
fireEvent.click(buttonIn(name, 'row_actions'));
|
||||
const menu = await screen.findByRole('menu');
|
||||
return Array.from(menu.querySelectorAll('[role="menuitem"]')).find((el) => el.textContent === label) as HTMLElement | undefined;
|
||||
}
|
||||
|
||||
describe('Calendar order in settings', () => {
|
||||
it('moves a calendar up past its neighbour', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(buttonIn('Holidays', 'move_up'));
|
||||
fireEvent.click((await menuItemFor('Holidays', 'move_up'))!);
|
||||
await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['work', 'holidays', 'home']));
|
||||
});
|
||||
|
||||
it('moves a calendar down past its neighbour', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(buttonIn('Work', 'move_down'));
|
||||
fireEvent.click((await menuItemFor('Work', 'move_down'))!);
|
||||
await waitFor(() => expect(reorderCalendars).toHaveBeenCalledWith(client, ['home', 'work', 'holidays']));
|
||||
});
|
||||
|
||||
it('disables moving the first calendar up and the last one down', () => {
|
||||
it('disables moving the first calendar up and the last one down', async () => {
|
||||
const { unmount } = render(<CalendarManagementSettings />);
|
||||
expect(await menuItemFor('Work', 'move_up')).toBeDisabled();
|
||||
unmount();
|
||||
render(<CalendarManagementSettings />);
|
||||
expect(buttonIn('Work', 'move_up')).toBeDisabled();
|
||||
expect(buttonIn('Holidays', 'move_down')).toBeDisabled();
|
||||
expect(await menuItemFor('Holidays', 'move_down')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers no reordering for a calendar shared with the user', () => {
|
||||
it('offers no reordering for a calendar shared with the user', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
expect(buttonIn('Team', 'move_up')).toBeUndefined();
|
||||
expect(await menuItemFor('Team', 'move_up')).toBeUndefined();
|
||||
expect(row('Team')).not.toHaveAttribute('draggable', 'true');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { FreeBusyShareSetting } from '../free-busy-share-setting';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// pim-26: the button named one address ("Share my free/busy with
|
||||
// dso-ux-pim-b@example.org") while the text spoke of "colleagues in the
|
||||
// group". The text no longer says group.
|
||||
|
||||
vi.mock('@/components/calendar/free-busy-share-button', () => ({ FreeBusyShareButton: () => null }));
|
||||
|
||||
describe('Free/busy setting wording (pim-26)', () => {
|
||||
it('describes sharing without calling the address a group', () => {
|
||||
useAuthStore.setState({
|
||||
client: {
|
||||
supportsCalendars: () => true,
|
||||
getSharedAccounts: () => [{ id: 'b', name: 'b@example.org', isPrimary: false }],
|
||||
getAccountInfo: () => ({ isPersonal: false }),
|
||||
},
|
||||
} as never);
|
||||
render(<FreeBusyShareSetting />);
|
||||
expect(screen.getByText('freebusy_description')).toBeInTheDocument();
|
||||
expect(screen.queryByText('description')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,9 @@ describe('deleting a calendar and clearing its events', () => {
|
||||
|
||||
it('confirms a delete in a dialog, not an inline strip', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByTitle('delete'));
|
||||
// Delete and clear live in the row's ⋮ since pim-20.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'row_actions' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'delete_calendar' }));
|
||||
expect(confirmDialog().getByText('delete_title')).toBeTruthy();
|
||||
fireEvent.click(confirmDialog().getByRole('button', { name: 'delete' }));
|
||||
await waitFor(() => expect(removeCalendar).toHaveBeenCalledWith(expect.anything(), 'work'));
|
||||
@@ -100,7 +102,8 @@ describe('deleting a calendar and clearing its events', () => {
|
||||
|
||||
it('confirms clearing the events in a dialog', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByTitle('clear_events'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'row_actions' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'clear_all_events' }));
|
||||
expect(confirmDialog().getByText('clear_title')).toBeTruthy();
|
||||
fireEvent.click(confirmDialog().getByRole('button', { name: 'clear_events' }));
|
||||
await waitFor(() => expect(clearCalendarEvents).toHaveBeenCalledWith(expect.anything(), 'work'));
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('server default names', () => {
|
||||
expect(screen.getByText('default_calendar')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stalwart Calendar/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTitle('edit'));
|
||||
fireEvent.click(screen.getByText('edit_calendar'));
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
await vi.waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
|
||||
expect(updateCalendar.mock.calls[0][2]).toEqual({ name: 'Stalwart Calendar (me@example.org)' });
|
||||
|
||||
@@ -50,7 +50,12 @@ describe('settings row actions on touch and keyboard', () => {
|
||||
icalSubscriptions: [],
|
||||
} as never);
|
||||
render(<CalendarManagementSettings />);
|
||||
expectReachable(screen.getByTitle('edit'));
|
||||
// No hover icons since pim-20: Edit… and the ⋮ are always shown.
|
||||
for (const name of ['edit_calendar', 'row_actions']) {
|
||||
const button = screen.getByRole('button', { name });
|
||||
expect(button.className).not.toContain('opacity-0');
|
||||
expect((button.parentElement as HTMLElement).className).not.toContain('opacity-0');
|
||||
}
|
||||
});
|
||||
|
||||
it('address books', () => {
|
||||
|
||||
@@ -107,11 +107,14 @@ export function CalendarEditForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading,
|
||||
extraActions,
|
||||
}: {
|
||||
initial?: { name: string; color: string };
|
||||
onSave: (data: { name: string; color: string; components?: CalendarComponentType[] }) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
/** More of the calendar's settings, under the colour (pim-20). */
|
||||
extraActions?: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('calendar.management');
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
@@ -160,6 +163,8 @@ export function CalendarEditForm({
|
||||
<CalendarKindPicker value={kind} onChange={setKind} disabled={isLoading} />
|
||||
)}
|
||||
|
||||
{extraActions && <div className="flex flex-wrap items-center gap-2">{extraActions}</div>}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => isValid && submit()}
|
||||
@@ -271,6 +276,7 @@ export function CalendarDefaultAlertsForm({
|
||||
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const tUx = useTranslations('ux_pim_calendar');
|
||||
const tWording = useTranslations('uxs_wording');
|
||||
const calName = (cal: { name: string }) => collectionDisplayName(cal.name, 'calendar', tWording('default_calendar'));
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
@@ -294,9 +300,9 @@ export function CalendarManagementSettings() {
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
// The calendar whose CalDAV address is open under "Use in other apps".
|
||||
const [shownUrlId, setShownUrlId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
// The account's CalDAV addresses wait behind "Use in other apps".
|
||||
const [caldavShown, setCaldavShown] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
@@ -570,6 +576,16 @@ export function CalendarManagementSettings() {
|
||||
return discoveredCalDavUrls[accountKey] || wellKnownCalDavUrl;
|
||||
};
|
||||
|
||||
// The CalDAV address is the account's, the same for each of its
|
||||
// calendars: once per account, not under every calendar (pim-26).
|
||||
const caldavAddresses: Array<{ url: string; owner: string | null }> = [];
|
||||
for (const cal of listedCalendars) {
|
||||
const url = buildCalDavUrl(cal.id);
|
||||
if (!url || caldavAddresses.some((a) => a.url === url)) continue;
|
||||
caldavAddresses.push({ url, owner: cal.isShared ? (cal.accountName || cal.accountId || null) : null });
|
||||
}
|
||||
caldavAddresses.sort((a, b) => Number(!!a.owner) - Number(!!b.owner));
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
@@ -588,22 +604,17 @@ export function CalendarManagementSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// One action list per calendar (R6): the row's icons on wider screens, the
|
||||
// ⋮ and right-click menu everywhere.
|
||||
// Each row shows one "Edit…" (name, colour, then reminders and sharing
|
||||
// from there) and a ⋮ with the rest; right-click opens the same menu.
|
||||
// Seven icons on hover hid what they did (pim-20).
|
||||
const canEditAlerts = (cal: Calendar) => !cal.isShared;
|
||||
const canShareCalendar = (cal: Calendar) => !!cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id);
|
||||
const calendarActions = (cal: Calendar) => {
|
||||
const actions: Array<{ id: string; label: string; icon: typeof Pencil; run: () => void; destructive?: boolean }> = [
|
||||
{ id: 'edit', label: t('edit'), icon: Pencil, run: () => setEditingId(cal.id) },
|
||||
{ id: 'clear', label: tUx('clear_all_events'), icon: Eraser, run: () => setClearingId(cal.id), destructive: true },
|
||||
];
|
||||
// The owner's defaults; a sharee can't write the calendar.
|
||||
if (!cal.isShared) {
|
||||
actions.push({ id: 'alerts', label: t('default_alerts'), icon: Bell, run: () => setAlertsEditingId(cal.id) });
|
||||
}
|
||||
if (cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id)) {
|
||||
actions.push({ id: 'share', label: t('share'), icon: Users, run: () => setSharingId(cal.id) });
|
||||
}
|
||||
actions.push({ id: 'clear', label: t('clear_events'), icon: Eraser, run: () => setClearingId(cal.id) });
|
||||
if (!cal.isDefault) {
|
||||
actions.push({ id: 'delete', label: t('delete'), icon: Trash2, run: () => setDeletingId(cal.id), destructive: true });
|
||||
actions.push({ id: 'delete', label: tUx('delete_calendar'), icon: Trash2, run: () => setDeletingId(cal.id), destructive: true });
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
@@ -616,18 +627,11 @@ export function CalendarManagementSettings() {
|
||||
const orderIndex = siblings.indexOf(cal);
|
||||
const previousId = siblings[orderIndex - 1]?.id;
|
||||
const nextId = siblings[orderIndex + 1]?.id;
|
||||
const buildUrl = buildCalDavUrl(cal.id);
|
||||
const actions = calendarActions(cal);
|
||||
const plain = actions.filter((a) => !a.destructive);
|
||||
const destructive = actions.filter((a) => a.destructive);
|
||||
const destructive = calendarActions(cal);
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
{plain.map((a) => (
|
||||
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} onClick={run(a.run)} />
|
||||
))}
|
||||
{buildUrl && (
|
||||
<ContextMenuItem icon={Copy} label={t('copy_url')} onClick={run(() => handleCopyUrl(buildUrl))} />
|
||||
)}
|
||||
<ContextMenuItem icon={Pencil} label={tUx('edit_calendar')} onClick={run(() => setEditingId(cal.id))} />
|
||||
{/* Without a mouse there is no drag: the order changes from here. */}
|
||||
{siblings.length > 1 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
@@ -635,9 +639,9 @@ export function CalendarManagementSettings() {
|
||||
<ContextMenuItem icon={ArrowDown} label={t('move_down')} disabled={!nextId} onClick={run(() => void handleMove(cal.id, nextId))} />
|
||||
</>
|
||||
)}
|
||||
{destructive.length > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuSeparator />
|
||||
{destructive.map((a) => (
|
||||
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} destructive onClick={run(a.run)} />
|
||||
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} destructive={a.id === 'delete'} onClick={run(a.run)} />
|
||||
))}
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -651,12 +655,11 @@ export function CalendarManagementSettings() {
|
||||
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
|
||||
// Up and down step over the neighbours of the same account.
|
||||
const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal));
|
||||
const orderIndex = siblings.indexOf(cal);
|
||||
const isOrderable = siblings.length > 1;
|
||||
const previousId = siblings[orderIndex - 1]?.id;
|
||||
const nextId = siblings[orderIndex + 1]?.id;
|
||||
|
||||
if (editingId === cal.id) {
|
||||
const alerts = canEditAlerts(cal);
|
||||
const share = canShareCalendar(cal);
|
||||
return (
|
||||
<CalendarEditForm
|
||||
key={cal.id}
|
||||
@@ -665,6 +668,30 @@ export function CalendarManagementSettings() {
|
||||
onSave={(data) => handleUpdate(cal.id, { ...data, name: data.name === calName(cal) ? cal.name : data.name }, color)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
extraActions={alerts || share ? (
|
||||
<>
|
||||
{alerts && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingId(null); setAlertsEditingId(cal.id); }}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md border border-border hover:bg-muted"
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{tUx('default_alerts_open')}
|
||||
</button>
|
||||
)}
|
||||
{share && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingId(cal.id)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md border border-border hover:bg-muted"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{tUx('share_open')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -707,7 +734,7 @@ export function CalendarManagementSettings() {
|
||||
)}
|
||||
>
|
||||
{isOrderable && (
|
||||
// No HTML drag and drop on touch screens: the arrows do it there.
|
||||
// No HTML drag and drop on touch screens: the ⋮ moves it there.
|
||||
<GripVertical className="w-4 h-4 -me-1.5 text-muted-foreground/60 cursor-grab flex-shrink-0 pointer-coarse:hidden" aria-hidden="true" />
|
||||
)}
|
||||
{/* Color swatch - clickable to change color */}
|
||||
@@ -742,44 +769,7 @@ export function CalendarManagementSettings() {
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block" title={calName(cal)} dir="auto">{calName(cal)}</span>
|
||||
{(() => {
|
||||
const caldavUrl = buildCalDavUrl(cal.id);
|
||||
if (!caldavUrl) return null;
|
||||
if (shownUrlId !== cal.id) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={false}
|
||||
data-testid="calendar-use-in-other-apps"
|
||||
onClick={(e) => { e.stopPropagation(); setShownUrlId(cal.id); }}
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
|
||||
{tWording('use_in_other_apps')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
{/* An address reads left to right in Arabic too, cut at its end. */}
|
||||
<span dir="ltr" className="text-xs text-muted-foreground truncate" title={caldavUrl}>
|
||||
{caldavUrl}
|
||||
</span>
|
||||
<IconButton
|
||||
label={t('copy_url')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyUrl(caldavUrl);
|
||||
}}
|
||||
className="-my-1.5 hidden sm:inline-flex flex-shrink-0 text-muted-foreground"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<span id={`calendar-name-${cal.id}`} className="text-sm font-medium truncate block" title={calName(cal)} dir="auto">{calName(cal)}</span>
|
||||
</div>
|
||||
|
||||
{cal.isDefault && (
|
||||
@@ -808,45 +798,24 @@ export function CalendarManagementSettings() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Phone: seven icons next to the name left it a letter or two
|
||||
and pushed the Default badge over the copy button, so the
|
||||
row actions sit behind a ⋮ there, as for folders. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(cal.id)}
|
||||
aria-describedby={`calendar-name-${cal.id}`}
|
||||
className={cn(
|
||||
"flex-shrink-0 px-2.5 py-1 text-xs rounded-md border border-border text-foreground hover:bg-muted",
|
||||
TOUCH_HIT_AREA,
|
||||
)}
|
||||
>
|
||||
{tUx('edit_calendar')}
|
||||
</button>
|
||||
<IconButton
|
||||
label={t('row_actions', { name: calName(cal) })}
|
||||
className="sm:hidden flex-shrink-0 text-muted-foreground"
|
||||
className="flex-shrink-0 text-muted-foreground"
|
||||
onClick={(e) => openContextMenuFrom(e.currentTarget, cal, 'end')}
|
||||
>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
|
||||
{/* Shown for touch too: there is no hover, and no HTML drag and drop. */}
|
||||
{isOrderable && (
|
||||
<div className="hidden sm:flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
|
||||
<IconButton label={t('move_up')} onClick={() => handleMove(cal.id, previousId)} disabled={!previousId} className="text-muted-foreground">
|
||||
<ArrowUp />
|
||||
</IconButton>
|
||||
<IconButton label={t('move_down')} onClick={() => handleMove(cal.id, nextId)} disabled={!nextId} className="text-muted-foreground">
|
||||
<ArrowDown />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden sm:flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
|
||||
{calendarActions(cal).map((action) => (
|
||||
<IconButton
|
||||
key={action.id}
|
||||
label={action.label}
|
||||
onClick={action.run}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
action.destructive && "hover:text-destructive hover:bg-destructive/10",
|
||||
action.id === 'clear' && "hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<action.icon />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -895,6 +864,40 @@ export function CalendarManagementSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{caldavAddresses.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5" data-testid="caldav-addresses">
|
||||
<h4 className="text-sm font-medium text-foreground">{tUx('caldav_title')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{tUx('caldav_hint')}</p>
|
||||
{!caldavShown && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={false}
|
||||
data-testid="calendar-use-in-other-apps"
|
||||
onClick={() => setCaldavShown(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
|
||||
{tWording('use_in_other_apps')}
|
||||
</button>
|
||||
)}
|
||||
{caldavShown && caldavAddresses.map(({ url, owner }) => (
|
||||
<div key={url} className="flex items-center gap-1.5 min-w-0">
|
||||
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" aria-hidden="true" />
|
||||
{owner && <span className="text-xs text-muted-foreground flex-shrink-0">{tUx('caldav_shared_by', { name: owner })}</span>}
|
||||
{/* An address reads left to right in Arabic too, cut at its end. */}
|
||||
<span dir="ltr" className="text-xs text-muted-foreground truncate" title={url}>{url}</span>
|
||||
<IconButton
|
||||
label={t('copy_url')}
|
||||
onClick={() => handleCopyUrl(url)}
|
||||
className="-my-1.5 flex-shrink-0 text-muted-foreground"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* iCal Subscriptions */}
|
||||
{icalSubscriptions.length > 0 && (
|
||||
<div className="mt-6 space-y-2">
|
||||
|
||||
@@ -15,12 +15,15 @@ import { freeBusyShareGroups } from '@/lib/free-busy-share';
|
||||
*/
|
||||
export function FreeBusyShareSetting() {
|
||||
const t = useTranslations('free_busy_share');
|
||||
const tUx = useTranslations('ux_pim_calendar');
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const adminGroup = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? '');
|
||||
const groups = useMemo(() => freeBusyShareGroups(client, adminGroup), [client, adminGroup]);
|
||||
if (!client?.supportsCalendars() || groups.length === 0) return null;
|
||||
return (
|
||||
<SettingItem label={t('title')} description={t('description')}>
|
||||
// The button names whom it shares with, which may be one person: the
|
||||
// text doesn't say "group" (pim-26).
|
||||
<SettingItem label={t('title')} description={tUx('freebusy_description')}>
|
||||
<FreeBusyShareButton />
|
||||
</SettingItem>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// pim-13: the German week title read "2. Nov. – 8, 2026", an English pattern
|
||||
// with German names. Every range now comes from Intl's formatRange.
|
||||
|
||||
let locale = 'en';
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => locale,
|
||||
}));
|
||||
|
||||
import { useCalendarLocale } from '../use-calendar-locale';
|
||||
import { formatDateRange } from '@/lib/format-date-range';
|
||||
|
||||
// Intl puts thin and narrow no-break spaces around the dash.
|
||||
const plain = (text: string) => text.replace(/\s/g, ' ');
|
||||
|
||||
function hookIn(code: string) {
|
||||
locale = code;
|
||||
const hook = renderHook(() => useCalendarLocale()).result.current;
|
||||
return {
|
||||
formatWeekRange: (d: Date) => plain(hook.formatWeekRange(d)),
|
||||
formatWeekRangeShort: (d: Date) => plain(hook.formatWeekRangeShort(d)),
|
||||
formatMonthYear: (d: Date) => plain(hook.formatMonthYear(d)),
|
||||
formatMonthYearShort: (d: Date) => plain(hook.formatMonthYearShort(d)),
|
||||
formatDateRangeLabel: (a: Date, b: Date, o: Intl.DateTimeFormatOptions) => plain(hook.formatDateRangeLabel(a, b, o)),
|
||||
};
|
||||
}
|
||||
|
||||
const sameMonth = new Date(2026, 10, 2); // Mon 2 Nov 2026 .. Sun 8 Nov
|
||||
const acrossMonths = new Date(2026, 9, 26); // 26 Oct .. 1 Nov
|
||||
const acrossYears = new Date(2026, 11, 28); // 28 Dec 2026 .. 3 Jan 2027
|
||||
|
||||
describe('week title (pim-13)', () => {
|
||||
it('writes the German range the German way', () => {
|
||||
const { formatWeekRange, formatWeekRangeShort } = hookIn('de');
|
||||
expect(formatWeekRange(sameMonth)).toBe('2.–8. Nov. 2026');
|
||||
expect(formatWeekRange(acrossMonths)).toBe('26. Okt. – 1. Nov. 2026');
|
||||
expect(formatWeekRange(acrossYears)).toBe('28. Dez. 2026 – 3. Jan. 2027');
|
||||
expect(formatWeekRangeShort(sameMonth)).toBe('2.–8. Nov.');
|
||||
});
|
||||
|
||||
it('keeps the English title', () => {
|
||||
const { formatWeekRange, formatWeekRangeShort } = hookIn('en');
|
||||
expect(formatWeekRange(sameMonth)).toBe('Nov 2 – 8, 2026');
|
||||
expect(formatWeekRange(acrossMonths)).toBe('Oct 26 – Nov 1, 2026');
|
||||
expect(formatWeekRange(acrossYears)).toBe('Dec 28, 2026 – Jan 3, 2027');
|
||||
expect(formatWeekRangeShort(acrossMonths)).toBe('Oct 26 – Nov 1');
|
||||
});
|
||||
|
||||
it('follows French, Japanese and Arabic', () => {
|
||||
expect(hookIn('fr').formatWeekRange(sameMonth)).toBe('2–8 nov. 2026');
|
||||
expect(hookIn('fr').formatWeekRange(acrossYears)).toBe('28 déc. 2026 – 3 janv. 2027');
|
||||
// Japanese writes the year first and never "2, 2026".
|
||||
const ja = hookIn('ja').formatWeekRange(sameMonth);
|
||||
expect(ja.startsWith('2026')).toBe(true);
|
||||
expect(ja).toBe(plain(formatDateRange('ja', sameMonth, new Date(2026, 10, 8), { day: 'numeric', month: 'short', year: 'numeric' })));
|
||||
expect(hookIn('ar').formatWeekRange(acrossMonths)).toBe(
|
||||
plain(formatDateRange('ar', acrossMonths, new Date(2026, 10, 1), { day: 'numeric', month: 'short', year: 'numeric' })),
|
||||
);
|
||||
expect(hookIn('ar').formatWeekRange(acrossMonths)).not.toContain(',');
|
||||
});
|
||||
|
||||
it('puts month and year in the language order', () => {
|
||||
expect(hookIn('ja').formatMonthYear(sameMonth)).toBe('2026年11月');
|
||||
expect(hookIn('de').formatMonthYear(sameMonth)).toBe('November 2026');
|
||||
expect(hookIn('en').formatMonthYearShort(sameMonth)).toBe('Nov 2026');
|
||||
});
|
||||
|
||||
it('formats a multi-day range once', () => {
|
||||
const { formatDateRangeLabel } = hookIn('de');
|
||||
const label = formatDateRangeLabel(new Date(2026, 10, 2), new Date(2026, 10, 4), {
|
||||
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
|
||||
});
|
||||
expect(label).toBe('Mo., 2. – Mi., 4. Nov. 2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateRange', () => {
|
||||
it('falls back to two dates without formatRange', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.formatRange;
|
||||
// @ts-expect-error simulate an engine without formatRange
|
||||
delete Intl.DateTimeFormat.prototype.formatRange;
|
||||
try {
|
||||
expect(plain(formatDateRange('en-US', sameMonth, new Date(2026, 10, 8), { day: 'numeric', month: 'short' }))).toBe('Nov 2 – Nov 8');
|
||||
} finally {
|
||||
Intl.DateTimeFormat.prototype.formatRange = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isSameMonth,
|
||||
} from "date-fns";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import { formatDateRange, intlLocaleOf } from "@/lib/format-date-range";
|
||||
|
||||
/**
|
||||
* Unified calendar-locale hook.
|
||||
@@ -119,10 +120,8 @@ export function useCalendarLocale() {
|
||||
const { jy, jm } = toJalali(date);
|
||||
return `${JALALI_MONTHS[jm - 1]} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "long",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
// The language's order: "2026年11月" in Japanese, not "11月 2026".
|
||||
return date.toLocaleString(intlLocaleOf(locale), { month: "long", year: "numeric" });
|
||||
};
|
||||
|
||||
/** Short month + year for mobile. */
|
||||
@@ -132,10 +131,7 @@ export function useCalendarLocale() {
|
||||
const short = JALALI_MONTHS[jm - 1].slice(0, 3);
|
||||
return `${short} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
return date.toLocaleString(intlLocaleOf(locale), { month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
/** Week range label (e.g. "6 – 12 Farvardin 1404"). */
|
||||
@@ -150,16 +146,12 @@ export function useCalendarLocale() {
|
||||
}
|
||||
return `${start.jd} ${JALALI_MONTHS[start.jm - 1]} – ${end.jd} ${JALALI_MONTHS[end.jm - 1]} ${end.jy}`;
|
||||
}
|
||||
const sameMonth = weekStart.getMonth() === weekEnd.getMonth();
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
// The language's own range (pim-13): "2.–8. Nov. 2026", not "2. Nov. – 8, 2026".
|
||||
return formatDateRange(intlLocaleOf(locale), weekStart, weekEnd, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: sameMonth ? undefined : "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}, ${weekEnd.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Short week range for mobile. */
|
||||
@@ -171,16 +163,19 @@ export function useCalendarLocale() {
|
||||
const end = toJalali(weekEnd);
|
||||
return `${start.jd}/${start.jm} – ${end.jd}/${end.jm}`;
|
||||
}
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
return formatDateRange(intlLocaleOf(locale), weekStart, weekEnd, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any date range in the language's own pattern (pim-13), e.g. a multi-day
|
||||
* event: Intl's formatRange, in the Persian calendar for Jalali.
|
||||
*/
|
||||
const formatDateRangeLabel = (start: Date, end: Date, options: Intl.DateTimeFormatOptions): string =>
|
||||
formatDateRange(intlLocaleOf(locale, { persianCalendar: isJalali }), start, end, options);
|
||||
|
||||
/** Full date label for accessibility / tooltips. */
|
||||
const formatFullDate = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
@@ -258,6 +253,7 @@ export function useCalendarLocale() {
|
||||
formatMonthYearShort,
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatDateRangeLabel,
|
||||
formatFullDate,
|
||||
formatDayShort,
|
||||
getMonth,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { collectUserCalendarAddresses } from "@/lib/calendar-participants";
|
||||
|
||||
/**
|
||||
* The user's addresses for one event. With several logins in the calendar
|
||||
* (pim-12), an event of another login is answered and edited as that login,
|
||||
* so its participant is found by that login's address, not the active
|
||||
* login's ones (`activeEmails`).
|
||||
*/
|
||||
export function eventUserEmails(
|
||||
event: { localAccountId?: string } | null | undefined,
|
||||
activeEmails: string[],
|
||||
activeLocalAccountId: string | null,
|
||||
accounts: ReadonlyArray<{ id: string; email?: string; username?: string }>,
|
||||
): string[] {
|
||||
const owner = event?.localAccountId;
|
||||
if (!owner || owner === activeLocalAccountId) return activeEmails;
|
||||
const account = accounts.find((a) => a.id === owner);
|
||||
if (!account) return activeEmails;
|
||||
const own = collectUserCalendarAddresses([account.email, account.username?.includes("@") ? account.username : null]);
|
||||
return own.length > 0 ? own : activeEmails;
|
||||
}
|
||||
|
||||
export function useEventUserEmails(activeEmails: string[]): (event: { localAccountId?: string } | null | undefined) => string[] {
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
return useCallback(
|
||||
(event) => eventUserEmails(event, activeEmails, activeLocalAccountId, accounts),
|
||||
[activeEmails, activeLocalAccountId, accounts],
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
|
||||
/**
|
||||
* When the Pro shell is the active interface, aggregate calendars from
|
||||
* every connected account so the calendar sidebar lists them all - the
|
||||
* same way [[use-pro-multi-account-mailboxes]] does for mail folders.
|
||||
* Aggregate calendars from every connected account so the calendar sidebar
|
||||
* lists them all, grouped by account, and the grid shows the ticked ones
|
||||
* together: in the Pro shell (as [[use-pro-multi-account-mailboxes]] does
|
||||
* for mail folders), and in the standard interface once two or more
|
||||
* logins with calendars are signed in (pim-12).
|
||||
*
|
||||
* Returns the resolved list of `{ localAccountId, client }` pairs so the
|
||||
* caller (calendar page) can fetch events the same way without
|
||||
@@ -27,10 +30,7 @@ export function useProMultiAccountCalendars(start: string | null, end: string |
|
||||
const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
const enabled = proInterface || isEmbedded;
|
||||
|
||||
const accountClients = useMemo(() => {
|
||||
if (!enabled) return [];
|
||||
const connected = useMemo(() => {
|
||||
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||
const pairs: CalendarAccountClient[] = [];
|
||||
for (const account of accounts) {
|
||||
@@ -42,7 +42,29 @@ export function useProMultiAccountCalendars(start: string | null, end: string |
|
||||
return pairs;
|
||||
// accounts identity changes whenever the connected set or login states
|
||||
// change, so this is the only dependency we need.
|
||||
}, [enabled, accounts]);
|
||||
}, [accounts]);
|
||||
|
||||
const enabled = proInterface || isEmbedded || connected.length > 1;
|
||||
const accountClients = useMemo(() => (enabled ? connected : []), [enabled, connected]);
|
||||
|
||||
// The stores route their own loads (a push, a refresh) and each task
|
||||
// change through the logins while several are shown. Going back to one
|
||||
// login reloads it with plain ids.
|
||||
const wasAggregated = useRef(false);
|
||||
useEffect(() => {
|
||||
const aggregated = enabled && accountClients.length > 0;
|
||||
useCalendarStore.getState().setAccountClients(aggregated ? accountClients : null, activeAccountId);
|
||||
useTaskStore.getState().setAccountClients(aggregated ? accountClients : null, useAuthStore.getState().client);
|
||||
if (!aggregated && wasAggregated.current) {
|
||||
const client = useAuthStore.getState().client;
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
if (client) {
|
||||
void calendarStore.fetchCalendars(client);
|
||||
if (calendarStore.dateRange) void calendarStore.fetchEvents(client, calendarStore.dateRange.start, calendarStore.dateRange.end);
|
||||
}
|
||||
}
|
||||
wasAggregated.current = aggregated;
|
||||
}, [enabled, accountClients, activeAccountId]);
|
||||
|
||||
// Fetch calendars whenever the set of connected calendar-capable accounts
|
||||
// changes. Skips when there isn't an active account yet (auth still
|
||||
|
||||
@@ -7,6 +7,7 @@ import { debug } from "@/lib/debug";
|
||||
import { formatIsoInTimeZone, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { fromDisplayDate, getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { movedEventUpdates } from "@/lib/calendar-participants";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
/** Saves a dragged or resized event; `kind` says which, for the message. */
|
||||
@@ -90,6 +91,8 @@ export function useTimeGridInteractions({
|
||||
gridEndMinutes = 1440,
|
||||
onMoveEvent,
|
||||
}: UseTimeGridInteractionsOptions) {
|
||||
// A move of another signed-in login's event: that login's addresses (pim-12).
|
||||
const userEmailsFor = useEventUserEmails(currentUserEmails ?? []);
|
||||
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
||||
const raw = gridStartMinutes + ((clientY - containerTop) / hourHeight) * 60;
|
||||
return Math.max(gridStartMinutes, Math.min(gridEndMinutes, Math.round(raw / 15) * 15));
|
||||
@@ -285,7 +288,7 @@ export function useTimeGridInteractions({
|
||||
if (dur === "PT") dur = "PT0M";
|
||||
const updates: { start?: string; duration: string } = { duration: dur };
|
||||
if (start) updates.start = start;
|
||||
const moved = movedEventUpdates(event, updates, currentUserEmails ?? []);
|
||||
const moved = movedEventUpdates(event, updates, userEmailsFor(event));
|
||||
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "resize");
|
||||
else await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
@@ -294,7 +297,7 @@ export function useTimeGridInteractions({
|
||||
} finally {
|
||||
setResizeVisual(null);
|
||||
}
|
||||
}, [computeResize, errorMessages.resize, describeError, currentUserEmails, onMoveEvent]);
|
||||
}, [computeResize, errorMessages.resize, describeError, userEmailsFor, onMoveEvent]);
|
||||
|
||||
// --- Click / Double-click / Quick-create ---
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -422,13 +425,13 @@ export function useTimeGridInteractions({
|
||||
toast.error(errorMessages.move);
|
||||
return;
|
||||
}
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
|
||||
const moved = movedEventUpdates(event, { start: newStartISO }, userEmailsFor(event));
|
||||
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
|
||||
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
|
||||
} catch (error) {
|
||||
toast.error(describeError ? describeError(error, errorMessages.move) : errorMessages.move);
|
||||
}
|
||||
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails, onMoveEvent]);
|
||||
}, [snapDragMinutes, errorMessages.move, describeError, userEmailsFor, onMoveEvent]);
|
||||
|
||||
return {
|
||||
dragCreate,
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { addDays, format } from 'date-fns';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fixes pim-1, pim-8, pim-12 and pim-24 (cal), against a VM 106
|
||||
* Stalwart (1.0 and 0.16). Every UI step is checked on the server.
|
||||
*
|
||||
* Both Stalwarts keep tasks as CalendarEvent objects of @type "Task" in an
|
||||
* ordinary calendar, with no tasks capability of their own; the seeded
|
||||
* "To-do" list holds only tasks.
|
||||
*
|
||||
* Accounts: dso-ux-pim and dso-ux-pim-b (password IT_DSO_PASSWORD), and
|
||||
* ent-alice (IT_ENT_PASSWORD) as an outside organiser. The spec makes its
|
||||
* own items, titled "UXFIX-cal …", and removes them again.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const ENT_PASSWORD = process.env.IT_ENT_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const B = 'dso-ux-pim-b@example.org';
|
||||
const ALICE = 'ent-alice@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CAL = 'urn:ietf:params:jmap:calendars';
|
||||
const RUN = Date.now().toString(36);
|
||||
const TZ = 'Europe/Berlin';
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Obj = Record<string, unknown> & { id: string; title?: string; calendarIds?: Record<string, boolean>; progress?: string };
|
||||
|
||||
async function calendars(c: JmapClient): Promise<Array<{ id: string; name: string; isDefault?: boolean }>> {
|
||||
const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
/** Calendar objects whose title starts with `prefix`, with the properties the checks need. */
|
||||
async function objects(c: JmapClient, prefix: string): Promise<Obj[]> {
|
||||
const r = await c.request([
|
||||
['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: c.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: ['id', '@type', 'title', 'calendarIds', 'progress', 'due', 'start', 'participants', 'uid'],
|
||||
}, 'g'],
|
||||
], [CORE, CAL]);
|
||||
return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function destroyAll(c: JmapClient, prefix: string): Promise<void> {
|
||||
const ids = (await objects(c, prefix)).map((o) => o.id);
|
||||
if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]);
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await signIn(page, email);
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
async function activeEmail(page: Page): Promise<string | null> {
|
||||
const id = await accountSwitcher(page).getAttribute('data-active-account-id');
|
||||
if (!id) return null;
|
||||
await accountSwitcher(page).click();
|
||||
const email = await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().getAttribute('data-account-email');
|
||||
await page.keyboard.press('Escape');
|
||||
return email;
|
||||
}
|
||||
|
||||
test.describe('Tasks (pim-1, pim-8, pim-24)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const prefix = `UXFIX-cal ${RUN}`;
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await destroyAll(a, prefix);
|
||||
});
|
||||
|
||||
test('tasks show without switching them on, and every move keeps the task on screen and on the server', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const cals = await calendars(a);
|
||||
const todo = cals.find((c) => c.name === 'To-do');
|
||||
const home = cals.find((c) => c.isDefault);
|
||||
expect(todo, 'seeded To-do list').toBeTruthy();
|
||||
expect(home).toBeTruthy();
|
||||
|
||||
// A task in the event calendar, to move into To-do and back.
|
||||
const made = await a.request([['CalendarEvent/set', {
|
||||
accountId: a.accountId,
|
||||
create: { t: { '@type': 'Task', title: `${prefix} move`, calendarIds: { [home!.id]: true }, uid: `uxfix-cal-${RUN}`, progress: 'needs-action' } },
|
||||
}, '0']], [CORE, CAL]);
|
||||
expect(made.methodResponses[0][1].created?.t?.id).toBeTruthy();
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
|
||||
// pim-8: Tasks is in the sidebar without visiting Settings.
|
||||
await page.goto('/calendar');
|
||||
const tasksEntry = page.getByRole('button', { name: /^Tasks/ }).first();
|
||||
await expect(tasksEntry).toBeVisible({ timeout: 60000 });
|
||||
await tasksEntry.click();
|
||||
await expect(page).toHaveURL(/\/calendar\/tasks/);
|
||||
|
||||
// pim-1: the seeded tasks of the task-only list show, under All and Pending.
|
||||
await expect(page.getByText('Renew passport').first()).toBeVisible({ timeout: 60000 });
|
||||
await page.getByRole('button', { name: 'Pending', exact: true }).click();
|
||||
await expect(page.getByText('Renew passport').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'All', exact: true }).click();
|
||||
const lists = page.getByTestId('task-lists-section');
|
||||
await expect(lists.getByRole('checkbox', { name: /To-do/ })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// pim-24 and pim-8: a quick task with "tomorrow" goes to To-do, due tomorrow.
|
||||
const quick = page.getByLabel('Add a task…');
|
||||
await quick.fill(`${prefix} quick tomorrow`);
|
||||
await expect(page.getByTestId('quick-task-due')).toBeVisible();
|
||||
await quick.press('Enter');
|
||||
await expect(page.getByText(`${prefix} quick`, { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
await expect.poll(async () => {
|
||||
const [t] = await objects(a, `${prefix} quick`);
|
||||
return t ? { title: t.title, lists: Object.keys(t.calendarIds ?? {}), due: String(t.due).slice(0, 10), progress: t.progress } : null;
|
||||
}, { timeout: 30000 }).toEqual({
|
||||
title: `${prefix} quick`, lists: [todo!.id], due: format(addDays(new Date(), 1), 'yyyy-MM-dd'), progress: 'needs-action',
|
||||
});
|
||||
|
||||
// Move into To-do: the task stays listed and the server has it there.
|
||||
const moveRow = page.getByText(`${prefix} move`, { exact: true });
|
||||
await expect(moveRow).toBeVisible({ timeout: 30000 });
|
||||
const moveTo = async (calendarName: string, calendarId: string) => {
|
||||
await moveRow.click();
|
||||
const form = page.getByRole('dialog', { name: 'Edit task' });
|
||||
// pim-19: plain status words.
|
||||
await expect(form.getByLabel('Status').locator('option:checked')).toHaveText('Not started');
|
||||
await form.getByLabel('Calendar').selectOption({ label: calendarName });
|
||||
await form.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(form).toBeHidden({ timeout: 30000 });
|
||||
await expect(moveRow).toBeVisible();
|
||||
await expect.poll(async () => {
|
||||
const [t] = await objects(a, `${prefix} move`);
|
||||
return t ? Object.keys(t.calendarIds ?? {}) : null;
|
||||
}, { timeout: 30000 }).toEqual([calendarId]);
|
||||
};
|
||||
await moveTo('To-do', todo!.id);
|
||||
await moveTo(home!.name, home!.id);
|
||||
await moveTo('To-do', todo!.id);
|
||||
|
||||
// Complete it from the list.
|
||||
const row = page.locator('div.cursor-pointer', { has: page.getByText(`${prefix} move`, { exact: true }) });
|
||||
await row.getByRole('button', { name: 'Mark as complete' }).click();
|
||||
await expect.poll(async () => (await objects(a, `${prefix} move`))[0]?.progress, { timeout: 30000 }).toBe('completed');
|
||||
await expect(moveRow).toBeVisible();
|
||||
|
||||
// Unticking the list hides its tasks; ticking it shows them again.
|
||||
await lists.getByRole('checkbox', { name: /To-do/ }).click();
|
||||
await expect(page.getByText('Renew passport')).toHaveCount(0);
|
||||
await lists.getByRole('checkbox', { name: /To-do/ }).click();
|
||||
await expect(page.getByText('Renew passport').first()).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Two signed-in logins in one calendar (pim-12)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const prefix = `UXFIX-cal ${RUN} B`;
|
||||
|
||||
test.afterAll(async () => {
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
await destroyAll(b, prefix);
|
||||
if (ENT_PASSWORD) {
|
||||
const alice = await JmapClient.connect(ALICE, ENT_PASSWORD);
|
||||
await destroyAll(alice, prefix);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows both logins\' calendars and changes the other login\'s events in its own account', async ({ browser }) => {
|
||||
test.setTimeout(300_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
const bCals = await calendars(b);
|
||||
const bHome = bCals.find((c) => c.isDefault)!;
|
||||
const aCals = await calendars(a);
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator('[data-testid="add-account"]').click();
|
||||
await signIn(page, B);
|
||||
await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B);
|
||||
// Back to A: B is the other login now.
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator(`[data-testid="account-option"][data-account-email="${A}"]`).first().click();
|
||||
await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(A);
|
||||
|
||||
await page.goto(`/calendar/month/${format(new Date(), 'yyyy-MM-dd')}`);
|
||||
// Both logins' calendars, each ticked.
|
||||
await expect(page.locator(`[data-testid="calendar-item"][data-calendar-name="${bHome.name}"]`).first()).toBeVisible({ timeout: 60000 });
|
||||
for (const name of [aCals.find((c) => c.name === 'Work')!.name, bHome.name]) {
|
||||
await expect(page.locator(`[data-testid="calendar-item"][data-calendar-name="${name}"]`).first()).toHaveAttribute('aria-checked', 'true');
|
||||
}
|
||||
|
||||
// Each has its own checkbox: hiding A's calendars leaves B's in the grid,
|
||||
// so B's events don't fold under "+n more".
|
||||
const aItems = page.locator(`[data-testid="calendar-item"][data-account="${A}"]`);
|
||||
const aCount = await aItems.count();
|
||||
expect(aCount).toBeGreaterThan(0);
|
||||
for (let i = 0; i < aCount; i++) await aItems.nth(i).click();
|
||||
for (let i = 0; i < aCount; i++) await expect(aItems.nth(i)).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(page.locator(`[data-testid="calendar-item"][data-calendar-name="${bHome.name}"]`).first()).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Create in B's calendar from A's session.
|
||||
await page.locator(`[data-testid="calendar-item"][data-calendar-name="${bHome.name}"]`).first().click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'New event in this calendar' }).click();
|
||||
const title = page.getByLabel('Title', { exact: true });
|
||||
await title.fill(`${prefix} made`);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(async () => (await objects(b, `${prefix} made`)).map((o) => Object.keys(o.calendarIds ?? {})), { timeout: 30000 })
|
||||
.toEqual([[bHome.id]]);
|
||||
expect(await objects(a, prefix)).toEqual([]);
|
||||
|
||||
// Edit it.
|
||||
// Event chips are buttons named "<title>, <time>, <calendar>".
|
||||
const card = (t: string) => page.locator(`button[aria-label^="${t},"]`).first();
|
||||
await card(`${prefix} made`).click();
|
||||
await page.getByRole('button', { name: 'Edit event' }).click();
|
||||
const editTitle = page.getByLabel('Title', { exact: true });
|
||||
await expect(editTitle).toHaveValue(`${prefix} made`);
|
||||
// The editor fills its fields once it has the event: typing before that is lost.
|
||||
await page.waitForTimeout(2000);
|
||||
await editTitle.fill(`${prefix} edited`);
|
||||
await expect(editTitle).toHaveValue(`${prefix} edited`);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(async () => (await objects(b, prefix)).map((o) => o.title), { timeout: 30000 }).toEqual([`${prefix} edited`]);
|
||||
|
||||
// Move it two days on by dragging it in the month grid.
|
||||
const before = (await objects(b, `${prefix} edited`))[0];
|
||||
const target = format(addDays(new Date(String(before.start)), 2), 'yyyy-MM-dd');
|
||||
await card(`${prefix} edited`).dragTo(page.locator(`[data-day-button="${target}"]`).first());
|
||||
await expect.poll(async () => String((await objects(b, `${prefix} edited`))[0]?.start).slice(0, 10), { timeout: 30000 }).toBe(target);
|
||||
expect(await objects(a, prefix)).toEqual([]);
|
||||
|
||||
// Delete it, from a fresh load: a moved occurrence can come back under a new id.
|
||||
await page.reload();
|
||||
await expect(card(`${prefix} edited`)).toBeVisible({ timeout: 60000 });
|
||||
await card(`${prefix} edited`).click();
|
||||
await page.getByRole('button', { name: 'Delete event' }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: /Delete/ }).click();
|
||||
await expect.poll(async () => (await objects(b, prefix)).length, { timeout: 30000 }).toBe(0);
|
||||
|
||||
// Answer an invitation B got from someone else.
|
||||
test.skip(!ENT_PASSWORD, 'IT_ENT_PASSWORD for the outside organiser');
|
||||
const alice = await JmapClient.connect(ALICE, ENT_PASSWORD);
|
||||
const aliceHome = (await calendars(alice)).find((c) => c.isDefault)!;
|
||||
const start = `${format(addDays(new Date(), 1), 'yyyy-MM-dd')}T15:00:00`;
|
||||
const invite = await alice.request([['CalendarEvent/set', {
|
||||
accountId: alice.accountId,
|
||||
sendSchedulingMessages: true,
|
||||
create: {
|
||||
i: {
|
||||
'@type': 'Event', title: `${prefix} invite`, uid: `uxfix-cal-inv-${RUN}`, start, duration: 'PT1H', timeZone: TZ,
|
||||
calendarIds: { [aliceHome.id]: true },
|
||||
participants: {
|
||||
o: { '@type': 'Participant', calendarAddress: `mailto:${ALICE}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
p: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CAL]);
|
||||
expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
|
||||
await expect.poll(async () => (await objects(b, `${prefix} invite`)).length, { timeout: 90000 }).toBe(1);
|
||||
|
||||
// The day view lists every event of the day (the month may fold it under "+n more").
|
||||
await page.goto(`/calendar/day/${start.slice(0, 10)}`);
|
||||
await expect(card(`${prefix} invite`)).toBeVisible({ timeout: 60000 });
|
||||
await card(`${prefix} invite`).click();
|
||||
await page.getByRole('button', { name: 'Accept', exact: true }).first().click();
|
||||
await expect.poll(async () => {
|
||||
const [ev] = await objects(b, `${prefix} invite`);
|
||||
const parts = Object.values((ev?.participants ?? {}) as Record<string, { calendarAddress?: string; participationStatus?: string }>);
|
||||
return parts.find((p) => p.calendarAddress?.toLowerCase() === `mailto:${B}`)?.participationStatus;
|
||||
}, { timeout: 30000 }).toBe('accepted');
|
||||
// Nothing of it in A's account.
|
||||
expect(await objects(a, prefix)).toEqual([]);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fixes pim-11 (CSV import into a chosen address book) and pim-14
|
||||
* (merging duplicate contacts), against a VM 106 Stalwart (1.0 and 0.16).
|
||||
* Every UI step is checked on the server through JMAP.
|
||||
*
|
||||
* Account: dso-ux-pim (password IT_DSO_PASSWORD). The spec makes its own
|
||||
* cards, named "UXFIX-cimport …", and removes them again; the seeded "Work"
|
||||
* address book is only written to with those cards.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CONTACTS = 'urn:ietf:params:jmap:contacts';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = 'UXFIX-cimport';
|
||||
const SHOTS = 'C:/Users/info/Downloads/jmap-webmail/local-data/usability-2026-11-04/shots';
|
||||
// Shots once, from the 1.0 run.
|
||||
const TAKE_SHOTS = process.env.IT_ENT_SERVER === '1.0';
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Card = Record<string, unknown> & {
|
||||
id: string;
|
||||
uid?: string;
|
||||
kind?: string;
|
||||
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
|
||||
addressBookIds?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
emails?: Record<string, { address: string }>;
|
||||
phones?: Record<string, { number: string; features?: Record<string, boolean>; contexts?: Record<string, boolean> }>;
|
||||
addresses?: Record<string, { components?: Array<{ kind: string; value: string }>; contexts?: Record<string, boolean>; isOrdered?: boolean }>;
|
||||
notes?: Record<string, { note: string }>;
|
||||
organizations?: Record<string, { name?: string }>;
|
||||
titles?: Record<string, { name: string }>;
|
||||
anniversaries?: Record<string, { kind: string; date: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
const nameOf = (c: Card) => c.name?.full || (c.name?.components ?? []).filter((p) => p.kind !== 'separator').map((p) => p.value).join(' ');
|
||||
|
||||
async function books(c: JmapClient): Promise<Array<{ id: string; name: string; isDefault?: boolean }>> {
|
||||
const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
async function cards(c: JmapClient, prefix: string): Promise<Card[]> {
|
||||
const r = await c.request([
|
||||
['ContactCard/query', { accountId: c.accountId, limit: 2000 }, 'q'],
|
||||
['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
|
||||
], [CORE, CONTACTS]);
|
||||
return (r.methodResponses[1][1].list as Card[]).filter((card) => nameOf(card).includes(prefix));
|
||||
}
|
||||
|
||||
async function destroyAll(c: JmapClient): Promise<void> {
|
||||
const ids = (await cards(c, PREFIX)).map((card) => card.id);
|
||||
if (ids.length) await c.request([['ContactCard/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CONTACTS]);
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', A);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
/** German Outlook's export: `;` between fields, Windows-1252 bytes. */
|
||||
function outlookDeCsv(rows: Array<Record<string, string>>): Buffer {
|
||||
const headers = ['Anrede', 'Vorname', 'Weitere Vornamen', 'Nachname', 'Firma', 'Abteilung', 'Position',
|
||||
'Straße geschäftlich', 'Ort geschäftlich', 'Postleitzahl geschäftlich', 'Land/Region geschäftlich',
|
||||
'Telefon geschäftlich', 'Mobiltelefon', 'E-Mail-Adresse', 'E-Mail-Typ', 'E-Mail: Angezeigter Name',
|
||||
'Geburtstag', 'Jahrestag', 'Kategorien', 'Notizen', 'Privat'];
|
||||
const line = (cells: string[]) => cells.map((c) => `"${c.replace(/"/g, '""')}"`).join(';');
|
||||
const text = [line(headers), ...rows.map((r) => line(headers.map((h) => r[h] ?? '')))].join('\r\n') + '\r\n';
|
||||
const high: Record<string, number> = { '€': 0x80, '„': 0x84, '“': 0x93, '”': 0x94, '–': 0x96 };
|
||||
return Buffer.from([...text].map((ch) => {
|
||||
if (high[ch]) return high[ch];
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code > 0xff) throw new Error(`not in Windows-1252: ${ch}`);
|
||||
return code;
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe('Contacts import and merge (pim-11, pim-14)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await destroyAll(await JmapClient.connect(A, PASSWORD));
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await destroyAll(await JmapClient.connect(A, PASSWORD));
|
||||
});
|
||||
|
||||
test('a German Outlook CSV goes through the column step into the address book that is open', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const work = (await books(a)).find((b) => b.name === 'Work');
|
||||
expect(work, 'seeded Work address book').toBeTruthy();
|
||||
expect(work!.isDefault).toBeFalsy();
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page);
|
||||
await page.goto('/contacts');
|
||||
await page.getByText('Work', { exact: true }).first().click();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).first().click();
|
||||
await page.getByRole('menuitem', { name: /^Import contacts/ }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'Outlook-Kontakte.csv',
|
||||
mimeType: 'text/csv',
|
||||
buffer: outlookDeCsv([
|
||||
{
|
||||
Anrede: 'Frau', Vorname: 'Jürgen', Nachname: `${PREFIX} Import1 ${RUN}`, Firma: 'Muster & Söhne GmbH', Position: 'Leiterin',
|
||||
'Straße geschäftlich': 'Hauptstraße 1', 'Ort geschäftlich': 'Köln', 'Postleitzahl geschäftlich': '50667',
|
||||
'Land/Region geschäftlich': 'Deutschland', Mobiltelefon: '+49 170 1234567',
|
||||
'E-Mail-Adresse': `import1-${RUN}@example.org`, 'E-Mail-Typ': 'SMTP', 'E-Mail: Angezeigter Name': 'Jürgen',
|
||||
Geburtstag: '12.04.1985', Jahrestag: '0.0.00', Notizen: 'Rabatt 10 €, „Stammkunde“', Privat: 'Aus',
|
||||
},
|
||||
{ Vorname: 'Ana', Nachname: `${PREFIX} Import2 ${RUN}`, 'E-Mail-Adresse': `import2-${RUN}@example.org`, Privat: 'Aus' },
|
||||
]),
|
||||
});
|
||||
|
||||
const columns = dialog.getByTestId('csv-columns');
|
||||
await expect(columns).toBeVisible();
|
||||
await expect(columns.getByRole('combobox', { name: 'Vorname', exact: true })).toHaveValue('name.given');
|
||||
await expect(columns.getByRole('combobox', { name: 'Straße geschäftlich', exact: true })).toHaveValue('adr.work.street');
|
||||
await expect(columns.getByRole('combobox', { name: 'E-Mail-Typ', exact: true })).toHaveValue('skip');
|
||||
await expect(columns.getByRole('row', { name: /^Vorname/ }).getByText('Jürgen', { exact: true })).toBeVisible();
|
||||
if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after.png` });
|
||||
await dialog.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(dialog.getByText(`Jürgen ${PREFIX} Import1 ${RUN}`)).toBeVisible();
|
||||
const into = dialog.getByRole('combobox');
|
||||
await expect(into).toHaveValue(work!.id);
|
||||
if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after-preview.png` });
|
||||
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(dialog.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect.poll(async () => (await cards(a, `${PREFIX} Import`)).length, { timeout: 30000 }).toBe(2);
|
||||
const imported = await cards(a, `${PREFIX} Import`);
|
||||
for (const card of imported) expect(Object.keys(card.addressBookIds ?? {})).toEqual([work!.id]);
|
||||
const jurgen = imported.find((c) => nameOf(c).includes('Import1'))!;
|
||||
expect(jurgen.name?.components?.filter((p) => p.kind !== 'separator')).toEqual([
|
||||
{ kind: 'title', value: 'Frau' }, { kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: `${PREFIX} Import1 ${RUN}` },
|
||||
]);
|
||||
expect(Object.values(jurgen.emails ?? {}).map((e) => e.address)).toEqual([`import1-${RUN}@example.org`]);
|
||||
expect(Object.values(jurgen.phones ?? {}).map((p) => [p.number, p.features])).toEqual([['+49 170 1234567', { mobile: true }]]);
|
||||
const address = Object.values(jurgen.addresses ?? {})[0];
|
||||
expect(address?.contexts).toEqual({ work: true });
|
||||
expect(address?.components?.filter((p) => p.kind !== 'separator').map((p) => [p.kind, p.value])).toEqual([
|
||||
['name', 'Hauptstraße 1'], ['locality', 'Köln'], ['postcode', '50667'], ['country', 'Deutschland'],
|
||||
]);
|
||||
expect(Object.values(jurgen.organizations ?? {}).map((o) => o.name)).toEqual(['Muster & Söhne GmbH']);
|
||||
expect(Object.values(jurgen.titles ?? {}).map((t) => t.name)).toEqual(['Leiterin']);
|
||||
expect(Object.values(jurgen.notes ?? {}).map((n) => n.note)).toEqual(['Rabatt 10 €, „Stammkunde“']);
|
||||
const birthday = Object.values(jurgen.anniversaries ?? {}).find((x) => x.kind === 'birth');
|
||||
expect(birthday?.date).toMatchObject({ year: 1985, month: 4, day: 12 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('two cards merge into one: everything kept, the group moves, the other card is gone', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const home = (await books(a)).find((b) => b.isDefault) ?? (await books(a))[0];
|
||||
const surname = `${PREFIX} Merge ${RUN}`;
|
||||
// The address the way a server card has it: parts and separators in order.
|
||||
const berlin = {
|
||||
'@type': 'Address',
|
||||
components: [
|
||||
{ '@type': 'AddressComponent', kind: 'name', value: 'Hauptstraße' },
|
||||
{ '@type': 'AddressComponent', kind: 'separator', value: ' ' },
|
||||
{ '@type': 'AddressComponent', kind: 'number', value: '1' },
|
||||
{ '@type': 'AddressComponent', kind: 'postcode', value: '10115' },
|
||||
{ '@type': 'AddressComponent', kind: 'locality', value: 'Berlin' },
|
||||
],
|
||||
isOrdered: true,
|
||||
defaultSeparator: ', ',
|
||||
contexts: { private: true },
|
||||
};
|
||||
const made = await a.request([['ContactCard/set', {
|
||||
accountId: a.accountId,
|
||||
create: {
|
||||
one: {
|
||||
'@type': 'Card', version: '1.0', uid: `urn:uuid:uxfix-cimport-one-${RUN}`, addressBookIds: { [home.id]: true },
|
||||
name: { '@type': 'Name', components: [{ '@type': 'NameComponent', kind: 'given', value: 'Anna' }, { '@type': 'NameComponent', kind: 'surname', value: surname }], isOrdered: true },
|
||||
emails: { e0: { '@type': 'EmailAddress', address: `merge-a-${RUN}@example.org` } },
|
||||
addresses: { a0: berlin },
|
||||
notes: { n0: { '@type': 'Note', note: 'Met at the fair' } },
|
||||
},
|
||||
two: {
|
||||
'@type': 'Card', version: '1.0', uid: `urn:uuid:uxfix-cimport-two-${RUN}`, addressBookIds: { [home.id]: true },
|
||||
name: { '@type': 'Name', components: [{ '@type': 'NameComponent', kind: 'given', value: 'Anna' }, { '@type': 'NameComponent', kind: 'surname', value: surname }], isOrdered: true },
|
||||
emails: { e0: { '@type': 'EmailAddress', address: `merge-b-${RUN}@example.org` } },
|
||||
phones: { p0: { '@type': 'Phone', number: '+49 30 5550000' } },
|
||||
},
|
||||
group: {
|
||||
'@type': 'Card', version: '1.0', kind: 'group', uid: `urn:uuid:uxfix-cimport-group-${RUN}`, addressBookIds: { [home.id]: true },
|
||||
name: { '@type': 'Name', full: `${PREFIX} Group ${RUN}` },
|
||||
members: { [`urn:uuid:uxfix-cimport-one-${RUN}`]: true },
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CONTACTS]);
|
||||
const created = made.methodResponses[0][1].created ?? {};
|
||||
expect(Object.keys(created).sort(), JSON.stringify(made.methodResponses[0][1].notCreated)).toEqual(['group', 'one', 'two']);
|
||||
const [before] = (await cards(a, surname)).filter((c) => c.id === created.one.id);
|
||||
const beforeAddress = Object.values(before.addresses ?? {})[0];
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page);
|
||||
await page.goto('/contacts');
|
||||
const search = page.getByPlaceholder(/Search contacts/).first();
|
||||
await search.fill(surname);
|
||||
const rows = page.getByRole('option').filter({ hasText: surname });
|
||||
await expect(rows).toHaveCount(2, { timeout: 60000 });
|
||||
|
||||
// The single-contact menu offers Merge with…, not Duplicate.
|
||||
await rows.first().click({ button: 'right' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Merge with…' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await rows.nth(0).click();
|
||||
await rows.nth(1).click({ modifiers: ['Control'] });
|
||||
await page.getByRole('button', { name: 'Merge…' }).first().click();
|
||||
const preview = page.getByTestId('merge-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview.getByText(`merge-a-${RUN}@example.org`)).toBeVisible();
|
||||
await expect(preview.getByText(`merge-b-${RUN}@example.org`)).toBeVisible();
|
||||
await expect(preview.getByText('Hauptstraße 1, 10115, Berlin')).toBeVisible();
|
||||
await expect(preview.getByText(`${PREFIX} Group ${RUN}`)).toBeVisible();
|
||||
if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-14-after.png` });
|
||||
await page.getByRole('button', { name: 'Merge', exact: true }).click();
|
||||
const toastRegion = page.getByRole('region', { name: 'Notifications' });
|
||||
await expect(toastRegion.getByText('Contacts merged')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// On the server: one card with everything, the address untouched, the group pointing at it.
|
||||
await expect.poll(async () => (await cards(a, surname)).filter((c) => c.kind !== 'group').length, { timeout: 30000 }).toBe(1);
|
||||
const all = await cards(a, PREFIX);
|
||||
const [kept] = all.filter((c) => nameOf(c).includes(surname) && c.kind !== 'group');
|
||||
const goneId = kept.id === created.one.id ? created.two.id : created.one.id;
|
||||
expect(all.some((c) => c.id === goneId)).toBe(false);
|
||||
const gone = await a.request([['ContactCard/get', { accountId: a.accountId, ids: [goneId] }, '0']], [CORE, CONTACTS]);
|
||||
expect(gone.methodResponses[0][1].notFound).toEqual([goneId]);
|
||||
expect(Object.values(kept.emails ?? {}).map((e) => e.address).sort()).toEqual([`merge-a-${RUN}@example.org`, `merge-b-${RUN}@example.org`]);
|
||||
expect(Object.values(kept.phones ?? {}).map((p) => p.number)).toEqual(['+49 30 5550000']);
|
||||
expect(Object.values(kept.notes ?? {}).map((n) => n.note)).toEqual(['Met at the fair']);
|
||||
const keptAddresses = Object.values(kept.addresses ?? {});
|
||||
expect(keptAddresses).toHaveLength(1);
|
||||
expect(keptAddresses[0].components).toEqual(beforeAddress?.components);
|
||||
expect(keptAddresses[0].contexts).toEqual({ private: true });
|
||||
const group = all.find((c) => c.kind === 'group');
|
||||
const members = Object.keys(group?.members ?? {}).filter((k) => group?.members?.[k]);
|
||||
expect(members).toEqual([kept.uid]);
|
||||
|
||||
// Undo: the other card is back under its own uid, the group and the kept card as before.
|
||||
await toastRegion.getByRole('button', { name: 'Undo' }).click();
|
||||
await expect(toastRegion.getByText('Merge undone')).toBeVisible({ timeout: 30000 });
|
||||
const after = await cards(a, PREFIX);
|
||||
const back = after.filter((c) => nameOf(c).includes(surname) && c.kind !== 'group');
|
||||
expect(back.map((c) => c.uid).sort()).toEqual([`urn:uuid:uxfix-cimport-one-${RUN}`, `urn:uuid:uxfix-cimport-two-${RUN}`]);
|
||||
for (const card of back) expect(Object.keys(card.emails ?? {})).toHaveLength(1);
|
||||
const groupAfter = after.find((c) => c.kind === 'group');
|
||||
expect(Object.keys(groupAfter?.members ?? {}).filter((k) => groupAfter?.members?.[k])).toEqual([`urn:uuid:uxfix-cimport-one-${RUN}`]);
|
||||
const one = back.find((c) => c.uid === `urn:uuid:uxfix-cimport-one-${RUN}`);
|
||||
expect(Object.values(one?.addresses ?? {})[0]?.components).toEqual(beforeAddress?.components);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('the contact menu says Merge with… in German too', async ({ browser }) => {
|
||||
test.skip(!TAKE_SHOTS, 'Shot only from the 1.0 run');
|
||||
test.setTimeout(180_000);
|
||||
const context = await browser.newContext({ ...contextOptions(), locale: 'de-DE', viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page);
|
||||
await page.goto('/contacts');
|
||||
await page.getByRole('option').filter({ hasText: 'Anna Example' }).first().click();
|
||||
await page.getByRole('button', { name: 'Weitere Aktionen' }).first().click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Zusammenführen mit…' })).toBeVisible();
|
||||
await expect(page.getByText(/Mögliche Duplikate \(\d+\)/)).toBeVisible();
|
||||
await page.screenshot({ path: `${SHOTS}/pim-fix-14-after-de.png` });
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fix pim-4: saving a contact never rewrites an address the user
|
||||
* didn't touch, and an edited address is saved in its country's order.
|
||||
* Against a VM 106 Stalwart (1.0 and 0.16, which converts cards through
|
||||
* vCard), every UI step is checked on the server.
|
||||
*
|
||||
* Account: dso-ux-pim (password IT_DSO_PASSWORD). The spec makes its own
|
||||
* card, named "UXFIX-contact …", and removes it again.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CONTACTS = 'urn:ietf:params:jmap:contacts';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = 'UXFIX-contact';
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Address = Record<string, unknown> & { components?: Array<{ kind: string; value: string }> };
|
||||
type Card = Record<string, unknown> & {
|
||||
id: string;
|
||||
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
|
||||
nicknames?: Record<string, { name: string }>;
|
||||
addresses?: Record<string, Address>;
|
||||
};
|
||||
|
||||
async function defaultBook(c: JmapClient): Promise<string> {
|
||||
const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]);
|
||||
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
|
||||
return (list.find((b) => b.isDefault) ?? list[0]).id;
|
||||
}
|
||||
|
||||
async function cards(c: JmapClient): Promise<Card[]> {
|
||||
const r = await c.request([
|
||||
['ContactCard/query', { accountId: c.accountId, limit: 1000 }, 'q'],
|
||||
['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
|
||||
], [CORE, CONTACTS]);
|
||||
const named = (card: Card) => card.name?.full ?? card.name?.components?.map((x) => x.value).join(' ') ?? '';
|
||||
return (r.methodResponses[1][1].list as Card[]).filter((card) => named(card).startsWith(PREFIX));
|
||||
}
|
||||
|
||||
async function getCard(c: JmapClient, id: string): Promise<Card> {
|
||||
const r = await c.request([['ContactCard/get', { accountId: c.accountId, ids: [id] }, '0']], [CORE, CONTACTS]);
|
||||
return r.methodResponses[0][1].list[0] as Card;
|
||||
}
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', A);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
test.describe('Saving a contact keeps its address (pim-4)', () => {
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const ids = (await cards(a)).filter((card) => JSON.stringify(card.name).includes(RUN)).map((card) => card.id);
|
||||
if (ids.length) await a.request([['ContactCard/set', { accountId: a.accountId, destroy: ids }, '0']], [CORE, CONTACTS]);
|
||||
});
|
||||
|
||||
test('a nickname edit leaves a German address as stored, an address edit saves it in German order', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const surname = `Adresse ${RUN}`;
|
||||
const created = await a.request([['ContactCard/set', {
|
||||
accountId: a.accountId,
|
||||
create: {
|
||||
c: {
|
||||
'@type': 'Card',
|
||||
version: '1.0',
|
||||
kind: 'individual',
|
||||
addressBookIds: { [await defaultBook(a)]: true },
|
||||
name: { '@type': 'Name', components: [{ kind: 'given', value: PREFIX }, { kind: 'surname', value: surname }], isOrdered: true },
|
||||
phones: { p1: { '@type': 'Phone', number: '+49 30 123456', features: { voice: true }, contexts: { private: true } } },
|
||||
addresses: {
|
||||
home: {
|
||||
'@type': 'Address',
|
||||
contexts: { private: true },
|
||||
countryCode: 'DE',
|
||||
isOrdered: true,
|
||||
defaultSeparator: ', ',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Hauptstraße' },
|
||||
{ kind: 'separator', value: ' ' },
|
||||
{ kind: 'number', value: '1' },
|
||||
{ kind: 'apartment', value: '3. OG' },
|
||||
{ kind: 'postcode', value: '10000' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CONTACTS]);
|
||||
const id = created.methodResponses[0][1].created?.c?.id as string | undefined;
|
||||
expect(id, JSON.stringify(created.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
|
||||
// The address as this server keeps it (0.16 goes through vCard).
|
||||
const stored = (await getCard(a, id!)).addresses!;
|
||||
expect(Object.keys(stored)).toHaveLength(1);
|
||||
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const page = await context.newPage();
|
||||
await login(page);
|
||||
await page.goto('/contacts');
|
||||
await page.getByPlaceholder('Search contacts...').fill(RUN);
|
||||
await page.locator('[data-contact-id]', { hasText: surname }).first().click();
|
||||
|
||||
// The contact page lays the address out the German way, one line per part.
|
||||
const addressLines = page.getByTestId('contact-address').first().locator('[data-address-line]');
|
||||
await expect(addressLines).toHaveText(['Hauptstraße 1, 3. OG', '10000 Berlin', 'Germany']);
|
||||
await expect(page.getByText('Home · Phone', { exact: true })).toBeVisible();
|
||||
|
||||
// 1. Only the nickname changes: the server's address stays exactly as it was.
|
||||
await page.getByRole('button', { name: 'Edit contact' }).click();
|
||||
await expect(page.getByLabel('Street')).toHaveValue('Hauptstraße 1');
|
||||
await page.getByLabel('Nickname').fill('Adi');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Contact updated')).toBeVisible({ timeout: 30000 });
|
||||
await expect.poll(async () => Object.values((await getCard(a, id!)).nicknames ?? {})[0]?.name, { timeout: 30000 }).toBe('Adi');
|
||||
expect((await getCard(a, id!)).addresses).toEqual(stored);
|
||||
|
||||
// 2. The address is edited: saved in German order, apartment and context kept.
|
||||
await page.getByRole('button', { name: 'Edit contact' }).click();
|
||||
await page.getByLabel('Street').fill('Hauptstraße 12a');
|
||||
await page.getByLabel('Postal code').fill('10115');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(async () => {
|
||||
const address = Object.values((await getCard(a, id!)).addresses ?? {})[0];
|
||||
return address?.components?.map((x) => `${x.kind}:${x.value}`);
|
||||
}, { timeout: 30000 }).toEqual([
|
||||
'name:Hauptstraße', 'separator: ', 'number:12a', 'apartment:3. OG', 'postcode:10115', 'separator: ', 'locality:Berlin', 'country:Germany',
|
||||
]);
|
||||
const edited = Object.values((await getCard(a, id!)).addresses!)[0];
|
||||
expect(edited).toMatchObject({ countryCode: 'DE', contexts: { private: true }, isOrdered: true });
|
||||
await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
|
||||
|
||||
// A reload reads the card back from the server, not from the app's memory.
|
||||
await page.reload();
|
||||
await page.getByPlaceholder('Search contacts...').fill(RUN);
|
||||
await page.locator('[data-contact-id]', { hasText: surname }).first().click();
|
||||
await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { addDays, format } from 'date-fns';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fixes pim-2 (events keep their time zone) and pim-23 (the
|
||||
* invitation card), against a VM 106 Stalwart (1.0 and 0.16). Every UI step
|
||||
* is checked on the server.
|
||||
*
|
||||
* Accounts: dso-ux-pim (password IT_DSO_PASSWORD), and ent-alice
|
||||
* (IT_ENT_PASSWORD) as an outside organiser. The spec makes its own items,
|
||||
* titled "UXFIX-event …", and removes them again.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const ENT_PASSWORD = process.env.IT_ENT_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const ALICE = 'ent-alice@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CAL = 'urn:ietf:params:jmap:calendars';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const RUN = Date.now().toString(36);
|
||||
const TZ = 'Europe/Berlin';
|
||||
const NY = 'America/New_York';
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Obj = Record<string, unknown> & {
|
||||
id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string;
|
||||
// Stalwart names it as JSCalendar 2.0 does: one rule, singular.
|
||||
recurrenceRule?: Record<string, unknown> | null;
|
||||
recurrenceOverrides?: Record<string, Record<string, unknown>> | null;
|
||||
participants?: Record<string, { calendarAddress?: string; participationStatus?: string }> | null;
|
||||
};
|
||||
|
||||
async function defaultCalendar(c: JmapClient): Promise<string> {
|
||||
const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]);
|
||||
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
|
||||
return (list.find((cal) => cal.isDefault) ?? list[0]).id;
|
||||
}
|
||||
|
||||
/** Events whose title starts with `prefix`, as the server keeps them (base events, not expanded). */
|
||||
async function objects(c: JmapClient, prefix: string): Promise<Obj[]> {
|
||||
const r = await c.request([
|
||||
['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: c.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: ['id', 'uid', 'title', 'start', 'timeZone', 'duration', 'recurrenceRule', 'recurrenceOverrides', 'participants'],
|
||||
}, 'g'],
|
||||
], [CORE, CAL]);
|
||||
return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function destroyAll(c: JmapClient, prefix: string): Promise<void> {
|
||||
const ids = (await objects(c, prefix)).map((o) => o.id);
|
||||
if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]);
|
||||
}
|
||||
|
||||
async function mails(c: JmapClient, word: string): Promise<string[]> {
|
||||
const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 50 }, 'q']], [CORE, MAIL]);
|
||||
return r.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
async function destroyMails(c: JmapClient, word: string): Promise<void> {
|
||||
const ids = await mails(c, word);
|
||||
if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]);
|
||||
}
|
||||
|
||||
async function createEvent(c: JmapClient, event: Record<string, unknown>): Promise<string> {
|
||||
const r = await c.request([['CalendarEvent/set', {
|
||||
accountId: c.accountId,
|
||||
create: { e: { '@type': 'Event', calendarIds: { [await defaultCalendar(c)]: true }, ...event } },
|
||||
}, '0']], [CORE, CAL]);
|
||||
const id = r.methodResponses[0][1].created?.e?.id as string | undefined;
|
||||
expect(id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
|
||||
return id!;
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string, password = PASSWORD): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', password);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
// Event chips are buttons named "<title>, <time>, <calendar>".
|
||||
const card = (page: Page, title: string) => page.locator(`button[aria-label^="${title},"]`).first();
|
||||
|
||||
/**
|
||||
* The chip of a series on screen: the week view keeps the weeks next to the
|
||||
* shown one in the page too, with chips of the same name.
|
||||
*/
|
||||
async function cardOnScreen(page: Page, title: string) {
|
||||
const cards = page.locator(`button[aria-label^="${title},"]`);
|
||||
await cards.first().waitFor({ state: 'attached', timeout: 60000 });
|
||||
const width = page.viewportSize()!.width;
|
||||
for (let i = 0; i < await cards.count(); i++) {
|
||||
const box = await cards.nth(i).boundingBox();
|
||||
if (box && box.x >= 0 && box.x + box.width <= width) return cards.nth(i);
|
||||
}
|
||||
throw new Error(`no chip of ${title} on screen`);
|
||||
}
|
||||
|
||||
const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd');
|
||||
|
||||
test.describe('Events keep their time zone (pim-2)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const prefix = `UXFIX-event ${RUN}`;
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await destroyAll(a, prefix);
|
||||
});
|
||||
|
||||
test('a title change keeps a New York event in New York, and Tokyo can be picked for a new one', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const when = day(9);
|
||||
const id = await createEvent(a, {
|
||||
title: `${prefix} NY call`, uid: `uxfix-event-ny-${RUN}`, start: `${when}T10:00:00`, timeZone: NY, duration: 'PT1H',
|
||||
});
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
|
||||
// The popover names the event's zone and the user's time.
|
||||
await page.goto(`/calendar/week/${when}`);
|
||||
await card(page, `${prefix} NY call`).click();
|
||||
const pop = page.getByRole('dialog', { name: `${prefix} NY call` });
|
||||
await expect(pop.getByTestId('event-zone-times')).toHaveText(/^10:00–11:00 New York \(\d\d:00–\d\d:00 your time\)$/);
|
||||
await pop.getByRole('button', { name: 'Edit event' }).click();
|
||||
|
||||
// The form shows the event's own clock and says which zone it is on.
|
||||
await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('10:00');
|
||||
await expect(page.getByRole('button', { name: 'Time zone: New York' })).toBeVisible();
|
||||
await page.getByLabel('Title', { exact: true }).fill(`${prefix} NY call, room 2`);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(async () => {
|
||||
const [e] = await objects(a, `${prefix} NY call`);
|
||||
return e && { id: e.id, title: e.title, start: e.start, timeZone: e.timeZone, duration: e.duration };
|
||||
}, { timeout: 30000 }).toEqual({ id, title: `${prefix} NY call, room 2`, start: `${when}T10:00:00`, timeZone: NY, duration: 'PT1H' });
|
||||
|
||||
// A new event in Tokyo through the picker.
|
||||
const tokyoDay = day(10);
|
||||
await page.goto(`/calendar/week/${tokyoDay}`);
|
||||
await expect(page.getByRole('button', { name: 'Today', exact: true })).toBeVisible({ timeout: 60000 });
|
||||
// The calendars first: a form opened before they load has none picked.
|
||||
await expect(page.locator('[data-testid="calendar-item"]').first()).toBeVisible({ timeout: 60000 });
|
||||
const title = page.getByLabel('Title', { exact: true });
|
||||
// "n" opens a new event once the page listens for keys.
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('n');
|
||||
await expect(title).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({ timeout: 60000 });
|
||||
await title.fill(`${prefix} Tokyo sync`);
|
||||
await page.getByLabel('Start date', { exact: true }).fill(tokyoDay);
|
||||
await page.getByLabel('Start time', { exact: true }).fill('10:00');
|
||||
await page.getByLabel('End date', { exact: true }).fill(tokyoDay);
|
||||
await page.getByLabel('End time', { exact: true }).fill('11:00');
|
||||
await page.getByRole('button', { name: 'Time zone', exact: true }).click();
|
||||
await page.getByLabel('Time zone', { exact: true }).selectOption('Asia/Tokyo');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(async () => {
|
||||
const [e] = await objects(a, `${prefix} Tokyo sync`);
|
||||
return e && { start: e.start, timeZone: e.timeZone, duration: e.duration };
|
||||
}, { timeout: 30000 }).toEqual({ start: `${tokyoDay}T10:00:00`, timeZone: 'Asia/Tokyo', duration: 'PT1H' });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a New York series keeps its zone and its exceptions for every scope', async ({ browser }) => {
|
||||
test.setTimeout(300_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
// Weekly, six times, 10:00 in New York; the second one has a title of its own.
|
||||
const d = [0, 7, 14, 21, 28, 35].map((n) => day(n + 2));
|
||||
const own = `${prefix} series (budget)`;
|
||||
const id = await createEvent(a, {
|
||||
title: `${prefix} series`, uid: `uxfix-event-series-${RUN}`, start: `${d[0]}T10:00:00`, timeZone: NY, duration: 'PT1H',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 6 },
|
||||
recurrenceOverrides: { [`${d[1]}T10:00:00`]: { title: own } },
|
||||
});
|
||||
const series = async () => (await objects(a, prefix)).find((e) => e.id === id)!;
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
|
||||
async function edit(on: string, from: string, to: string, scope: 'This event only' | 'This and following events' | 'All events') {
|
||||
await page.goto(`/calendar/week/${on}`);
|
||||
await page.locator('[data-testid="calendar-item"]').first().waitFor({ timeout: 60000 });
|
||||
await expect(async () => (await cardOnScreen(page, from)).click()).toPass({ timeout: 60000 });
|
||||
await page.getByRole('dialog', { name: from }).getByRole('button', { name: 'Edit event' }).click();
|
||||
await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('10:00');
|
||||
await page.getByLabel('Title', { exact: true }).fill(to);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
const ask = page.getByRole('dialog', { name: 'Edit recurring event' });
|
||||
await ask.getByRole('radio', { name: scope }).check();
|
||||
await ask.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(ask).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
|
||||
// All events, from the third.
|
||||
await edit(d[2], `${prefix} series`, `${prefix} series v2`, 'All events');
|
||||
await expect.poll(async () => {
|
||||
const e = await series();
|
||||
return { title: e.title, start: e.start, timeZone: e.timeZone, keys: Object.keys(e.recurrenceOverrides ?? {}) };
|
||||
}, { timeout: 30000 }).toEqual({ title: `${prefix} series v2`, start: `${d[0]}T10:00:00`, timeZone: NY, keys: [`${d[1]}T10:00:00`] });
|
||||
expect((await series()).recurrenceOverrides![`${d[1]}T10:00:00`].title).toBe(own);
|
||||
|
||||
// This event only, the fourth.
|
||||
await edit(d[3], `${prefix} series v2`, `${prefix} series v2 (fourth)`, 'This event only');
|
||||
await expect.poll(async () => {
|
||||
const e = await series();
|
||||
return { start: e.start, timeZone: e.timeZone, keys: Object.keys(e.recurrenceOverrides ?? {}).sort() };
|
||||
}, { timeout: 30000 }).toEqual({ start: `${d[0]}T10:00:00`, timeZone: NY, keys: [`${d[1]}T10:00:00`, `${d[3]}T10:00:00`].sort() });
|
||||
const fourth = (await series()).recurrenceOverrides![`${d[3]}T10:00:00`];
|
||||
expect(fourth.title).toBe(`${prefix} series v2 (fourth)`);
|
||||
// The occurrence stays at 10:00 in New York: no time or a New York one of its own.
|
||||
expect([undefined, NY]).toContain(fourth.timeZone);
|
||||
expect([undefined, `${d[3]}T10:00:00`]).toContain(fourth.start);
|
||||
|
||||
// This and following, from the fifth.
|
||||
await edit(d[4], `${prefix} series v2`, `${prefix} series v3`, 'This and following events');
|
||||
await expect.poll(async () => {
|
||||
const rest = (await objects(a, `${prefix} series v3`)).find((e) => e.id !== id);
|
||||
return rest && { start: rest.start, timeZone: rest.timeZone };
|
||||
}, { timeout: 30000 }).toEqual({ start: `${d[4]}T10:00:00`, timeZone: NY });
|
||||
const old = await series();
|
||||
expect(old).toMatchObject({ start: `${d[0]}T10:00:00`, timeZone: NY });
|
||||
expect(Object.keys(old.recurrenceOverrides ?? {}).sort()).toEqual([`${d[1]}T10:00:00`, `${d[3]}T10:00:00`].sort());
|
||||
expect(old.recurrenceRule?.until).toBeTruthy();
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('The invitation card (pim-23)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const prefix = `UXFIX-event ${RUN} invite`;
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await destroyAll(a, prefix);
|
||||
await destroyMails(a, prefix);
|
||||
if (ENT_PASSWORD) {
|
||||
const alice = await JmapClient.connect(ALICE, ENT_PASSWORD);
|
||||
await destroyAll(alice, prefix);
|
||||
await destroyMails(alice, prefix);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows a same-day invitation with its date once and no zone, then says what the user answered', async ({ browser }) => {
|
||||
test.skip(!ENT_PASSWORD, 'IT_ENT_PASSWORD for the outside organiser');
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const alice = await JmapClient.connect(ALICE, ENT_PASSWORD);
|
||||
const when = day(11);
|
||||
const invite = await alice.request([['CalendarEvent/set', {
|
||||
accountId: alice.accountId,
|
||||
sendSchedulingMessages: true,
|
||||
create: {
|
||||
i: {
|
||||
'@type': 'Event', title: prefix, uid: `uxfix-event-inv-${RUN}`, start: `${when}T10:00:00`, duration: 'PT1H30M', timeZone: TZ,
|
||||
calendarIds: { [await defaultCalendar(alice)]: true },
|
||||
participants: {
|
||||
o: { '@type': 'Participant', calendarAddress: `mailto:${ALICE}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CAL]);
|
||||
expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
|
||||
await expect.poll(async () => (await mails(a, prefix)).length, { timeout: 90000 }).toBeGreaterThan(0);
|
||||
const [mailId] = await mails(a, prefix);
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await page.goto(`/mail/message/${mailId}`);
|
||||
|
||||
// "Thu, Nov 5, 10:00 – 11:30": the date once, no "GMT+1".
|
||||
const whenLine = page.getByTestId('invitation-when');
|
||||
await expect(whenLine).toBeVisible({ timeout: 60000 });
|
||||
const text = (await whenLine.textContent()) ?? '';
|
||||
const weekday = format(new Date(`${when}T12:00:00`), 'EEE');
|
||||
expect(text).toMatch(new RegExp(`^${weekday}, \\w{3} \\d{1,2}, 10:00\\s(AM\\s)?–\\s11:30(\\sAM)?$`));
|
||||
expect(text).not.toContain('GMT');
|
||||
|
||||
await expect(page.getByText(/^You've been invited/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Accept', exact: true }).first().click();
|
||||
await expect(page.getByText(/^You accepted on \w{3}, \w{3} \d{1,2}\.$/)).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText(/^You've been invited/)).toBeHidden();
|
||||
await expect.poll(async () => {
|
||||
const [e] = await objects(a, prefix);
|
||||
return Object.values(e?.participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${A}`)?.participationStatus;
|
||||
}, { timeout: 30000 }).toBe('accepted');
|
||||
|
||||
// Opened again later, it still says so.
|
||||
await page.reload();
|
||||
await expect(page.getByText(/^You accepted on \w{3}, \w{3} \d{1,2}\.$/)).toBeVisible({ timeout: 60000 });
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fixes in Files (pim-3, pim-10, pim-16, pim-26) against a VM 106
|
||||
* Stalwart, 1.0 and 0.16. FileNode differs between them (1.0: draft 18 with
|
||||
* nodeType and `@type`; 0.16: blobId == null marks a folder), so the server
|
||||
* state is read back over JMAP after every step.
|
||||
*
|
||||
* - pim-10: uploading a name that exists asks Replace / Keep both / Skip.
|
||||
* Replace keeps the node id and gives it a new blob, Keep both makes the
|
||||
* "(1)" copy, Skip changes nothing; "Do this for all" answers the rest.
|
||||
* - pim-3: a double-click opens the preview and it stays open.
|
||||
* - pim-16: a file with no preview opens a pane with Download.
|
||||
* - pim-26: delete has Undo, and sharing offers "Send a message" with a link.
|
||||
*
|
||||
* Account dso-ux-pim (and dso-ux-pim-b to share with), password
|
||||
* IT_DSO_PASSWORD. Every node made here is named "UXFIX-files …" and removed.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const B = 'dso-ux-pim-b@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const FILENODE = 'urn:ietf:params:jmap:filenode';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = `UXFIX-files ${RUN}`;
|
||||
const SHOTS = process.env.IT_UXPIM_SHOTS;
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Node = { id: string; name: string; parentId: string | null; blobId: string | null; shareWith?: Record<string, unknown> | null };
|
||||
|
||||
async function nodes(c: JmapClient): Promise<Node[]> {
|
||||
const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]);
|
||||
return r.methodResponses[0][1].list as Node[];
|
||||
}
|
||||
|
||||
async function ours(c: JmapClient): Promise<Node[]> {
|
||||
return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX));
|
||||
}
|
||||
|
||||
async function cleanUp(c: JmapClient): Promise<void> {
|
||||
// Our nodes wherever they are, the trash's batch folders included.
|
||||
const all = await nodes(c);
|
||||
const ids = all.filter((n) => n.name.startsWith(PREFIX)).map((n) => n.id);
|
||||
if (ids.length) await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: ids }, '0']], [CORE, FILENODE]);
|
||||
// The draft "Send a message" started.
|
||||
const q = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: PREFIX } }, '0']]);
|
||||
const drafts = q.methodResponses[0][1].ids as string[];
|
||||
if (drafts?.length) await c.request([['Email/set', { accountId: c.accountId, destroy: drafts }, '0']]);
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
async function openFilesRoot(page: Page): Promise<void> {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function upload(page: Page, files: { name: string; text: string }[]): Promise<void> {
|
||||
await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles(
|
||||
files.map((f) => ({ name: f.name, mimeType: 'text/plain', buffer: Buffer.from(f.text) })),
|
||||
);
|
||||
}
|
||||
|
||||
async function shot(page: Page, name: string): Promise<void> {
|
||||
if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name) });
|
||||
}
|
||||
|
||||
const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first();
|
||||
|
||||
test.describe('Files (pim-3, pim-10, pim-16, pim-26)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await cleanUp(a);
|
||||
});
|
||||
|
||||
test('an upload over an existing name asks, and each answer shows on the server', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const copy = `${PREFIX} (1).txt`;
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
|
||||
await upload(page, [{ name, text: 'one' }]);
|
||||
await expect.poll(async () => (await ours(a)).map((n) => n.name), { timeout: 30000 }).toEqual([name]);
|
||||
const [first] = await ours(a);
|
||||
await expect(row(page, name)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Replace: the same node, a new blob, no copy.
|
||||
await upload(page, [{ name, text: 'two, longer' }]);
|
||||
const dialog = page.getByRole('alertdialog', { name: `${name} already exists.` });
|
||||
await expect(dialog).toBeVisible({ timeout: 30000 });
|
||||
await expect(dialog.getByRole('checkbox')).toHaveCount(0);
|
||||
await shot(page, 'pim-fix-10-after.png');
|
||||
await dialog.getByRole('button', { name: 'Replace', exact: true }).click();
|
||||
await expect.poll(async () => {
|
||||
const list = await ours(a);
|
||||
return list.map((n) => ({ id: n.id, name: n.name, newBlob: n.blobId !== first.blobId }));
|
||||
}, { timeout: 30000 }).toEqual([{ id: first.id, name, newBlob: true }]);
|
||||
const replaced = (await ours(a))[0];
|
||||
|
||||
// Keep both: the "(1)" copy, the original untouched.
|
||||
await upload(page, [{ name, text: 'three' }]);
|
||||
await dialog.getByRole('button', { name: 'Keep both' }).click();
|
||||
await expect.poll(async () => (await ours(a)).map((n) => n.name).sort(), { timeout: 30000 }).toEqual([copy, name].sort());
|
||||
expect((await ours(a)).find((n) => n.name === name)).toMatchObject({ id: first.id, blobId: replaced.blobId });
|
||||
const before = JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort());
|
||||
|
||||
// Skip: nothing changes.
|
||||
await expect(row(page, copy)).toBeVisible({ timeout: 30000 });
|
||||
await upload(page, [{ name, text: 'four' }]);
|
||||
await dialog.getByRole('button', { name: 'Skip' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
// Two conflicts: "Do this for all 2 conflicts", and Skip answers both.
|
||||
await upload(page, [{ name, text: 'five' }, { name: copy, text: 'six' }]);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('checkbox', { name: 'Do this for all 2 conflicts' }).check();
|
||||
await dialog.getByRole('button', { name: 'Skip' }).click();
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
await page.waitForTimeout(3000);
|
||||
expect(JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort())).toBe(before);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('double-click opens a preview that stays, a file with no preview gets a pane, delete has Undo', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const zip = `${PREFIX}.bin`;
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
await upload(page, [{ name: zip, text: 'binary-ish' }]);
|
||||
await expect(row(page, zip)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// pim-3: one click selects, a double-click opens, and it stays open.
|
||||
await row(page, name).getByTitle(name).click();
|
||||
await expect(row(page, name)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByRole('dialog', { name })).toHaveCount(0);
|
||||
await row(page, name).getByTitle(name).dblclick();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByRole('dialog', { name })).toBeVisible();
|
||||
await shot(page, 'pim-fix-3-after.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog', { name })).toHaveCount(0);
|
||||
|
||||
// pim-16: no preview, no editor: the pane, not a download.
|
||||
let downloaded = false;
|
||||
page.on('download', () => { downloaded = true; });
|
||||
await row(page, zip).getByTitle(zip).dblclick();
|
||||
const pane = page.getByTestId('file-no-preview');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect(pane).toContainText('There is no preview for this type of file.');
|
||||
await expect(pane.getByRole('button', { name: 'Download' })).toBeVisible();
|
||||
await shot(page, 'pim-fix-16-after.png');
|
||||
expect(downloaded).toBe(false);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// pim-26: delete goes to the trash with Undo, and Undo puts it back.
|
||||
await row(page, zip).click();
|
||||
await page.keyboard.press('Delete');
|
||||
const undo = page.getByRole('button', { name: 'Undo' }).last();
|
||||
await expect(undo).toBeVisible({ timeout: 30000 });
|
||||
await shot(page, 'pim-fix-26-undo-after.png');
|
||||
const zipId = (await ours(a)).find((n) => n.name === zip)!.id;
|
||||
await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).not.toBeNull();
|
||||
await undo.click();
|
||||
await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).toBeNull();
|
||||
await expect(row(page, zip)).toBeVisible({ timeout: 30000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('sharing with someone offers a message with a link to the file', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
// Run on its own (-g), it makes the file the first test made.
|
||||
if (!(await ours(a)).some((n) => n.name === name)) await upload(page, [{ name, text: 'one' }]);
|
||||
await expect.poll(async () => (await ours(a)).some((n) => n.name === name), { timeout: 30000 }).toBe(true);
|
||||
const node = (await ours(a)).find((n) => n.name === name)!;
|
||||
await expect(row(page, name)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await row(page, name).getByTitle(name).click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: /^Share/ }).click();
|
||||
const shareDialog = page.getByRole('dialog').last();
|
||||
await shareDialog.getByRole('button', { name: 'Add person or group' }).click();
|
||||
await shareDialog.getByRole('textbox', { name: 'Search by name or email…' }).fill('dso-ux-pim-b');
|
||||
await shareDialog.getByRole('button', { name: /dso-ux-pim-b/ }).first().click();
|
||||
const send = page.getByRole('button', { name: 'Send a message' });
|
||||
await expect(send).toBeVisible({ timeout: 30000 });
|
||||
await expect.poll(async () => Object.keys((await nodes(a)).find((n) => n.id === node.id)?.shareWith ?? {}).length, { timeout: 30000 }).toBe(1);
|
||||
await shot(page, 'pim-fix-26-share-after.png');
|
||||
await send.click();
|
||||
|
||||
// The composer, to b, with a link the person opens it by.
|
||||
const link = `?shared=${encodeURIComponent(`${a.filesAccountId}:${node.id}`)}`;
|
||||
await expect(page.getByText(B).first()).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.locator('[contenteditable="true"], textarea').filter({ hasText: link }).first()).toBeVisible({ timeout: 30000 });
|
||||
await shot(page, 'pim-fix-26-compose-after.png');
|
||||
|
||||
// b sees the node under that id.
|
||||
const shared = await b.request([['FileNode/get', { accountId: a.filesAccountId, ids: [node.id], properties: ['id', 'name'] }, '0']], [CORE, FILENODE]);
|
||||
expect(shared.methodResponses[0][1].list?.[0]?.name).toBe(name);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { test, expect, type Page, type Locator } from '@playwright/test';
|
||||
import { addDays, format } from 'date-fns';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fix pim-9: invitations the user hasn't answered look different
|
||||
* in the grid and can be answered from it, against a VM 106 Stalwart (1.0
|
||||
* and 0.16). Every answer is checked on the server: the user's own copy and,
|
||||
* where the server passes the reply on, the organiser's.
|
||||
*
|
||||
* Accounts: dso-ux-pim-b invites dso-ux-pim (both IT_DSO_PASSWORD). The spec
|
||||
* makes its own events, titled "UXFIX-grid …", and removes them again.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const B = 'dso-ux-pim-b@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CAL = 'urn:ietf:params:jmap:calendars';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const RUN = Date.now().toString(36);
|
||||
const TZ = 'Europe/Berlin';
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Participant = { calendarAddress?: string; participationStatus?: string };
|
||||
type Obj = Record<string, unknown> & {
|
||||
id: string; title?: string; start?: string;
|
||||
participants?: Record<string, Participant> | null;
|
||||
recurrenceOverrides?: Record<string, Record<string, unknown>> | null;
|
||||
};
|
||||
|
||||
async function defaultCalendar(c: JmapClient): Promise<string> {
|
||||
const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]);
|
||||
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
|
||||
return (list.find((cal) => cal.isDefault) ?? list[0]).id;
|
||||
}
|
||||
|
||||
async function objects(c: JmapClient, prefix: string): Promise<Obj[]> {
|
||||
const r = await c.request([
|
||||
['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: c.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: ['id', 'title', 'start', 'participants', 'recurrenceOverrides'],
|
||||
}, 'g'],
|
||||
], [CORE, CAL]);
|
||||
return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function destroyAll(c: JmapClient, prefix: string): Promise<void> {
|
||||
const ids = (await objects(c, prefix)).map((o) => o.id);
|
||||
if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]);
|
||||
}
|
||||
|
||||
async function destroyMails(c: JmapClient, word: string): Promise<void> {
|
||||
const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 100 }, 'q']], [CORE, MAIL]);
|
||||
const ids = r.methodResponses[0][1].ids as string[];
|
||||
if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]);
|
||||
}
|
||||
|
||||
/** A's answer in a participants map (or an override's). */
|
||||
function statusOf(participants: Record<string, Participant> | null | undefined, address: string): string | undefined {
|
||||
return Object.values(participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${address}`)?.participationStatus;
|
||||
}
|
||||
|
||||
/** B invites A; returns once the invitation is in A's calendar. */
|
||||
async function invite(b: JmapClient, a: JmapClient, title: string, start: string, extra: Record<string, unknown> = {}): Promise<void> {
|
||||
const r = await b.request([['CalendarEvent/set', {
|
||||
accountId: b.accountId,
|
||||
sendSchedulingMessages: true,
|
||||
create: {
|
||||
i: {
|
||||
'@type': 'Event', title, uid: `uxfix-grid-${title.replace(/\W+/g, '-')}`, start, duration: 'PT1H', timeZone: TZ,
|
||||
calendarIds: { [await defaultCalendar(b)]: true },
|
||||
participants: {
|
||||
o: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CAL]);
|
||||
expect(r.methodResponses[0][1].created?.i?.id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
|
||||
await expect.poll(async () => (await objects(a, title)).length, { timeout: 120000 }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
/** The card of `title` on screen: the week view also renders the weeks beside it. */
|
||||
async function cardOnScreen(page: Page, title: string): Promise<Locator> {
|
||||
const cards = page.locator(`button[aria-label^="${title},"]`);
|
||||
await cards.first().waitFor({ state: 'attached', timeout: 60000 });
|
||||
const { width, height } = page.viewportSize()!;
|
||||
for (let i = 0; i < await cards.count(); i++) {
|
||||
const box = await cards.nth(i).boundingBox();
|
||||
if (box && box.x >= 0 && box.x + box.width <= width && box.y >= 0 && box.y + box.height <= height) return cards.nth(i);
|
||||
}
|
||||
throw new Error(`no card of ${title} on screen`);
|
||||
}
|
||||
|
||||
const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd');
|
||||
|
||||
test.describe('Answering invitations from the grid (pim-9)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const prefix = `UXFIX-grid ${RUN}`;
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
await destroyAll(b, prefix);
|
||||
await destroyAll(a, prefix);
|
||||
await destroyMails(a, prefix);
|
||||
await destroyMails(b, prefix);
|
||||
});
|
||||
|
||||
test('an unanswered invitation is marked in month, week and agenda, and its marker accepts it', async ({ browser }, info) => {
|
||||
test.setTimeout(300_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
// Three weeks out: the seeded days around today are full, and the month
|
||||
// view would fold the invitation into "+n more".
|
||||
const when = day(24);
|
||||
const title = `${prefix} single`;
|
||||
await invite(b, a, title, `${when}T10:00:00`);
|
||||
expect(statusOf((await objects(a, title))[0].participants, A)).toBe('needs-action');
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ, viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
|
||||
// Month: outlined, with the marker.
|
||||
await page.goto(`/calendar/month/${when}`);
|
||||
await expect(async () => {
|
||||
const card = await cardOnScreen(page, title);
|
||||
await expect(card).toHaveAttribute('data-reply-state', 'needs-action', { timeout: 2000 });
|
||||
}).toPass({ timeout: 60000 });
|
||||
expect(await (await cardOnScreen(page, title)).getAttribute('aria-label')).toContain('Not answered yet');
|
||||
|
||||
// Agenda: a hollow dot and a Reply button.
|
||||
await page.goto(`/calendar/agenda/${when}`);
|
||||
const row = page.locator('[data-agenda-day] button[data-reply-state="needs-action"]', { hasText: title });
|
||||
await expect(row).toBeVisible({ timeout: 60000 });
|
||||
await expect(row.locator('[data-agenda-dot="needs-action"]')).toHaveCount(1);
|
||||
|
||||
// Week: the marker offers the answers alone; Accept goes to the server.
|
||||
await page.goto(`/calendar/week/${when}`);
|
||||
let card!: Locator;
|
||||
await expect(async () => {
|
||||
card = await cardOnScreen(page, title);
|
||||
await expect(card).toHaveAttribute('data-reply-state', 'needs-action', { timeout: 2000 });
|
||||
}).toPass({ timeout: 60000 });
|
||||
await card.locator('xpath=..').locator('[data-reply-marker]').click();
|
||||
const items = page.getByRole('menuitem');
|
||||
await expect(items).toHaveText(['Accept', 'Maybe', 'Decline']);
|
||||
await page.getByRole('menuitem', { name: 'Accept' }).click();
|
||||
|
||||
await expect.poll(async () => statusOf((await objects(a, title))[0]?.participants, A), { timeout: 30000 }).toBe('accepted');
|
||||
// The card turns solid.
|
||||
await expect(async () => {
|
||||
expect(await (await cardOnScreen(page, title)).getAttribute('data-reply-state')).toBeNull();
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// The organiser's copy, where the server passes the reply on.
|
||||
const organiser = await expect.poll(async () => statusOf((await objects(b, title))[0]?.participants, A), { timeout: 60000 })
|
||||
.toBe('accepted').then(() => 'accepted', () => 'not updated');
|
||||
info.annotations.push({ type: 'organiser copy', description: `single, accept: ${organiser}` });
|
||||
console.log(`[grid] organiser copy after accept: ${organiser}`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('right-click on one occurrence answers maybe for it alone', async ({ browser }, info) => {
|
||||
test.setTimeout(300_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
const first = day(4);
|
||||
const second = day(11);
|
||||
const title = `${prefix} series`;
|
||||
await invite(b, a, title, `${first}T14:00:00`, {
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 },
|
||||
});
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ, viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await page.goto(`/calendar/day/${second}`);
|
||||
let card!: Locator;
|
||||
await expect(async () => {
|
||||
card = await cardOnScreen(page, title);
|
||||
await expect(card).toHaveAttribute('data-reply-state', 'needs-action', { timeout: 2000 });
|
||||
}).toPass({ timeout: 60000 });
|
||||
|
||||
await card.click({ button: 'right' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Accept' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Maybe' }).click();
|
||||
const ask = page.getByRole('dialog', { name: 'Respond to recurring event' });
|
||||
await ask.getByRole('radio', { name: 'This event only' }).check();
|
||||
await ask.getByRole('button', { name: 'Respond', exact: true }).click();
|
||||
await expect(ask).toBeHidden({ timeout: 30000 });
|
||||
|
||||
// The server: "maybe" on that occurrence, the series still unanswered.
|
||||
const key = `${second}T14:00:00`;
|
||||
await expect.poll(async () => {
|
||||
const [series] = await objects(a, title);
|
||||
const override = series?.recurrenceOverrides?.[key] as { participants?: Record<string, Participant> } & Record<string, unknown> | undefined;
|
||||
const patched = override && Object.entries(override)
|
||||
.find(([path, value]) => path.startsWith('participants/') && path.endsWith('/participationStatus') && value === 'tentative');
|
||||
return { series: statusOf(series?.participants, A), occurrence: statusOf(override?.participants, A) ?? (patched ? 'tentative' : undefined) };
|
||||
}, { timeout: 30000 }).toEqual({ series: 'needs-action', occurrence: 'tentative' });
|
||||
|
||||
// The occurrence is drawn dashed now, the others stay marked.
|
||||
await page.reload();
|
||||
await expect(async () => {
|
||||
expect(await (await cardOnScreen(page, title)).getAttribute('data-reply-state')).toBe('tentative');
|
||||
}).toPass({ timeout: 60000 });
|
||||
|
||||
const organiser = await expect.poll(async () => {
|
||||
const [series] = await objects(b, title);
|
||||
const override = series?.recurrenceOverrides?.[key] as { participants?: Record<string, Participant> } | undefined;
|
||||
return statusOf(override?.participants, A);
|
||||
}, { timeout: 60000 }).toBe('tentative').then(() => 'tentative', () => 'not updated');
|
||||
info.annotations.push({ type: 'organiser copy', description: `series, maybe on one: ${organiser}` });
|
||||
console.log(`[grid] organiser copy after maybe on one occurrence: ${organiser}`);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
addressStyle,
|
||||
addressToParts,
|
||||
countryCodeFromName,
|
||||
formatAddressLines,
|
||||
partsToComponents,
|
||||
splitStreet,
|
||||
} from '../address-format';
|
||||
import type { ContactAddress } from '@/lib/jmap/types';
|
||||
|
||||
const german: ContactAddress = {
|
||||
components: [
|
||||
{ kind: 'name', value: 'Hauptstraße' },
|
||||
{ kind: 'number', value: '1' },
|
||||
{ kind: 'postcode', value: '10000' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('address-format', () => {
|
||||
it('knows countries by code and by name in several languages', () => {
|
||||
expect(countryCodeFromName('Germany')).toBe('DE');
|
||||
expect(countryCodeFromName('Deutschland')).toBe('DE');
|
||||
expect(countryCodeFromName('Schweiz')).toBe('CH');
|
||||
expect(countryCodeFromName('de')).toBe('DE');
|
||||
expect(countryCodeFromName('USA')).toBe('US');
|
||||
expect(countryCodeFromName('United Kingdom')).toBe('GB');
|
||||
expect(countryCodeFromName('Atlantis')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the number after the street for Germany and before it for the US', () => {
|
||||
expect(addressStyle('Germany').street).toBe('number-after');
|
||||
expect(addressStyle('Österreich').street).toBe('number-after');
|
||||
expect(addressStyle('Netherlands').street).toBe('number-after');
|
||||
expect(addressStyle('United States').street).toBe('number-before');
|
||||
expect(addressStyle('France')).toEqual({ street: 'number-before', town: 'postcode-city' });
|
||||
expect(addressStyle('', 'CA').town).toBe('city-region-postcode');
|
||||
// No country: the UI language's.
|
||||
expect(addressStyle('', null, 'de').street).toBe('number-after');
|
||||
expect(addressStyle('', null, 'en').street).toBe('number-before');
|
||||
});
|
||||
|
||||
it('shows street and number in one field in the country\'s order', () => {
|
||||
expect(addressToParts(german).street).toBe('Hauptstraße 1');
|
||||
expect(addressToParts({ ...german, countryCode: 'US', components: [
|
||||
{ kind: 'number', value: '1' }, { kind: 'name', value: 'Main Street' }, { kind: 'country', value: 'USA' },
|
||||
] }).street).toBe('1 Main Street');
|
||||
// A country this file doesn't know keeps the card's own order.
|
||||
expect(addressToParts({ components: [
|
||||
{ kind: 'name', value: 'Ulitsa' }, { kind: 'number', value: '7' }, { kind: 'country', value: 'Atlantis' },
|
||||
] }).street).toBe('Ulitsa 7');
|
||||
});
|
||||
|
||||
it('splits a typed street line where the country puts the number', () => {
|
||||
expect(splitStreet('Hauptstraße 12a', 'number-after')).toEqual({ name: 'Hauptstraße', number: '12a' });
|
||||
expect(splitStreet('Am Markt 3-5', 'number-after')).toEqual({ name: 'Am Markt', number: '3-5' });
|
||||
expect(splitStreet('Am Markt', 'number-after')).toEqual({ name: 'Am Markt', number: '' });
|
||||
expect(splitStreet('221B Baker Street', 'number-before')).toEqual({ name: 'Baker Street', number: '221B' });
|
||||
expect(splitStreet('Baker Street 221', 'number-before')).toEqual({ name: 'Baker Street 221', number: '' });
|
||||
});
|
||||
|
||||
it('builds components in the country\'s order, keeping parts the form doesn\'t show', () => {
|
||||
const parts = { street: 'Hauptstraße 1', locality: 'Berlin', region: '', postcode: '10000', country: 'Germany' };
|
||||
const components = partsToComponents(parts, [{ kind: 'apartment', value: '3. OG' }]);
|
||||
expect(components.map(c => `${c.kind}:${c.value}`)).toEqual([
|
||||
'name:Hauptstraße', 'separator: ', 'number:1', 'apartment:3. OG', 'postcode:10000', 'separator: ', 'locality:Berlin', 'country:Germany',
|
||||
]);
|
||||
const us = partsToComponents({ street: '1 Main St', locality: 'Springfield', region: 'IL', postcode: '62701', country: 'USA' });
|
||||
expect(us.map(c => `${c.kind}:${c.value}`)).toEqual([
|
||||
'number:1', 'separator: ', 'name:Main St', 'locality:Springfield', 'region:IL', 'separator: ', 'postcode:62701', 'country:USA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats an address one line per part, for its country', () => {
|
||||
expect(formatAddressLines(german)).toEqual(['Hauptstraße 1', '10000 Berlin', 'Germany']);
|
||||
expect(formatAddressLines({ components: [
|
||||
{ kind: 'number', value: '1' }, { kind: 'name', value: 'Main St' }, { kind: 'locality', value: 'Springfield' },
|
||||
{ kind: 'region', value: 'IL' }, { kind: 'postcode', value: '62701' }, { kind: 'country', value: 'USA' },
|
||||
] })).toEqual(['1 Main St', 'Springfield, IL 62701', 'USA']);
|
||||
expect(formatAddressLines({ street: '10 Downing Street', locality: 'London', postcode: 'SW1A 2AA', country: 'United Kingdom' }))
|
||||
.toEqual(['10 Downing Street', 'London', 'SW1A 2AA', 'United Kingdom']);
|
||||
expect(formatAddressLines({ full: 'Somewhere 1\n12345 Town' })).toEqual(['Somewhere 1', '12345 Town']);
|
||||
});
|
||||
});
|
||||
@@ -655,9 +655,10 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
else process.env.TZ = originalTZ;
|
||||
});
|
||||
|
||||
// What next-intl's format.dateTime does with the provider's display zone.
|
||||
const formatIn = (timeZone: string) => (date: Date, options: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat('en-US', { timeZone, ...options }).format(date);
|
||||
// The banner's options for a user in `timeZone`. Intl writes ranges with
|
||||
// thin spaces around the dash; the expectations use plain ones.
|
||||
const formatIn = (timeZone: string, hour12 = false) => ({ locale: 'en-US', timeZone, hour12 });
|
||||
const plain = (text: string) => text.replace(/[\u2009\u202f]/g, ' ');
|
||||
|
||||
// CalendarEvent/parse output for the Exchange invite in the issue.
|
||||
const londonInvite: Partial<CalendarEvent> = {
|
||||
@@ -674,12 +675,27 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
expect(summary.isFloating).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the invite in the viewer zone with a zone label', () => {
|
||||
it('shows the invite in the viewer zone, the date once, and its own times after it (pim-23)', () => {
|
||||
const summary = formatEventSummary(londonInvite);
|
||||
expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
|
||||
.toBe('Tue, Oct 20, 16:00 – Tue, Oct 20, 16:25 GMT+2');
|
||||
expect(formatInvitationSchedule(summary, formatIn('America/New_York'), true))
|
||||
.toBe('Tue, Oct 20, 10:00 AM – Tue, Oct 20, 10:25 AM EDT');
|
||||
expect(plain(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'))))
|
||||
.toBe('Tue, Oct 20, 16:00 – 16:25 (15:00 – 15:25 London)');
|
||||
expect(plain(formatInvitationSchedule(summary, formatIn('America/New_York', true))))
|
||||
.toBe('Tue, Oct 20, 10:00 – 10:25 AM (3:00 – 3:25 PM London)');
|
||||
});
|
||||
|
||||
it('shows no zone for an invite on the viewer clock (pim-23)', () => {
|
||||
const berlin = formatEventSummary({ title: 'Sprint planning', start: '2026-11-05T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H30M' });
|
||||
expect(plain(formatInvitationSchedule(berlin, formatIn('Europe/Berlin')))).toBe('Thu, Nov 5, 10:00 – 11:30');
|
||||
// Another zone with the same clock that day says nothing either.
|
||||
expect(plain(formatInvitationSchedule(berlin, formatIn('Europe/Paris')))).toBe('Thu, Nov 5, 10:00 – 11:30');
|
||||
});
|
||||
|
||||
it('gives the event zone its date when its day there is another (pim-23)', () => {
|
||||
const tokyo = formatEventSummary({ start: '2026-11-06T08:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' });
|
||||
expect(plain(formatInvitationSchedule(tokyo, formatIn('Europe/Berlin'))))
|
||||
.toBe('Fri, Nov 6, 00:00 – 01:00 (08:00 – 09:00 Tokyo)');
|
||||
expect(plain(formatInvitationSchedule(tokyo, formatIn('America/New_York'))))
|
||||
.toBe('Thu, Nov 5, 18:00 – 19:00 (Fri, Nov 6, 08:00 – 09:00 Tokyo)');
|
||||
});
|
||||
|
||||
it('treats a UTC start as UTC', () => {
|
||||
@@ -698,14 +714,14 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
it('shows floating times as the same wall clock everywhere, without a zone label', () => {
|
||||
const summary = formatEventSummary({ start: '2026-10-20T15:00:00', timeZone: null, duration: 'PT30M' });
|
||||
expect(summary.isFloating).toBe(true);
|
||||
expect(formatInvitationSchedule(summary, formatIn('Asia/Tokyo'), false))
|
||||
.toBe('Tue, Oct 20, 15:00 – Tue, Oct 20, 15:30');
|
||||
expect(plain(formatInvitationSchedule(summary, formatIn('Asia/Tokyo'))))
|
||||
.toBe('Tue, Oct 20, 15:00 – 15:30');
|
||||
});
|
||||
|
||||
it('does not shift all-day dates in zones west or east of UTC', () => {
|
||||
const oneDay = formatEventSummary({ start: '2026-03-16T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: 'Europe/London' });
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16');
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'), false)).toBe('Mon, Mar 16');
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('America/Los_Angeles'))).toBe('Mon, Mar 16');
|
||||
expect(formatInvitationSchedule(oneDay, formatIn('Pacific/Auckland'))).toBe('Mon, Mar 16');
|
||||
|
||||
const threeDays = formatEventSummary({
|
||||
start: '2026-03-16T00:00:00',
|
||||
@@ -713,7 +729,7 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
utcStart: '2026-03-16T00:00:00Z',
|
||||
utcEnd: '2026-03-19T00:00:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles'), false)).toBe('Mon, Mar 16 – Wed, Mar 18');
|
||||
expect(plain(formatInvitationSchedule(threeDays, formatIn('America/Los_Angeles')))).toBe('Mon, Mar 16 – Wed, Mar 18');
|
||||
});
|
||||
|
||||
it('shows only the start for a zero-duration event', () => {
|
||||
@@ -723,8 +739,8 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
utcStart: '2026-10-20T14:00:00Z',
|
||||
utcEnd: '2026-10-20T14:00:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'), false))
|
||||
.toBe('Tue, Oct 20, 16:00 GMT+2');
|
||||
expect(plain(formatInvitationSchedule(summary, formatIn('Europe/Warsaw'))))
|
||||
.toBe('Tue, Oct 20, 16:00 (15:00 London)');
|
||||
});
|
||||
|
||||
it('still prefers the server utcStart/utcEnd when present', () => {
|
||||
@@ -733,7 +749,7 @@ describe('invitation times across time zones (#1252)', () => {
|
||||
utcStart: '2026-10-20T14:00:00Z',
|
||||
utcEnd: '2026-10-20T14:25:00Z',
|
||||
});
|
||||
expect(formatInvitationSchedule(summary, formatIn('UTC'), false))
|
||||
.toBe('Tue, Oct 20, 14:00 – Tue, Oct 20, 14:25 UTC');
|
||||
expect(plain(formatInvitationSchedule(summary, formatIn('UTC'))))
|
||||
.toBe('Tue, Oct 20, 14:00 – 14:25 (15:00 – 15:25 London)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { findUploadConflicts, resolveConflictsInTurn, type UploadConflict } from '@/lib/files-upload-conflicts';
|
||||
|
||||
describe('findUploadConflicts', () => {
|
||||
it('lists same-kind clashes once, in upload order', () => {
|
||||
const incoming: UploadConflict[] = [
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
{ name: 'Photos', kind: 'folder' },
|
||||
{ name: 'new.txt', kind: 'file' },
|
||||
];
|
||||
const existing = [
|
||||
{ name: 'Docs', isDirectory: true },
|
||||
{ name: 'b.txt', isDirectory: false },
|
||||
{ name: 'Photos', isDirectory: false },
|
||||
];
|
||||
expect(findUploadConflicts(incoming, existing)).toEqual([
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflictsInTurn', () => {
|
||||
const conflicts: UploadConflict[] = [
|
||||
{ name: 'a.txt', kind: 'file' },
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'c.txt', kind: 'file' },
|
||||
];
|
||||
|
||||
it('asks one at a time, and "Do this for all" answers the rest', async () => {
|
||||
const ask = vi.fn()
|
||||
.mockResolvedValueOnce({ choice: 'skip', applyToAll: false })
|
||||
.mockResolvedValueOnce({ choice: 'replace', applyToAll: true });
|
||||
const choices = await resolveConflictsInTurn(conflicts, ask);
|
||||
expect(ask).toHaveBeenCalledTimes(2);
|
||||
expect(ask.mock.calls.map((c) => [c[0].name, c[1], c[2]])).toEqual([['a.txt', 0, 3], ['b.txt', 1, 3]]);
|
||||
expect([...choices!]).toEqual([['a.txt', 'skip'], ['b.txt', 'replace'], ['c.txt', 'replace']]);
|
||||
});
|
||||
|
||||
it('stops when a question is called off', async () => {
|
||||
const ask = vi.fn().mockResolvedValueOnce({ choice: 'keep', applyToAll: false }).mockResolvedValueOnce(null);
|
||||
expect(await resolveConflictsInTurn(conflicts, ask)).toBeNull();
|
||||
expect(ask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { followingSeriesData, seriesUpdatesFromOccurrence, type EventEditorState } from '@/lib/recurrence-scope-edit';
|
||||
import { getEventEndDate, getEventStartDate } from '@/lib/calendar-utils';
|
||||
import { AUTO_TIME_ZONE, fromWallClock, toDisplayDate } from '@/lib/timezone';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// pim-2: a series moved to another zone with the editor's time zone picker
|
||||
// goes on at the same time on the new zone's clock, its overrides on their
|
||||
// keys. Without the picker, a series keeps its own zone.
|
||||
|
||||
// Mondays at 08:00 in New York from 5 October; 19 October has a title of its own.
|
||||
const master = {
|
||||
id: 'b', uid: 'weekly@example.org', '@type': 'Event', title: 'Planning', description: '',
|
||||
start: '2026-10-05T08:00:00', duration: 'PT1H', timeZone: 'America/New_York', showWithoutTime: false,
|
||||
utcStart: '2026-10-05T12:00:00Z', utcEnd: '2026-10-05T13:00:00Z', calendarIds: { cal: true },
|
||||
participants: null, alerts: null,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }],
|
||||
recurrenceOverrides: { '2026-10-19T08:00:00': { title: 'Planning, budget' } },
|
||||
} as unknown as CalendarEvent;
|
||||
const occurrence = {
|
||||
...master, id: 'maaaaac', baseEventId: 'b', start: '2026-10-12T08:00:00', recurrenceId: '2026-10-12T08:00:00',
|
||||
recurrenceIdTimeZone: 'America/New_York', utcStart: '2026-10-12T12:00:00Z', utcEnd: '2026-10-12T13:00:00Z',
|
||||
} as CalendarEvent;
|
||||
|
||||
const updates = (change: Partial<CalendarEvent>): Partial<CalendarEvent> => ({
|
||||
title: 'Planning', description: '', start: '2026-10-12T08:00:00', duration: 'PT1H', timeZone: 'America/New_York',
|
||||
showWithoutTime: false, calendarIds: { cal: true }, recurrenceRules: master.recurrenceRules, recurrenceId: occurrence.recurrenceId,
|
||||
...change,
|
||||
});
|
||||
const editor = (when: EventEditorState['when'], change: Partial<EventEditorState> = {}): EventEditorState => ({
|
||||
initialAttendees: [], attendees: [], when, ...change,
|
||||
});
|
||||
const unmoved = () => ({ allDay: false, start: getEventStartDate(occurrence), end: getEventEndDate(occurrence) });
|
||||
// 08:00-09:00 on 12 October in Tokyo, as a display date.
|
||||
const inTokyo = () => ({
|
||||
allDay: false,
|
||||
start: toDisplayDate(fromWallClock({ year: 2026, month: 10, day: 12, hour: 8, minute: 0, second: 0 }, 'Asia/Tokyo')),
|
||||
end: toDisplayDate(fromWallClock({ year: 2026, month: 10, day: 12, hour: 9, minute: 0, second: 0 }, 'Asia/Tokyo')),
|
||||
});
|
||||
const context = { userEmail: 'me@example.org', userEmails: ['me@example.org'] };
|
||||
|
||||
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
|
||||
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
|
||||
|
||||
describe('a series and its time zone (pim-2)', () => {
|
||||
it('keeps its zone and start when every occurrence gets a new title', () => {
|
||||
const series = seriesUpdatesFromOccurrence(occurrence, master, updates({ title: 'Planning, room 2' }), editor(unmoved()), context);
|
||||
expect(series).toEqual({ title: 'Planning, room 2' });
|
||||
});
|
||||
|
||||
it('goes on at the same time in the zone the user picked, overrides on their keys', () => {
|
||||
const series = seriesUpdatesFromOccurrence(
|
||||
occurrence, master, updates({ timeZone: 'Asia/Tokyo' }), editor(inTokyo(), { timeZoneChanged: true }), context,
|
||||
);
|
||||
expect(series.timeZone).toBe('Asia/Tokyo');
|
||||
expect(series.start ?? master.start).toBe('2026-10-05T08:00:00');
|
||||
expect(series.recurrenceOverrides ?? master.recurrenceOverrides).toHaveProperty(['2026-10-19T08:00:00']);
|
||||
});
|
||||
|
||||
it('moves with the clock when the user also changed the time', () => {
|
||||
const when = inTokyo();
|
||||
when.start = new Date(when.start.getTime() + 3_600_000);
|
||||
when.end = new Date(when.end.getTime() + 3_600_000);
|
||||
const series = seriesUpdatesFromOccurrence(
|
||||
occurrence, master, updates({ timeZone: 'Asia/Tokyo', start: '2026-10-12T09:00:00' }), editor(when, { timeZoneChanged: true }), context,
|
||||
);
|
||||
expect(series).toMatchObject({ start: '2026-10-05T09:00:00', timeZone: 'Asia/Tokyo' });
|
||||
});
|
||||
|
||||
it('starts "this and following" in the picked zone at the occurrence time', () => {
|
||||
const data = followingSeriesData(occurrence, master, updates({ timeZone: 'Asia/Tokyo' }), master.recurrenceRules, {
|
||||
editor: editor(inTokyo(), { timeZoneChanged: true }),
|
||||
overrides: { '2026-10-19T08:00:00': { title: 'Planning, budget' } } as CalendarEvent['recurrenceOverrides'] & object,
|
||||
});
|
||||
expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'Asia/Tokyo' });
|
||||
expect(data.recurrenceOverrides).toHaveProperty(['2026-10-19T08:00:00']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTaskDue } from '../task-due-parser';
|
||||
|
||||
// Wednesday, 4 November 2026.
|
||||
const today = new Date(2026, 10, 4, 9, 30);
|
||||
|
||||
describe('parseTaskDue (pim-24)', () => {
|
||||
it('takes "tomorrow" off the end of the title as the due date', () => {
|
||||
expect(parseTaskDue('Call the plumber tomorrow', 'en', today)).toEqual({
|
||||
title: 'Call the plumber',
|
||||
due: '2026-11-05',
|
||||
matched: 'tomorrow',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes today and a weekday, with its "on"', () => {
|
||||
expect(parseTaskDue('Pay rent today', 'en', today)?.due).toBe('2026-11-04');
|
||||
expect(parseTaskDue('Send the report on Friday', 'en', today)).toEqual({
|
||||
title: 'Send the report',
|
||||
due: '2026-11-06',
|
||||
matched: 'on Friday',
|
||||
});
|
||||
// A weekday already past this week is next week's.
|
||||
expect(parseTaskDue('Team lunch Monday', 'en', today)?.due).toBe('2026-11-09');
|
||||
});
|
||||
|
||||
it('reads the words of the app language from Intl', () => {
|
||||
expect(parseTaskDue('Klempner anrufen morgen', 'de', today)).toMatchObject({ title: 'Klempner anrufen', due: '2026-11-05' });
|
||||
expect(parseTaskDue('Bericht schicken am Freitag', 'de', today)).toMatchObject({ title: 'Bericht schicken', due: '2026-11-06' });
|
||||
expect(parseTaskDue('Steuer übermorgen', 'de', today)?.due).toBe('2026-11-06');
|
||||
expect(parseTaskDue('Appeler le plombier demain', 'fr', today)).toMatchObject({ title: 'Appeler le plombier', due: '2026-11-05' });
|
||||
expect(parseTaskDue('Llamar al fontanero mañana', 'es', today)?.due).toBe('2026-11-05');
|
||||
expect(parseTaskDue('Позвонить сантехнику завтра', 'ru', today)?.due).toBe('2026-11-05');
|
||||
expect(parseTaskDue('配管工に電話 明日', 'ja', today)).toMatchObject({ title: '配管工に電話', due: '2026-11-05' });
|
||||
expect(parseTaskDue('给水管工打电话明天', 'zh', today)).toMatchObject({ title: '给水管工打电话', due: '2026-11-05' });
|
||||
// English works in every language.
|
||||
expect(parseTaskDue('Klempner anrufen tomorrow', 'de', today)?.due).toBe('2026-11-05');
|
||||
});
|
||||
|
||||
it('reads numeric dates in the order the locale writes them', () => {
|
||||
expect(parseTaskDue('Dentist 12.11.', 'de', today)).toMatchObject({ title: 'Dentist', due: '2026-11-12' });
|
||||
expect(parseTaskDue('Dentist 12.11.2027', 'de', today)?.due).toBe('2027-11-12');
|
||||
expect(parseTaskDue('Dentist 11/12', 'en-US', today)?.due).toBe('2026-11-12');
|
||||
expect(parseTaskDue('Dentist 11/12', 'fr', today)?.due).toBe('2026-12-11');
|
||||
expect(parseTaskDue('Dentist 2026-12-01', 'en', today)?.due).toBe('2026-12-01');
|
||||
// A day already past without a year is next year's.
|
||||
expect(parseTaskDue('Dentist 1.11.', 'de', today)?.due).toBe('2027-11-01');
|
||||
});
|
||||
|
||||
it('leaves titles without a date at the end alone', () => {
|
||||
expect(parseTaskDue('Tomorrow call the plumber', 'en', today)).toBeNull();
|
||||
expect(parseTaskDue('Fix the today page', 'en', today)).toBeNull();
|
||||
expect(parseTaskDue('tomorrow', 'en', today)).toBeNull();
|
||||
expect(parseTaskDue('Version 31.13', 'de', today)).toBeNull();
|
||||
expect(parseTaskDue('Buy 2 apples', 'en', today)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { AddressComponent, ContactAddress } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* Postal addresses laid out the way the address's country writes them.
|
||||
*
|
||||
* Two things differ between countries here: whether the house number comes
|
||||
* after the street name ("Hauptstraße 1") or before it ("1 Main Street"), and
|
||||
* whether the postcode leads the town line ("10115 Berlin", "75001 Paris"),
|
||||
* follows it ("Springfield, IL 62701") or has a line of its own (UK).
|
||||
*/
|
||||
|
||||
export type StreetOrder = "number-after" | "number-before";
|
||||
export type TownLine = "postcode-city" | "city-region-postcode" | "city-then-postcode";
|
||||
|
||||
export interface AddressStyle {
|
||||
street: StreetOrder;
|
||||
town: TownLine;
|
||||
}
|
||||
|
||||
const NUMBER_AFTER = [
|
||||
"DE", "AT", "CH", "LI", "NL", "BE", "IT", "SM", "VA", "ES", "AD", "PT", "DK", "IS", "NO", "SE", "FI",
|
||||
"PL", "CZ", "SK", "HU", "SI", "HR", "BA", "RS", "ME", "MK", "AL", "RO", "BG", "GR", "CY", "TR",
|
||||
"EE", "LV", "LT", "RU", "UA", "BY", "MX", "AR", "BR", "CL", "UY", "PY", "BO", "PE", "VE", "EC",
|
||||
];
|
||||
const NUMBER_BEFORE = ["US", "CA", "GB", "IE", "FR", "MC", "LU", "AU", "NZ", "ZA", "IN", "SG", "PH", "MY", "IL", "HK"];
|
||||
const CITY_REGION_POSTCODE = ["US", "CA", "AU"];
|
||||
const CITY_THEN_POSTCODE = ["GB", "IE"];
|
||||
|
||||
const KNOWN_CODES = [...NUMBER_AFTER, ...NUMBER_BEFORE];
|
||||
|
||||
/** The style for an ISO 3166 code, or null for a country this file doesn't know. */
|
||||
export function styleForCountryCode(code: string | undefined | null): AddressStyle | null {
|
||||
const cc = code?.trim().toUpperCase();
|
||||
if (!cc) return null;
|
||||
const c = cc === "UK" ? "GB" : cc;
|
||||
if (!KNOWN_CODES.includes(c)) return null;
|
||||
return {
|
||||
street: NUMBER_AFTER.includes(c) ? "number-after" : "number-before",
|
||||
town: CITY_REGION_POSTCODE.includes(c) ? "city-region-postcode"
|
||||
: CITY_THEN_POSTCODE.includes(c) ? "city-then-postcode"
|
||||
: "postcode-city",
|
||||
};
|
||||
}
|
||||
|
||||
// Country names as people type them: each known code's name in these
|
||||
// languages, plus a few short forms.
|
||||
const NAME_LOCALES = ["en", "de", "fr", "es", "it", "nl", "pt", "pl", "sv", "da", "nb", "fi", "cs", "sk", "hu", "tr", "ru", "uk", "ro", "el", "hr", "sl", "et", "lv", "lt", "ca"];
|
||||
const ALIASES: Record<string, string> = {
|
||||
usa: "US", "u.s.a.": "US", "u.s.": "US", "united states of america": "US", america: "US",
|
||||
uk: "GB", "u.k.": "GB", england: "GB", scotland: "GB", wales: "GB", "great britain": "GB", "northern ireland": "GB",
|
||||
holland: "NL", brd: "DE", "bundesrepublik deutschland": "DE", "the netherlands": "NL",
|
||||
"schweiz": "CH", "suisse": "CH", "svizzera": "CH", "österreich": "AT", "osterreich": "AT",
|
||||
};
|
||||
let nameIndex: Map<string, string> | null = null;
|
||||
|
||||
function normalise(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function countryNameIndex(): Map<string, string> {
|
||||
if (nameIndex) return nameIndex;
|
||||
const index = new Map<string, string>();
|
||||
for (const [alias, code] of Object.entries(ALIASES)) index.set(alias, code);
|
||||
for (const locale of NAME_LOCALES) {
|
||||
let names: Intl.DisplayNames;
|
||||
try {
|
||||
names = new Intl.DisplayNames([locale], { type: "region" });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const code of KNOWN_CODES) {
|
||||
const name = names.of(code);
|
||||
if (name && name !== code) {
|
||||
const key = normalise(name);
|
||||
if (!index.has(key)) index.set(key, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
nameIndex = index;
|
||||
return index;
|
||||
}
|
||||
|
||||
/** The ISO code for a typed country ("Germany", "Deutschland", "DE"), or null. */
|
||||
export function countryCodeFromName(country: string | undefined | null): string | null {
|
||||
const key = normalise(country ?? "");
|
||||
if (!key) return null;
|
||||
if (/^[a-z]{2}$/.test(key)) {
|
||||
const code = key.toUpperCase() === "UK" ? "GB" : key.toUpperCase();
|
||||
return KNOWN_CODES.includes(code) ? code : null;
|
||||
}
|
||||
return countryNameIndex().get(key) ?? null;
|
||||
}
|
||||
|
||||
// The country a UI language most likely means, for addresses without one.
|
||||
const LANGUAGE_COUNTRY: Record<string, string> = {
|
||||
de: "DE", nl: "NL", it: "IT", es: "ES", ca: "ES", pt: "PT", da: "DK", nb: "NO", sv: "SE", fi: "FI",
|
||||
pl: "PL", cs: "CZ", sk: "SK", hu: "HU", ro: "RO", tr: "TR", et: "EE", lv: "LV", lt: "LT", ru: "RU",
|
||||
uk: "UA", el: "GR", fr: "FR", en: "US",
|
||||
};
|
||||
|
||||
/**
|
||||
* The style for an address: from its country code, else its country's name,
|
||||
* else the UI language's country, else US style.
|
||||
*/
|
||||
export function addressStyle(country: string | undefined | null, countryCode?: string | null, locale?: string): AddressStyle {
|
||||
return styleForCountryCode(countryCode)
|
||||
?? styleForCountryCode(countryCodeFromName(country))
|
||||
?? styleForCountryCode(LANGUAGE_COUNTRY[(locale ?? "").split(/[-_]/)[0].toLowerCase()])
|
||||
?? { street: "number-before", town: "city-region-postcode" };
|
||||
}
|
||||
|
||||
/** "Hauptstraße" + "1" as the country writes it. */
|
||||
export function joinStreet(name: string, number: string, order: StreetOrder): string {
|
||||
const parts = order === "number-after" ? [name, number] : [number, name];
|
||||
return parts.map(p => p.trim()).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const HOUSE_NUMBER = String.raw`\d+\s?[a-zA-Z]?(?:\s?[-–/]\s?\d+\s?[a-zA-Z]?)?`;
|
||||
const NUMBER_LAST = new RegExp(String.raw`^(.*\S)\s+(${HOUSE_NUMBER})$`);
|
||||
const NUMBER_FIRST = new RegExp(String.raw`^(${HOUSE_NUMBER})\s+(\S.*)$`);
|
||||
|
||||
/**
|
||||
* Splits a typed street line into street name and house number where the
|
||||
* number sits where the country puts it. Anything else stays whole as the
|
||||
* name ("Am Markt", "Hauptstraße 1, Hinterhaus").
|
||||
*/
|
||||
export function splitStreet(line: string, order: StreetOrder): { name: string; number: string } {
|
||||
const text = line.trim().replace(/\s+/g, " ");
|
||||
if (order === "number-after") {
|
||||
const m = NUMBER_LAST.exec(text);
|
||||
if (m) return { name: m[1].replace(/,$/, ""), number: m[2] };
|
||||
} else {
|
||||
const m = NUMBER_FIRST.exec(text);
|
||||
if (m) return { name: m[2], number: m[1].replace(/,$/, "") };
|
||||
}
|
||||
return { name: text, number: "" };
|
||||
}
|
||||
|
||||
/** Kinds the contact form shows as fields of their own. */
|
||||
const FORM_KINDS = new Set(["name", "number", "locality", "region", "postcode", "country", "separator"]);
|
||||
/** Kinds that belong on the street line ("Hauptstraße 1, 3. OG"). */
|
||||
const STREET_EXTRAS = new Set(["apartment", "room", "floor", "building", "block", "direction", "landmark"]);
|
||||
|
||||
function valuesOf(components: AddressComponent[], kind: string): string {
|
||||
return components.filter(c => c.kind === kind).map(c => c.value?.trim()).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** The parts of an address as the contact form edits them. */
|
||||
export interface AddressParts {
|
||||
street: string;
|
||||
locality: string;
|
||||
region: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's fields for an address. Street name and number become one line in
|
||||
* the order of the address's country; for a country not known here, in the
|
||||
* order the components have.
|
||||
*/
|
||||
export function addressToParts(address: ContactAddress, locale?: string): AddressParts {
|
||||
const components = address.components ?? [];
|
||||
if (components.length === 0) {
|
||||
return {
|
||||
street: address.street ?? "",
|
||||
locality: address.locality ?? "",
|
||||
region: address.region ?? "",
|
||||
postcode: address.postcode ?? "",
|
||||
country: address.country ?? "",
|
||||
};
|
||||
}
|
||||
const country = valuesOf(components, "country");
|
||||
const name = valuesOf(components, "name");
|
||||
const number = valuesOf(components, "number");
|
||||
const known = styleForCountryCode(address.countryCode) ?? styleForCountryCode(countryCodeFromName(country));
|
||||
let order: StreetOrder;
|
||||
if (known) order = known.street;
|
||||
else {
|
||||
const nameAt = components.findIndex(c => c.kind === "name");
|
||||
const numberAt = components.findIndex(c => c.kind === "number");
|
||||
order = nameAt >= 0 && numberAt >= 0
|
||||
? (numberAt > nameAt ? "number-after" : "number-before")
|
||||
: addressStyle(country, null, locale).street;
|
||||
}
|
||||
return {
|
||||
street: joinStreet(name, number, order),
|
||||
locality: valuesOf(components, "locality"),
|
||||
region: valuesOf(components, "region"),
|
||||
postcode: valuesOf(components, "postcode"),
|
||||
country,
|
||||
};
|
||||
}
|
||||
|
||||
const sep = (value: string): AddressComponent => ({ kind: "separator", value });
|
||||
|
||||
/**
|
||||
* RFC 9553 components for edited form fields, in the country's order, with
|
||||
* separators inside a line and `defaultSeparator` (", ") between lines.
|
||||
* Components the form doesn't show (apartment, district, PO box...) are kept
|
||||
* from `previous`.
|
||||
*/
|
||||
export function partsToComponents(parts: AddressParts, previous: AddressComponent[] = [], locale?: string, countryCode?: string | null): AddressComponent[] {
|
||||
const style = addressStyle(parts.country, countryCode, locale);
|
||||
const { name, number } = splitStreet(parts.street, style.street);
|
||||
const extras = previous.filter(c => !FORM_KINDS.has(c.kind) && c.value?.trim());
|
||||
const comp = (kind: string, value: string): AddressComponent[] => value.trim() ? [{ kind, value: value.trim() }] : [];
|
||||
const line = (...groups: AddressComponent[][]): AddressComponent[] => {
|
||||
const filled = groups.filter(g => g.length > 0);
|
||||
return filled.flatMap((g, i) => (i === 0 ? g : [sep(" "), ...g]));
|
||||
};
|
||||
|
||||
const streetLine = style.street === "number-after"
|
||||
? line(comp("name", name), comp("number", number))
|
||||
: line(comp("number", number), comp("name", name));
|
||||
const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind));
|
||||
const otherExtras = extras.filter(c => !STREET_EXTRAS.has(c.kind));
|
||||
|
||||
const lines: AddressComponent[][] = [streetLine, ...streetExtras.map(c => [c]), ...otherExtras.map(c => [c])];
|
||||
if (style.town === "postcode-city") {
|
||||
lines.push(line(comp("postcode", parts.postcode), comp("locality", parts.locality)), comp("region", parts.region));
|
||||
} else if (style.town === "city-region-postcode") {
|
||||
lines.push(comp("locality", parts.locality), line(comp("region", parts.region), comp("postcode", parts.postcode)));
|
||||
} else {
|
||||
lines.push(comp("locality", parts.locality), comp("region", parts.region), comp("postcode", parts.postcode));
|
||||
}
|
||||
lines.push(comp("country", parts.country));
|
||||
// Lines follow each other through defaultSeparator, so no separator between them.
|
||||
return lines.filter(l => l.length > 0).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* An address as lines to show, laid out for its country: "Hauptstraße 1" /
|
||||
* "10000 Berlin" / "Germany". Addresses without components show their `full`
|
||||
* text or their flat fields.
|
||||
*/
|
||||
export function formatAddressLines(address: ContactAddress, locale?: string): string[] {
|
||||
const components = (address.components ?? []).filter(c => c.kind !== "separator" && c.value?.trim());
|
||||
if (components.length === 0) {
|
||||
const full = address.full || address.fullAddress;
|
||||
if (full) return full.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
}
|
||||
const parts = addressToParts(address, locale);
|
||||
const style = addressStyle(parts.country, address.countryCode, locale);
|
||||
const lines: string[] = [];
|
||||
const extras = components.filter(c => !FORM_KINDS.has(c.kind));
|
||||
const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind)).map(c => c.value.trim());
|
||||
const street = [parts.street, ...streetExtras].filter(Boolean).join(", ");
|
||||
if (street) lines.push(street);
|
||||
for (const c of extras.filter(c => !STREET_EXTRAS.has(c.kind))) lines.push(c.value.trim());
|
||||
const join = (...xs: string[]) => xs.map(x => x.trim()).filter(Boolean).join(" ");
|
||||
if (style.town === "postcode-city") {
|
||||
lines.push(join(parts.postcode, parts.locality), parts.region);
|
||||
} else if (style.town === "city-region-postcode") {
|
||||
const regionPostcode = join(parts.region, parts.postcode);
|
||||
lines.push([parts.locality.trim(), regionPostcode].filter(Boolean).join(", "));
|
||||
} else {
|
||||
lines.push(parts.locality, parts.region, parts.postcode);
|
||||
}
|
||||
lines.push(parts.country);
|
||||
return lines.map(l => l.trim()).filter(Boolean);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user