mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show booking calendar state across cards, say when a plugin stops a change and make the calendar picker keyboard friendly
This commit is contained in:
@@ -77,7 +77,11 @@ beforeEach(() => {
|
||||
vi.setSystemTime(NOW);
|
||||
client.queryCalendarEvents.mockReset();
|
||||
calendarHolds([]);
|
||||
createEvent.mockReset().mockImplementation(async (_client, event: Partial<CalendarEvent>) => ({ ...event, id: `new-${event.uid}` }));
|
||||
createEvent.mockReset().mockImplementation(async (_client, event: Partial<CalendarEvent>) => {
|
||||
const created = { ...event, id: `new-${event.uid}` } as CalendarEvent;
|
||||
useCalendarStore.setState((s) => ({ events: [...s.events, created] }));
|
||||
return created;
|
||||
});
|
||||
updateEvent.mockReset().mockResolvedValue(true);
|
||||
deleteEvent.mockReset().mockResolvedValue(true);
|
||||
setCalendarGate(true);
|
||||
@@ -148,6 +152,9 @@ describe('useBookingCalendar', () => {
|
||||
const [, id, patch, scheduling] = updateEvent.mock.calls[0];
|
||||
expect(id).toBe('e1');
|
||||
expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' });
|
||||
// The stored description isn't the generated one any more: title and notes stay the user's.
|
||||
expect(patch).not.toHaveProperty('title');
|
||||
expect(patch).not.toHaveProperty('description');
|
||||
expect(scheduling).toBeUndefined();
|
||||
expect(result.current.status).toBe('in-calendar');
|
||||
});
|
||||
@@ -241,12 +248,60 @@ describe('useBookingCalendar', () => {
|
||||
});
|
||||
|
||||
it('says why an add failed', async () => {
|
||||
createEvent.mockResolvedValue(null);
|
||||
createEvent.mockImplementation(async () => {
|
||||
useCalendarStore.setState({ error: 'Failed to create event' });
|
||||
return null;
|
||||
});
|
||||
const { result } = await settled(await bookingFrom([OUT]));
|
||||
await act(() => result.current.add());
|
||||
expect(result.current.error).toBe('action_failed');
|
||||
expect(result.current.status).toBe('add');
|
||||
});
|
||||
|
||||
it('says so when a plugin stops an add, an update or a removal', async () => {
|
||||
createEvent.mockResolvedValue(null);
|
||||
const fresh = await settled(await bookingFrom([OUT]));
|
||||
await act(() => fresh.result.current.add());
|
||||
expect(fresh.result.current).toMatchObject({ notice: 'blocked', error: null, status: 'add' });
|
||||
|
||||
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
|
||||
calendarHolds([stored(old, 'e1')]);
|
||||
updateEvent.mockResolvedValue(false);
|
||||
const moved = await settled(await bookingFrom([OUT_MOVED]));
|
||||
await act(() => moved.result.current.update());
|
||||
expect(moved.result.current).toMatchObject({ notice: 'blocked', status: 'update' });
|
||||
|
||||
deleteEvent.mockResolvedValue(false);
|
||||
const cancelled = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||
await act(() => cancelled.result.current.remove());
|
||||
expect(cancelled.result.current).toMatchObject({ notice: 'blocked', status: 'remove' });
|
||||
});
|
||||
|
||||
it('shows a booking added from another card for the same booking as in the calendar', async () => {
|
||||
const booking = await bookingFrom([OUT]);
|
||||
const first = await settled(booking);
|
||||
const second = await settled(await bookingFrom([OUT]));
|
||||
expect(first.result.current.status).toBe('add');
|
||||
expect(second.result.current.status).toBe('add');
|
||||
await act(() => second.result.current.add());
|
||||
expect(second.result.current.status).toBe('in-calendar');
|
||||
await waitFor(() => expect(first.result.current.status).toBe('in-calendar'));
|
||||
expect(createEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows a booking removed from another card as gone', async () => {
|
||||
const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' });
|
||||
calendarHolds([stored(old, 'e1')]);
|
||||
deleteEvent.mockImplementation(async (_client, id: string) => {
|
||||
useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) }));
|
||||
return true;
|
||||
});
|
||||
const first = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||
const second = await settled(await bookingFrom([OUT_CANCELLED]));
|
||||
expect(first.result.current.status).toBe('remove');
|
||||
await act(() => second.result.current.remove());
|
||||
await waitFor(() => expect(first.result.current.status).toBe('cancelled'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingCalendarActions', () => {
|
||||
@@ -272,6 +327,53 @@ describe('BookingCalendarActions', () => {
|
||||
expect(await screen.findByText('added')).toBeTruthy();
|
||||
});
|
||||
|
||||
async function openPicker() {
|
||||
useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work'), cal('Travel')] });
|
||||
render(<BookingCalendarActions booking={await bookingFrom([OUT])} email={EMAIL} />);
|
||||
const trigger = await screen.findByRole('button', { name: /add_to_calendar/ });
|
||||
fireEvent.click(trigger);
|
||||
return { trigger, menu: screen.getByRole('menu') };
|
||||
}
|
||||
|
||||
it('moves focus into the picker and through it with the arrow keys, Home and End', async () => {
|
||||
const { menu } = await openPicker();
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
fireEvent.keyDown(menu, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(items[1]);
|
||||
fireEvent.keyDown(menu, { key: 'End' });
|
||||
expect(document.activeElement).toBe(items[2]);
|
||||
fireEvent.keyDown(menu, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
fireEvent.keyDown(menu, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(items[2]);
|
||||
fireEvent.keyDown(menu, { key: 'Home' });
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(menu.querySelector('div')?.getAttribute('role')).toBe('presentation');
|
||||
});
|
||||
|
||||
it('closes the picker on Escape and gives focus back to its button', async () => {
|
||||
const { trigger, menu } = await openPicker();
|
||||
fireEvent.keyDown(menu, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('stays open while its own list scrolls, and closes when the page scrolls', async () => {
|
||||
const { menu } = await openPicker();
|
||||
fireEvent.scroll(menu);
|
||||
expect(screen.queryByRole('menu')).not.toBeNull();
|
||||
fireEvent.scroll(window);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts focus on the outcome after a pick', async () => {
|
||||
await openPicker();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Work' }));
|
||||
await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('status'));
|
||||
expect(document.activeElement?.textContent).toBe('added');
|
||||
});
|
||||
|
||||
it('keeps the picker inside a 390 px wide screen when the button sits at the right edge', async () => {
|
||||
useCalendarStore.setState({ calendars: [cal('Personal', { isDefault: true }), cal('Work')] });
|
||||
const width = window.innerWidth;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
@@ -53,35 +53,77 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
const [picker, setPicker] = useState<PickerPosition | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
// An action was started from here: once it is done, focus goes to its
|
||||
// outcome instead of being lost with the button or picker that vanished.
|
||||
const focusAfterAction = useRef(false);
|
||||
|
||||
const closePicker = useCallback(() => setPicker(null), []);
|
||||
const pickerOpen = picker !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!picker) return;
|
||||
if (!pickerOpen) return;
|
||||
pickerRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
const onPointer = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
|
||||
closePicker();
|
||||
};
|
||||
// Scrolling the list itself must not close it; scrolling the page moves the trigger away.
|
||||
const onScroll = (event: Event) => {
|
||||
if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return;
|
||||
closePicker();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closePicker();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', closePicker, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', closePicker);
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('touchstart', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', closePicker, true);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', closePicker);
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('touchstart', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [picker, closePicker]);
|
||||
}, [pickerOpen, closePicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (busy || !focusAfterAction.current) return;
|
||||
focusAfterAction.current = false;
|
||||
const row = rowRef.current;
|
||||
if (!row) return;
|
||||
const target = row.querySelector<HTMLElement>('[role="alert"]')
|
||||
?? row.querySelector<HTMLElement>('[role="status"]')
|
||||
?? row.querySelector<HTMLElement>('button');
|
||||
target?.focus();
|
||||
}, [busy, status, notice, error]);
|
||||
|
||||
const onMenuKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const items = Array.from(pickerRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
|
||||
if (items.length === 0) return;
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | null = null;
|
||||
if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length;
|
||||
else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length;
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = items.length - 1;
|
||||
else if (event.key === 'Tab') closePicker();
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
items[next].focus();
|
||||
};
|
||||
|
||||
const act = (action: () => Promise<void>) => {
|
||||
focusAfterAction.current = true;
|
||||
void action();
|
||||
};
|
||||
|
||||
if (status === 'hidden' || status === 'loading') return null;
|
||||
// A cancelled booking with nothing in the calendar has no action; only the
|
||||
@@ -90,7 +132,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
|
||||
const onAdd = () => {
|
||||
if (calendars.length <= 1) {
|
||||
void calendar.add(calendars[0]?.id);
|
||||
act(() => calendar.add(calendars[0]?.id));
|
||||
return;
|
||||
}
|
||||
if (picker) {
|
||||
@@ -101,10 +143,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
};
|
||||
|
||||
const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null;
|
||||
const blocked = notice === 'blocked';
|
||||
const showView = status === 'in-calendar' || status === 'update';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div ref={rowRef} className="flex flex-wrap items-center gap-1.5">
|
||||
{status === 'add' && (
|
||||
<>
|
||||
<button
|
||||
@@ -125,20 +168,22 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
aria-label={t('select_calendar')}
|
||||
onKeyDown={onMenuKey}
|
||||
className="fixed bg-background rounded-lg shadow-lg border border-border z-50 py-1 overflow-y-auto"
|
||||
style={{ top: picker.top, left: picker.left, right: picker.right, maxHeight: picker.maxHeight, width: `min(${PICKER_WIDTH}px, calc(100vw - ${2 * EDGE}px))` }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
|
||||
<div role="presentation" className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
|
||||
{calendars.map((cal) => (
|
||||
<button
|
||||
key={cal.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
closePicker();
|
||||
void calendar.add(cal.id);
|
||||
act(() => calendar.add(cal.id));
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 min-h-[36px]"
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
|
||||
<span className="truncate text-foreground">{cal.name}</span>
|
||||
@@ -151,7 +196,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
)}
|
||||
|
||||
{status === 'update' && (
|
||||
<button type="button" onClick={() => void calendar.update()} disabled={busy} className={BUTTON}>
|
||||
<button type="button" onClick={() => act(calendar.update)} disabled={busy} className={BUTTON}>
|
||||
<CalendarCheck className="w-3.5 h-3.5" />
|
||||
{t('update_in_calendar')}
|
||||
</button>
|
||||
@@ -160,7 +205,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
{status === 'remove' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void calendar.remove()}
|
||||
onClick={() => act(calendar.remove)}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-destructive px-3 py-1.5 rounded-md border border-border hover:border-destructive/30 hover:bg-destructive/10 transition-colors min-h-[36px] disabled:opacity-50"
|
||||
>
|
||||
@@ -174,7 +219,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
)}
|
||||
|
||||
{noticeText && (
|
||||
<span role="status" className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success">{noticeText}</span>
|
||||
<span role="status" tabIndex={-1} className="rounded-full bg-success/15 px-2 py-0.5 text-[0.6875rem] font-medium text-success focus:outline-none">{noticeText}</span>
|
||||
)}
|
||||
|
||||
{blocked && (
|
||||
<span role="status" tabIndex={-1} className="rounded-full bg-muted px-2 py-0.5 text-[0.6875rem] font-medium text-muted-foreground focus:outline-none">{t('blocked')}</span>
|
||||
)}
|
||||
|
||||
{showView && (
|
||||
@@ -192,7 +241,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="basis-full text-xs text-destructive">{error}</p>
|
||||
<p role="alert" tabIndex={-1} className="basis-full text-xs text-destructive focus:outline-none">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,8 +41,8 @@ export interface BookingCalendar {
|
||||
busy: boolean;
|
||||
/** What went wrong with the last action, in the user's language. */
|
||||
error: string | null;
|
||||
/** What the last action did, for a short confirmation. */
|
||||
notice: 'added' | 'updated' | 'removed' | null;
|
||||
/** What the last action did, for a short confirmation; `blocked` when a plugin stopped it. */
|
||||
notice: 'added' | 'updated' | 'removed' | 'blocked' | null;
|
||||
/** Add every live event, into `calendarId` or the default calendar. */
|
||||
add: (calendarId?: string) => Promise<void>;
|
||||
/** Add the missing live events, update the changed ones, remove the cancelled ones. */
|
||||
@@ -52,6 +52,13 @@ export interface BookingCalendar {
|
||||
viewInCalendar: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uids whose events a card removed in this session. Another card for the
|
||||
* same booking (a conversation showing two of its mails) may still hold the
|
||||
* event from its own lookup; this tells it the event is gone.
|
||||
*/
|
||||
const removedUids = new Set<string>();
|
||||
|
||||
type Lookup = { key: string; failed: false; events: Record<string, CalendarEvent | null> } | { key: string; failed: true };
|
||||
|
||||
interface Plan {
|
||||
@@ -174,6 +181,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
const events: Record<string, CalendarEvent | null> = {};
|
||||
uids.forEach((uid, i) => {
|
||||
events[uid] = results[i].find((e) => e.uid === uid && !e.recurrenceId) ?? null;
|
||||
if (events[uid]) removedUids.delete(uid);
|
||||
});
|
||||
setLookup({ key: uidKey, failed: false, events });
|
||||
const hits = Object.values(events).filter((e): e is CalendarEvent => e !== null);
|
||||
@@ -200,11 +208,14 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
|
||||
const known = lookup && lookup.key === uidKey && !lookup.failed ? lookup.events : null;
|
||||
const existingOf = useCallback((uid: string): CalendarEvent | null => {
|
||||
if (removedUids.has(uid)) return null;
|
||||
const stored = known?.[uid];
|
||||
if (!stored) return null;
|
||||
// The store's copy of the same event is fresher after a change elsewhere.
|
||||
return storeEvents.find((e) => e.id === stored.id) ?? stored;
|
||||
}, [known, storeEvents]);
|
||||
if (stored) return storeEvents.find((e) => e.id === stored.id) ?? stored;
|
||||
// Added since the lookup, maybe by another card for the same booking.
|
||||
return storeEvents.find((e) => e.uid === uid && !e.recurrenceId && !e.isShared
|
||||
&& (!e.localAccountId || e.localAccountId === activeAccountId)) ?? null;
|
||||
}, [known, storeEvents, activeAccountId]);
|
||||
|
||||
const plan = useMemo<Plan>(() => {
|
||||
if (!active) return { status: 'hidden', live: [], stale: [] };
|
||||
@@ -219,14 +230,17 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
setLookup((prev) => (prev && !prev.failed && prev.key === uidKey ? { ...prev, events: { ...prev.events, [uid]: event } } : prev));
|
||||
}, [uidKey]);
|
||||
|
||||
/** Run one action; on failure say why and look the events up again. */
|
||||
/**
|
||||
* Run one action. `work` resolves to false when a plugin stopped a step,
|
||||
* which is said as such; on failure say why and look the events up again.
|
||||
*/
|
||||
const run = useCallback(async (done: NonNullable<BookingCalendar['notice']>, work: () => Promise<boolean>) => {
|
||||
if (!client || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (await work()) setNotice(done);
|
||||
setNotice((await work()) ? done : 'blocked');
|
||||
} catch (err) {
|
||||
setError(describeError(err, t('action_failed')));
|
||||
setRefresh((n) => n + 1);
|
||||
@@ -239,9 +253,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
if (!client) return false;
|
||||
for (const { slot, existing } of plan.live) {
|
||||
if (existing) continue;
|
||||
// createEvent answers null both when a plugin stops it and when the
|
||||
// server refuses (the uid exists after all, say); only the latter sets
|
||||
// the store's error.
|
||||
useCalendarStore.setState({ error: null });
|
||||
const created = await createEvent(client, { ...slot.event, calendarIds: { [calendarId]: true } });
|
||||
// null: refused (a plugin, or the server, e.g. the uid exists after all).
|
||||
if (!created) throw new Error('create failed');
|
||||
if (!created) {
|
||||
if (useCalendarStore.getState().error) throw new Error('create failed');
|
||||
return false;
|
||||
}
|
||||
removedUids.delete(slot.uid);
|
||||
remember(slot.uid, created);
|
||||
}
|
||||
return true;
|
||||
@@ -250,7 +271,16 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
const removeStale = useCallback(async (): Promise<boolean> => {
|
||||
if (!client) return false;
|
||||
for (const { slot, existing } of plan.stale) {
|
||||
if (!(await deleteEvent(client, existing.id))) return false;
|
||||
// Marked before the store drops the event, so other cards that
|
||||
// re-render on that change already see it gone.
|
||||
removedUids.add(slot.uid);
|
||||
let removed = false;
|
||||
try {
|
||||
removed = await deleteEvent(client, existing.id);
|
||||
} finally {
|
||||
if (!removed) removedUids.delete(slot.uid);
|
||||
}
|
||||
if (!removed) return false;
|
||||
remember(slot.uid, null);
|
||||
}
|
||||
return true;
|
||||
@@ -266,7 +296,9 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
if (!client) return false;
|
||||
for (const { slot, existing } of plan.live) {
|
||||
if (!existing || !eventNeedsUpdate(existing, slot.event as Partial<CalendarEvent>)) continue;
|
||||
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>);
|
||||
// Only what moved; title and notes the user changed stay.
|
||||
const patch = bookingEventPatch(existing, slot.event as Partial<CalendarEvent>, senderDomain);
|
||||
if (Object.keys(patch).length === 0) continue;
|
||||
if (!(await updateEvent(client, existing.id, patch))) return false;
|
||||
const updated = { ...existing, ...patch } as CalendarEvent;
|
||||
remember(slot.uid, updated);
|
||||
@@ -281,7 +313,7 @@ export function useBookingCalendar(booking: StructuredCard | null | undefined, e
|
||||
if (!(await createMissing(target))) return false;
|
||||
}
|
||||
return removeStale();
|
||||
}), [run, client, plan.live, targets, updateEvent, remember, createMissing, removeStale]);
|
||||
}), [run, client, plan.live, senderDomain, targets, updateEvent, remember, createMissing, removeStale]);
|
||||
|
||||
const remove = useCallback(() => run('removed', removeStale), [run, removeStale]);
|
||||
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "تم تحديث التقويم",
|
||||
"removed": "تمت الإزالة من تقويمك",
|
||||
"action_failed": "تعذّر تغيير تقويمك. حاول مرة أخرى.",
|
||||
"blocked": "أوقفت إضافةٌ هذا التغيير في التقويم.",
|
||||
"title": {
|
||||
"rental_car": "سيارة مستأجرة",
|
||||
"rental_car_company": "سيارة مستأجرة: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "S’ha actualitzat el calendari",
|
||||
"removed": "S’ha tret del vostre calendari",
|
||||
"action_failed": "No s’ha pogut modificar el calendari. Torneu-ho a provar.",
|
||||
"blocked": "Un connector ha impedit aquest canvi al calendari.",
|
||||
"title": {
|
||||
"rental_car": "Cotxe de lloguer",
|
||||
"rental_car_company": "Cotxe de lloguer: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalendář aktualizován",
|
||||
"removed": "Odebráno z vašeho kalendáře",
|
||||
"action_failed": "Kalendář se nepodařilo změnit. Zkuste to znovu.",
|
||||
"blocked": "Tuto změnu kalendáře zastavil plugin.",
|
||||
"title": {
|
||||
"rental_car": "Pronájem auta",
|
||||
"rental_car_company": "Pronájem auta: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalender opdateret",
|
||||
"removed": "Fjernet fra din kalender",
|
||||
"action_failed": "Din kalender kunne ikke ændres. Prøv igen.",
|
||||
"blocked": "Et plugin stoppede denne ændring i kalenderen.",
|
||||
"title": {
|
||||
"rental_car": "Lejebil",
|
||||
"rental_car_company": "Lejebil: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalender aktualisiert",
|
||||
"removed": "Aus Ihrem Kalender entfernt",
|
||||
"action_failed": "Ihr Kalender konnte nicht geändert werden. Bitte versuchen Sie es erneut.",
|
||||
"blocked": "Ein Plugin hat diese Kalenderänderung verhindert.",
|
||||
"title": {
|
||||
"rental_car": "Mietwagen",
|
||||
"rental_car_company": "Mietwagen: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendar updated",
|
||||
"removed": "Removed from your calendar",
|
||||
"action_failed": "Couldn't change your calendar. Try again.",
|
||||
"blocked": "A plugin stopped this calendar change.",
|
||||
"title": {
|
||||
"rental_car": "Rental car",
|
||||
"rental_car_company": "Rental car: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendario actualizado",
|
||||
"removed": "Quitado de tu calendario",
|
||||
"action_failed": "No se pudo cambiar tu calendario. Inténtalo de nuevo.",
|
||||
"blocked": "Un complemento ha impedido este cambio en el calendario.",
|
||||
"title": {
|
||||
"rental_car": "Coche de alquiler",
|
||||
"rental_car_company": "Coche de alquiler: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalender uuendatud",
|
||||
"removed": "Eemaldatud sinu kalendrist",
|
||||
"action_failed": "Kalendrit ei õnnestunud muuta. Proovi uuesti.",
|
||||
"blocked": "Pistikprogramm peatas selle kalendrimuudatuse.",
|
||||
"title": {
|
||||
"rental_car": "Rendiauto",
|
||||
"rental_car_company": "Rendiauto: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "تقویم بهروزرسانی شد",
|
||||
"removed": "از تقویم شما حذف شد",
|
||||
"action_failed": "تغییر تقویم انجام نشد. دوباره تلاش کنید.",
|
||||
"blocked": "یک افزونه این تغییر تقویم را متوقف کرد.",
|
||||
"title": {
|
||||
"rental_car": "خودروی اجارهای",
|
||||
"rental_car_company": "خودروی اجارهای: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendrier mis à jour",
|
||||
"removed": "Retiré de votre calendrier",
|
||||
"action_failed": "Impossible de modifier votre calendrier. Réessayez.",
|
||||
"blocked": "Un plugin a empêché cette modification du calendrier.",
|
||||
"title": {
|
||||
"rental_car": "Voiture de location",
|
||||
"rental_car_company": "Voiture de location : {company}"
|
||||
|
||||
@@ -5964,6 +5964,7 @@
|
||||
"updated": "היומן עודכן",
|
||||
"removed": "הוסר מהיומן שלך",
|
||||
"action_failed": "לא ניתן היה לשנות את היומן. נסה שוב.",
|
||||
"blocked": "תוסף עצר את השינוי הזה ביומן.",
|
||||
"title": {
|
||||
"rental_car": "רכב שכור",
|
||||
"rental_car_company": "רכב שכור: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Naptár frissítve",
|
||||
"removed": "Eltávolítva a naptáradból",
|
||||
"action_failed": "Nem sikerült módosítani a naptárat. Próbáld újra.",
|
||||
"blocked": "Egy bővítmény megakadályozta ezt a naptárváltozást.",
|
||||
"title": {
|
||||
"rental_car": "Bérautó",
|
||||
"rental_car_company": "Bérautó: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendario aggiornato",
|
||||
"removed": "Rimosso dal tuo calendario",
|
||||
"action_failed": "Impossibile modificare il calendario. Riprova.",
|
||||
"blocked": "Un plugin ha impedito questa modifica al calendario.",
|
||||
"title": {
|
||||
"rental_car": "Auto a noleggio",
|
||||
"rental_car_company": "Auto a noleggio: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "カレンダーを更新しました",
|
||||
"removed": "カレンダーから削除しました",
|
||||
"action_failed": "カレンダーを変更できませんでした。もう一度お試しください。",
|
||||
"blocked": "プラグインがこのカレンダーの変更を止めました。",
|
||||
"title": {
|
||||
"rental_car": "レンタカー",
|
||||
"rental_car_company": "レンタカー: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "캘린더가 업데이트되었어요",
|
||||
"removed": "캘린더에서 제거되었어요",
|
||||
"action_failed": "캘린더를 변경할 수 없어요. 다시 시도해 주세요.",
|
||||
"blocked": "플러그인이 이 캘린더 변경을 막았어요.",
|
||||
"title": {
|
||||
"rental_car": "렌터카",
|
||||
"rental_car_company": "렌터카: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalendārs atjaunināts",
|
||||
"removed": "Noņemts no jūsu kalendāra",
|
||||
"action_failed": "Neizdevās mainīt kalendāru. Mēģiniet vēlreiz.",
|
||||
"blocked": "Spraudnis apturēja šīs kalendāra izmaiņas.",
|
||||
"title": {
|
||||
"rental_car": "Nomas auto",
|
||||
"rental_car_company": "Nomas auto: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Хуанли шинэчлэгдлээ",
|
||||
"removed": "Таны хуанлиас хассан",
|
||||
"action_failed": "Хуанлийг өөрчилж чадсангүй. Дахин оролдоно уу.",
|
||||
"blocked": "Нэмэлт өргөтгөл энэ хуанлийн өөрчлөлтийг зогсоосон.",
|
||||
"title": {
|
||||
"rental_car": "Түрээсийн машин",
|
||||
"rental_car_company": "Түрээсийн машин: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalenderen er oppdatert",
|
||||
"removed": "Fjernet fra kalenderen din",
|
||||
"action_failed": "Kunne ikke endre kalenderen. Prøv igjen.",
|
||||
"blocked": "Et programtillegg stoppet denne endringen i kalenderen.",
|
||||
"title": {
|
||||
"rental_car": "Leiebil",
|
||||
"rental_car_company": "Leiebil: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Agenda bijgewerkt",
|
||||
"removed": "Uit je agenda verwijderd",
|
||||
"action_failed": "Je agenda kon niet worden gewijzigd. Probeer het opnieuw.",
|
||||
"blocked": "Een plug-in heeft deze agendawijziging tegengehouden.",
|
||||
"title": {
|
||||
"rental_car": "Huurauto",
|
||||
"rental_car_company": "Huurauto: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Zaktualizowano kalendarz",
|
||||
"removed": "Usunięto z Twojego kalendarza",
|
||||
"action_failed": "Nie udało się zmienić kalendarza. Spróbuj ponownie.",
|
||||
"blocked": "Wtyczka zablokowała tę zmianę w kalendarzu.",
|
||||
"title": {
|
||||
"rental_car": "Wynajem samochodu",
|
||||
"rental_car_company": "Wynajem samochodu: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendário atualizado",
|
||||
"removed": "Removido do seu calendário",
|
||||
"action_failed": "Não foi possível alterar seu calendário. Tente novamente.",
|
||||
"blocked": "Um plugin impediu esta alteração no calendário.",
|
||||
"title": {
|
||||
"rental_car": "Carro alugado",
|
||||
"rental_car_company": "Carro alugado: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Calendar actualizat",
|
||||
"removed": "Eliminat din calendarul dvs.",
|
||||
"action_failed": "Calendarul nu a putut fi modificat. Încercați din nou.",
|
||||
"blocked": "Un plugin a oprit această modificare în calendar.",
|
||||
"title": {
|
||||
"rental_car": "Mașină închiriată",
|
||||
"rental_car_company": "Mașină închiriată: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Календарь обновлён",
|
||||
"removed": "Удалено из вашего календаря",
|
||||
"action_failed": "Не удалось изменить календарь. Попробуйте ещё раз.",
|
||||
"blocked": "Плагин остановил это изменение календаря.",
|
||||
"title": {
|
||||
"rental_car": "Аренда автомобиля",
|
||||
"rental_car_company": "Аренда автомобиля: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Kalendár bol aktualizovaný",
|
||||
"removed": "Odstránené z vášho kalendára",
|
||||
"action_failed": "Kalendár sa nepodarilo zmeniť. Skúste to znova.",
|
||||
"blocked": "Túto zmenu kalendára zastavil doplnok.",
|
||||
"title": {
|
||||
"rental_car": "Prenájom auta",
|
||||
"rental_car_company": "Prenájom auta: {company}"
|
||||
|
||||
@@ -5971,6 +5971,7 @@
|
||||
"updated": "อัปเดตปฏิทินแล้ว",
|
||||
"removed": "นำออกจากปฏิทินของคุณแล้ว",
|
||||
"action_failed": "เปลี่ยนแปลงปฏิทินไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"blocked": "ปลั๊กอินหยุดการเปลี่ยนแปลงปฏิทินนี้",
|
||||
"title": {
|
||||
"rental_car": "รถเช่า",
|
||||
"rental_car_company": "รถเช่า: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Takvim güncellendi",
|
||||
"removed": "Takviminizden kaldırıldı",
|
||||
"action_failed": "Takviminiz değiştirilemedi. Tekrar deneyin.",
|
||||
"blocked": "Bir eklenti bu takvim değişikliğini durdurdu.",
|
||||
"title": {
|
||||
"rental_car": "Kiralık araç",
|
||||
"rental_car_company": "Kiralık araç: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "Календар оновлено",
|
||||
"removed": "Видалено з вашого календаря",
|
||||
"action_failed": "Не вдалося змінити календар. Спробуйте ще раз.",
|
||||
"blocked": "Плагін зупинив цю зміну календаря.",
|
||||
"title": {
|
||||
"rental_car": "Оренда авто",
|
||||
"rental_car_company": "Оренда авто: {company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "行事曆已更新",
|
||||
"removed": "已從您的行事曆移除",
|
||||
"action_failed": "無法變更您的行事曆,請再試一次。",
|
||||
"blocked": "某個外掛程式阻止了此行事曆變更。",
|
||||
"title": {
|
||||
"rental_car": "租車",
|
||||
"rental_car_company": "租車:{company}"
|
||||
|
||||
@@ -5963,6 +5963,7 @@
|
||||
"updated": "日历已更新",
|
||||
"removed": "已从你的日历中移除",
|
||||
"action_failed": "无法更改你的日历,请重试。",
|
||||
"blocked": "某个插件阻止了此日历更改。",
|
||||
"title": {
|
||||
"rental_car": "租车",
|
||||
"rental_car_company": "租车:{company}"
|
||||
|
||||
Reference in New Issue
Block a user