@remit/web-client 0.0.229 → 0.0.231
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/calendar/CalendarComposePane.stories.tsx +25 -0
- package/src/components/calendar/CalendarComposePane.tsx +34 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
- package/src/components/calendar/CalendarEventPane.tsx +67 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
- package/src/components/calendar/CalendarWorkspace.tsx +162 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
- package/src/components/config-import/ConfigImportWizard.tsx +720 -0
- package/src/components/config-import/config-import.fixtures.ts +229 -0
- package/src/components/config-import/config-import.stories.tsx +449 -0
- package/src/components/config-import/steps.tsx +772 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/components/onboarding/OnboardingWizard.tsx +64 -390
- package/src/components/onboarding/credential-steps.tsx +441 -0
- package/src/components/settings/ConfigExportCard.tsx +114 -0
- package/src/components/settings/ConfigTransferPanel.tsx +72 -0
- package/src/hooks/useCalendarData.ts +72 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-fixtures.ts +207 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +183 -0
- package/src/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +220 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +100 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routes/import.tsx +51 -0
- package/src/routes/onboarding.tsx +9 -2
- package/src/routes/settings/accounts.tsx +30 -8
- package/src/routes/settings/advanced.tsx +5 -1
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +139 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
/**
|
|
3
|
+
* /calendar/{view}/{date} — the calendar, at one zoom, on one day.
|
|
4
|
+
*
|
|
5
|
+
* Both are path segments. The five zooms are mutually exclusive and each is a
|
|
6
|
+
* different surface, so sibling routes cannot both match (R5); the day is a
|
|
7
|
+
* segment because the strip is infinite and the address has to name where it
|
|
8
|
+
* opened — a `?date=` param would be the query compensating for a missing
|
|
9
|
+
* segment (R4).
|
|
10
|
+
*
|
|
11
|
+
* A segment the calendar cannot read is rewritten rather than refused. A
|
|
12
|
+
* hand-edited or stale address is a normal thing to receive, and the half that
|
|
13
|
+
* was readable is kept, so `/calendar/fortnight/2026-06-10` lands on that day's
|
|
14
|
+
* week instead of on an error.
|
|
15
|
+
*/
|
|
16
|
+
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
|
17
|
+
import { useCallback, useState } from "react";
|
|
18
|
+
import { CalendarShell } from "@/components/calendar/CalendarShell";
|
|
19
|
+
import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
|
|
20
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
21
|
+
import {
|
|
22
|
+
readCalendarDensity,
|
|
23
|
+
writeCalendarDensity,
|
|
24
|
+
} from "@/lib/calendar-density";
|
|
25
|
+
import { fixtureTimeZone } from "@/lib/calendar-fixtures";
|
|
26
|
+
import {
|
|
27
|
+
calendarSearchSchema,
|
|
28
|
+
canonicalCalendarParams,
|
|
29
|
+
isoDate,
|
|
30
|
+
} from "@/lib/calendar-route";
|
|
31
|
+
import {
|
|
32
|
+
useCalendarAddress,
|
|
33
|
+
useCalendarNavigation,
|
|
34
|
+
useIsWritingEvent,
|
|
35
|
+
useOpenCalendarEvent,
|
|
36
|
+
} from "@/routing";
|
|
37
|
+
|
|
38
|
+
export const Route = createFileRoute("/calendar/$view/$date")({
|
|
39
|
+
validateSearch: calendarSearchSchema,
|
|
40
|
+
beforeLoad: ({ params, search }) => {
|
|
41
|
+
const canonical = canonicalCalendarParams(params, isoDate(new Date()));
|
|
42
|
+
if (canonical.view === params.view && canonical.date === params.date)
|
|
43
|
+
return;
|
|
44
|
+
throw redirect({
|
|
45
|
+
to: "/calendar/$view/$date",
|
|
46
|
+
params: canonical,
|
|
47
|
+
search,
|
|
48
|
+
replace: true,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
component: CalendarViewLayout,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function CalendarViewLayout() {
|
|
55
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
56
|
+
const { events, colorByCalendarId } = useCalendarData({
|
|
57
|
+
view,
|
|
58
|
+
date,
|
|
59
|
+
calendarIds,
|
|
60
|
+
});
|
|
61
|
+
const { goToView, goToToday, step, openEvent, openComposer } =
|
|
62
|
+
useCalendarNavigation();
|
|
63
|
+
const openedEvent = useOpenCalendarEvent();
|
|
64
|
+
const isWriting = useIsWritingEvent();
|
|
65
|
+
// Held in state rather than read back from storage each render: how much of
|
|
66
|
+
// a day this device shows is not a fact about the calendar, so changing it
|
|
67
|
+
// changes nothing about the address.
|
|
68
|
+
const [density, setDensity] = useState(readCalendarDensity);
|
|
69
|
+
const changeDensity = useCallback((next: typeof density) => {
|
|
70
|
+
writeCalendarDensity(next);
|
|
71
|
+
setDensity(next);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const workspace = (
|
|
75
|
+
<CalendarWorkspace
|
|
76
|
+
view={view}
|
|
77
|
+
date={date}
|
|
78
|
+
events={events}
|
|
79
|
+
colorByCalendarId={colorByCalendarId}
|
|
80
|
+
density={density}
|
|
81
|
+
selectedEventId={openedEvent?.calendarObjectId ?? ""}
|
|
82
|
+
timeZone={fixtureTimeZone()}
|
|
83
|
+
now={new Date().toISOString()}
|
|
84
|
+
onChangeView={goToView}
|
|
85
|
+
onToday={goToToday}
|
|
86
|
+
onStep={step}
|
|
87
|
+
onChangeDensity={changeDensity}
|
|
88
|
+
onSelectEvent={openEvent}
|
|
89
|
+
onPickSlot={openComposer}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<CalendarShell
|
|
95
|
+
workspace={workspace}
|
|
96
|
+
reading={<Outlet />}
|
|
97
|
+
hasOpenEvent={openedEvent !== undefined || isWriting}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
/**
|
|
3
|
+
* The calendar's route tree.
|
|
4
|
+
*
|
|
5
|
+
* What is asserted is what the address does rather than what a component
|
|
6
|
+
* renders: which routes a path mounts, which segment carries which fact, and
|
|
7
|
+
* where an address the calendar cannot read is sent instead. The rules are
|
|
8
|
+
* `docs/architecture/url-state.md` — the view and the day are segments (R4/R5),
|
|
9
|
+
* the ticked calendars are query validated by one schema (R7), and no fact
|
|
10
|
+
* appears at two tiers.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import {
|
|
15
|
+
type AnyRoute,
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRouter,
|
|
20
|
+
isRedirect,
|
|
21
|
+
Outlet,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
23
|
+
import {
|
|
24
|
+
calendarSearchSchema,
|
|
25
|
+
isoDate,
|
|
26
|
+
stepCalendarDate,
|
|
27
|
+
} from "@/lib/calendar-route";
|
|
28
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
29
|
+
import { Route as OccurrenceRoute } from "./$view.$date/$calendarObjectId/$recurrenceId.js";
|
|
30
|
+
import { Route as EventIndexRoute } from "./$view.$date/$calendarObjectId/index.js";
|
|
31
|
+
import { Route as EventRoute } from "./$view.$date/$calendarObjectId.js";
|
|
32
|
+
import { Route as ReadingPaneRoute } from "./$view.$date/index.js";
|
|
33
|
+
import { Route as ComposeRoute } from "./$view.$date/new.js";
|
|
34
|
+
import { Route as ViewRoute } from "./$view.$date.js";
|
|
35
|
+
import { Route as CalendarIndexRoute } from "./index.js";
|
|
36
|
+
|
|
37
|
+
// The router reads `self` at construction; the shared globals stop at `window`.
|
|
38
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
39
|
+
|
|
40
|
+
/** The router types a file route's own `update` more narrowly than a test needs. */
|
|
41
|
+
type Attachable = { update: (options: unknown) => AnyRoute };
|
|
42
|
+
const attach = (route: unknown, options: unknown): AnyRoute =>
|
|
43
|
+
(route as Attachable).update(options);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The calendar's routes, mounted the way the generated tree mounts them but
|
|
47
|
+
* without the `/calendar` layout above them — that route only fetches the
|
|
48
|
+
* account list the sidebar is built from, and no rule under test is its. Built
|
|
49
|
+
* once: a file route is a module singleton, and re-parenting one per case would
|
|
50
|
+
* be re-parenting the same object.
|
|
51
|
+
*/
|
|
52
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
53
|
+
const viewRoute = attach(ViewRoute, {
|
|
54
|
+
path: "/calendar/$view/$date",
|
|
55
|
+
getParentRoute: () => rootRoute,
|
|
56
|
+
});
|
|
57
|
+
const eventRoute = attach(EventRoute, {
|
|
58
|
+
path: "$calendarObjectId",
|
|
59
|
+
getParentRoute: () => viewRoute,
|
|
60
|
+
});
|
|
61
|
+
eventRoute.addChildren([
|
|
62
|
+
attach(EventIndexRoute, { path: "/", getParentRoute: () => eventRoute }),
|
|
63
|
+
attach(OccurrenceRoute, {
|
|
64
|
+
path: "$recurrenceId",
|
|
65
|
+
getParentRoute: () => eventRoute,
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
viewRoute.addChildren([
|
|
69
|
+
attach(ReadingPaneRoute, { path: "/", getParentRoute: () => viewRoute }),
|
|
70
|
+
attach(ComposeRoute, { path: "new", getParentRoute: () => viewRoute }),
|
|
71
|
+
eventRoute,
|
|
72
|
+
]);
|
|
73
|
+
const routeTree = rootRoute.addChildren([
|
|
74
|
+
attach(CalendarIndexRoute, {
|
|
75
|
+
path: "/calendar",
|
|
76
|
+
getParentRoute: () => rootRoute,
|
|
77
|
+
}),
|
|
78
|
+
viewRoute,
|
|
79
|
+
]) as unknown as AnyRoute;
|
|
80
|
+
|
|
81
|
+
interface Match {
|
|
82
|
+
routeIds: string[];
|
|
83
|
+
params: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A router over the tree, spelling its query string the way the app does. */
|
|
87
|
+
const routerAt = (entry: string): AnyRouter =>
|
|
88
|
+
createRouter({
|
|
89
|
+
routeTree,
|
|
90
|
+
stringifySearch,
|
|
91
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
92
|
+
}) as unknown as AnyRouter;
|
|
93
|
+
|
|
94
|
+
/** The routes an address mounts, deepest last. */
|
|
95
|
+
function match(entry: string): Match {
|
|
96
|
+
const router = routerAt(entry);
|
|
97
|
+
const matches = router.matchRoutes(router.state.location);
|
|
98
|
+
return {
|
|
99
|
+
routeIds: matches.map((one: { routeId: string }) => one.routeId),
|
|
100
|
+
params: matches[matches.length - 1].params as Record<string, string>,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SentTo {
|
|
105
|
+
to?: string;
|
|
106
|
+
params?: Record<string, string>;
|
|
107
|
+
search?: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const beforeLoadOf = (route: unknown): ((args: unknown) => void) => {
|
|
111
|
+
const { beforeLoad } = (
|
|
112
|
+
route as { options: { beforeLoad?: (args: unknown) => void } }
|
|
113
|
+
).options;
|
|
114
|
+
assert.ok(beforeLoad, "the route decides nothing about the address it got");
|
|
115
|
+
return beforeLoad;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type Address = { params: Record<string, string>; search: unknown };
|
|
119
|
+
|
|
120
|
+
/** Where a route sends an address it will not serve. */
|
|
121
|
+
function sentTo(route: unknown, address: Address): SentTo {
|
|
122
|
+
let sent: SentTo | undefined;
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => beforeLoadOf(route)(address),
|
|
125
|
+
(thrown: unknown) => {
|
|
126
|
+
assert.ok(isRedirect(thrown), `not a redirect: ${String(thrown)}`);
|
|
127
|
+
sent = (thrown as { options: SentTo }).options;
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
return sent as SentTo;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** That the route serves the address as it stands, rewriting nothing. */
|
|
135
|
+
function serves(route: unknown, address: Address): void {
|
|
136
|
+
assert.doesNotThrow(() => beforeLoadOf(route)(address));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TODAY = isoDate(new Date());
|
|
140
|
+
const held = (view: string, date: string) => ({
|
|
141
|
+
params: { view, date },
|
|
142
|
+
search: {},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("/calendar", () => {
|
|
146
|
+
it("sends a link that names no zoom to this week", () => {
|
|
147
|
+
const sent = sentTo(CalendarIndexRoute, { params: {}, search: {} });
|
|
148
|
+
assert.equal(sent.to, "/calendar/$view/$date");
|
|
149
|
+
assert.deepEqual(sent.params, { view: "week", date: TODAY });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("takes whatever the link ticked off with it", () => {
|
|
153
|
+
const sent = sentTo(CalendarIndexRoute, {
|
|
154
|
+
params: {},
|
|
155
|
+
search: { calendarId: ["cal_a"] },
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(sent.search, { calendarId: ["cal_a"] });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("/calendar/{view}/{date}", () => {
|
|
162
|
+
it("serves every zoom the ladder has, on the day named", () => {
|
|
163
|
+
for (const view of ["year", "month", "week", "day", "agenda"]) {
|
|
164
|
+
serves(ViewRoute, held(view, "2026-06-10"));
|
|
165
|
+
const mounted = match(`/calendar/${view}/2026-06-10`);
|
|
166
|
+
assert.ok(mounted.routeIds.includes("/calendar/$view/$date"));
|
|
167
|
+
assert.equal(mounted.params.view, view);
|
|
168
|
+
assert.equal(mounted.params.date, "2026-06-10");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("mounts the reading pane under the view with nothing open", () => {
|
|
173
|
+
assert.deepEqual(match("/calendar/week/2026-06-10").routeIds.slice(-2), [
|
|
174
|
+
"/calendar/$view/$date",
|
|
175
|
+
"/calendar/$view/$date/",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rewrites a zoom the ladder does not have, keeping the day", () => {
|
|
180
|
+
const sent = sentTo(ViewRoute, held("fortnight", "2026-06-10"));
|
|
181
|
+
assert.deepEqual(sent.params, { view: "week", date: "2026-06-10" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rewrites a day the calendar does not have, keeping the zoom", () => {
|
|
185
|
+
assert.deepEqual(sentTo(ViewRoute, held("day", "2026-02-30")).params, {
|
|
186
|
+
view: "day",
|
|
187
|
+
date: TODAY,
|
|
188
|
+
});
|
|
189
|
+
assert.deepEqual(sentTo(ViewRoute, held("day", "tomorrow")).params, {
|
|
190
|
+
view: "day",
|
|
191
|
+
date: TODAY,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("the ticked calendars", () => {
|
|
197
|
+
it("are validated by the schema the domain reads them with", () => {
|
|
198
|
+
assert.equal(
|
|
199
|
+
(ViewRoute as unknown as { options: { validateSearch: unknown } }).options
|
|
200
|
+
.validateSearch,
|
|
201
|
+
calendarSearchSchema,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The address the toolbar's Next writes. Repeated params are the shape the
|
|
207
|
+
* issue pins and the shape A.3 and C.2 inherit, so what a step writes is
|
|
208
|
+
* asserted rather than assumed: the router's own serializer would have made
|
|
209
|
+
* this a JSON blob.
|
|
210
|
+
*/
|
|
211
|
+
it("are written as repeated params when the week steps", () => {
|
|
212
|
+
const router = routerAt("/calendar/week/2026-06-10?calendarId=cal_a");
|
|
213
|
+
const next = router.buildLocation({
|
|
214
|
+
to: "/calendar/$view/$date",
|
|
215
|
+
params: {
|
|
216
|
+
view: "week",
|
|
217
|
+
date: stepCalendarDate("2026-06-10", "week", 1),
|
|
218
|
+
},
|
|
219
|
+
search: { calendarId: ["cal_a", "cal_b"] },
|
|
220
|
+
});
|
|
221
|
+
assert.equal(next.pathname, "/calendar/week/2026-06-17");
|
|
222
|
+
assert.equal(next.searchStr, "?calendarId=cal_a&calendarId=cal_b");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("leave the query out of a step that has nothing ticked", () => {
|
|
226
|
+
const router = routerAt("/calendar/week/2026-06-10");
|
|
227
|
+
const next = router.buildLocation({
|
|
228
|
+
to: "/calendar/$view/$date",
|
|
229
|
+
params: { view: "week", date: "2026-06-17" },
|
|
230
|
+
search: {},
|
|
231
|
+
});
|
|
232
|
+
assert.equal(next.searchStr, "");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("the composer and the open event cannot both be addressed", () => {
|
|
237
|
+
it("mounts the composer, and nothing of the event route", () => {
|
|
238
|
+
const mounted = match("/calendar/week/2026-06-10/new");
|
|
239
|
+
assert.ok(mounted.routeIds.includes("/calendar/$view/$date/new"));
|
|
240
|
+
assert.equal(
|
|
241
|
+
mounted.routeIds.some((id) => id.includes("$calendarObjectId")),
|
|
242
|
+
false,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("mounts the event, and nothing of the composer", () => {
|
|
247
|
+
const mounted = match("/calendar/week/2026-06-10/evt_1");
|
|
248
|
+
assert.deepEqual(mounted.routeIds.slice(-2), [
|
|
249
|
+
"/calendar/$view/$date/$calendarObjectId",
|
|
250
|
+
"/calendar/$view/$date/$calendarObjectId/",
|
|
251
|
+
]);
|
|
252
|
+
assert.equal(mounted.params.calendarObjectId, "evt_1");
|
|
253
|
+
assert.equal(mounted.routeIds.includes("/calendar/$view/$date/new"), false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps the series matched under one of its occurrences", () => {
|
|
257
|
+
const mounted = match(
|
|
258
|
+
"/calendar/week/2026-06-10/evt_1/2026-06-11T09%3A15%3A00%2B02%3A00",
|
|
259
|
+
);
|
|
260
|
+
assert.deepEqual(mounted.routeIds.slice(-2), [
|
|
261
|
+
"/calendar/$view/$date/$calendarObjectId",
|
|
262
|
+
"/calendar/$view/$date/$calendarObjectId/$recurrenceId",
|
|
263
|
+
]);
|
|
264
|
+
assert.equal(mounted.params.calendarObjectId, "evt_1");
|
|
265
|
+
assert.equal(mounted.params.recurrenceId, "2026-06-11T09:15:00+02:00");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/calendar` has no view of its own: the five zoom levels are five addresses,
|
|
3
|
+
* so a link that names none is sent to the week the reader is in. The week is
|
|
4
|
+
* the default because it is the one that answers "what is my day like" without
|
|
5
|
+
* either scrolling or squinting.
|
|
6
|
+
*
|
|
7
|
+
* Whatever the link ticked off travels with it. The query modifies the view
|
|
8
|
+
* rather than choosing it, so it survives being told which view to open.
|
|
9
|
+
*/
|
|
10
|
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
11
|
+
import {
|
|
12
|
+
calendarSearchSchema,
|
|
13
|
+
DEFAULT_CALENDAR_VIEW,
|
|
14
|
+
isoDate,
|
|
15
|
+
} from "@/lib/calendar-route";
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/calendar/")({
|
|
18
|
+
validateSearch: calendarSearchSchema,
|
|
19
|
+
beforeLoad: ({ search }) => {
|
|
20
|
+
throw redirect({
|
|
21
|
+
to: "/calendar/$view/$date",
|
|
22
|
+
params: { view: DEFAULT_CALENDAR_VIEW, date: isoDate(new Date()) },
|
|
23
|
+
search,
|
|
24
|
+
replace: true,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /calendar — a sibling of /mail, sharing its nav and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The layout owns what the whole calendar needs and no more: the account list
|
|
5
|
+
* the sidebar is built from, and the nav slide-over the narrow tiers open. The
|
|
6
|
+
* view below it decides what the panes hold.
|
|
7
|
+
*
|
|
8
|
+
* The fragment keeps the meaning it has in mail. The calendar has no
|
|
9
|
+
* intelligence rail, so a reader arriving with `#intelligence` carries it
|
|
10
|
+
* through and takes it back to their mail — nothing calendar-specific joins the
|
|
11
|
+
* union, because none of the calendar's own chrome is a panel.
|
|
12
|
+
*/
|
|
13
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
+
import { useQuery } from "@tanstack/react-query";
|
|
15
|
+
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
|
16
|
+
import { useCallback, useEffect } from "react";
|
|
17
|
+
import { CalendarShellProvider } from "@/components/calendar/CalendarShell";
|
|
18
|
+
import { MailNav } from "@/components/mail/MailNav";
|
|
19
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
20
|
+
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
21
|
+
import { panelsWithOverlay, useOpenPanels, useSetOpenPanels } from "@/routing";
|
|
22
|
+
import "@/lib/client";
|
|
23
|
+
|
|
24
|
+
export const Route = createFileRoute("/calendar")({
|
|
25
|
+
loader: ({ context: { queryClient } }) => {
|
|
26
|
+
void queryClient.prefetchQuery(configOperationsGetConfigOptions());
|
|
27
|
+
},
|
|
28
|
+
component: CalendarLayout,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function CalendarLayout() {
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
const isSinglePane = isSinglePaneTier(useLayoutTier());
|
|
34
|
+
// Only the nav slide-over is the calendar's to open, and it is written as
|
|
35
|
+
// the whole set so a pane the reader brought with them from mail survives
|
|
36
|
+
// the visit rather than being dropped by the first tap on the hamburger.
|
|
37
|
+
const openPanels = useOpenPanels();
|
|
38
|
+
const setOpenPanels = useSetOpenPanels();
|
|
39
|
+
const navOpen = openPanels.includes("nav");
|
|
40
|
+
const showNav = useCallback(
|
|
41
|
+
(open: boolean) => {
|
|
42
|
+
setOpenPanels(panelsWithOverlay(openPanels, open ? "nav" : undefined));
|
|
43
|
+
},
|
|
44
|
+
[openPanels, setOpenPanels],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
data: config,
|
|
49
|
+
isLoading,
|
|
50
|
+
isError,
|
|
51
|
+
error,
|
|
52
|
+
refetch,
|
|
53
|
+
} = useQuery({
|
|
54
|
+
...configOperationsGetConfigOptions(),
|
|
55
|
+
staleTime: Infinity,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The same first-run guard `/mail` carries: a reader with no account has no
|
|
59
|
+
// calendar either, and the onboarding wizard is where they belong.
|
|
60
|
+
const hasNoAccounts = Boolean(config && (config.accounts?.length ?? 0) === 0);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!hasNoAccounts) return;
|
|
63
|
+
navigate({ to: "/onboarding", replace: true });
|
|
64
|
+
}, [hasNoAccounts, navigate]);
|
|
65
|
+
|
|
66
|
+
const accounts = config?.accounts ?? [];
|
|
67
|
+
|
|
68
|
+
if (isError) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
71
|
+
<ErrorState
|
|
72
|
+
title="Couldn't load your account"
|
|
73
|
+
error={error}
|
|
74
|
+
onRetry={() => {
|
|
75
|
+
refetch();
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<CalendarShellProvider
|
|
84
|
+
chrome={{
|
|
85
|
+
isSinglePane,
|
|
86
|
+
isLoading: isLoading || hasNoAccounts,
|
|
87
|
+
nav: (
|
|
88
|
+
<MailNav accounts={accounts} onMailboxSelect={() => showNav(false)} />
|
|
89
|
+
),
|
|
90
|
+
navOpen,
|
|
91
|
+
onOpenNav: () => showNav(true),
|
|
92
|
+
onCloseNav: () => showNav(false),
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<Outlet />
|
|
96
|
+
</CalendarShellProvider>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /import — the configuration import wizard, full screen.
|
|
3
|
+
*
|
|
4
|
+
* One address for two entrances: the first-run welcome screen offers it beside
|
|
5
|
+
* adding an account, and Settings › Advanced links to it. A path segment rather
|
|
6
|
+
* than a query param because it mounts a different surface entirely
|
|
7
|
+
* (url-state.md R1); the wizard's own step stays in component state, since the
|
|
8
|
+
* file behind it cannot survive a reload.
|
|
9
|
+
*/
|
|
10
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
12
|
+
import { useCallback } from "react";
|
|
13
|
+
import { ConfigImportWizard } from "@/components/config-import/ConfigImportWizard";
|
|
14
|
+
import { completeOnboarding } from "@/lib/onboarding-completion";
|
|
15
|
+
import { useTelemetry } from "@/lib/telemetry-context";
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/import")({
|
|
18
|
+
component: ConfigImportPage,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function ConfigImportPage() {
|
|
22
|
+
const telemetry = useTelemetry();
|
|
23
|
+
const queryClient = useQueryClient();
|
|
24
|
+
|
|
25
|
+
// A client-side navigation carries the query cache with it, and the /mail
|
|
26
|
+
// first-run guard reads the account list out of that cache — a stale
|
|
27
|
+
// zero-account entry would bounce a freshly imported instance straight back
|
|
28
|
+
// into onboarding. A document load starts from an empty cache.
|
|
29
|
+
//
|
|
30
|
+
// The event records an import, so only an apply that wrote may raise it: a
|
|
31
|
+
// 409 the reader aborted and a file that was refused both leave by the same
|
|
32
|
+
// door and neither imported anything.
|
|
33
|
+
const handleDone = useCallback(
|
|
34
|
+
(outcome: "imported" | "abandoned") => {
|
|
35
|
+
void completeOnboarding({
|
|
36
|
+
queryClient,
|
|
37
|
+
recordCompleted: () => {
|
|
38
|
+
if (outcome === "imported") {
|
|
39
|
+
telemetry.recordEvent("config.imported");
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
navigateToInbox: () => {
|
|
43
|
+
window.location.assign("/mail");
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
[telemetry, queryClient],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return <ConfigImportWizard onDone={handleDone} />;
|
|
51
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
3
3
|
import { useCallback } from "react";
|
|
4
4
|
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
|
|
5
5
|
import { completeOnboarding } from "@/lib/onboarding-completion";
|
|
@@ -18,6 +18,7 @@ export const Route = createFileRoute("/onboarding")({
|
|
|
18
18
|
function OnboardingPage() {
|
|
19
19
|
const telemetry = useTelemetry();
|
|
20
20
|
const queryClient = useQueryClient();
|
|
21
|
+
const navigate = useNavigate();
|
|
21
22
|
|
|
22
23
|
// A client-side navigation carries the query cache with it, and the /mail
|
|
23
24
|
// first-run guard reads config from that cache — a stale zero-account entry
|
|
@@ -36,5 +37,11 @@ function OnboardingPage() {
|
|
|
36
37
|
[telemetry, queryClient],
|
|
37
38
|
);
|
|
38
39
|
|
|
39
|
-
return
|
|
40
|
+
return (
|
|
41
|
+
<OnboardingWizard
|
|
42
|
+
skipWelcome={false}
|
|
43
|
+
onComplete={handleComplete}
|
|
44
|
+
onImportConfig={() => void navigate({ to: "/import" })}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
40
47
|
}
|
|
@@ -126,6 +126,17 @@ function needsReauth(account: RemitImapAccountResponse): boolean {
|
|
|
126
126
|
return account.connectionState === "reauth_required";
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The account arrived from a config file, which never carries a password, so it
|
|
131
|
+
* has none yet (#1021). It cannot sync until someone enters one, and the import
|
|
132
|
+
* wizard's "Finish later" leaves exactly these rows behind — so this screen has
|
|
133
|
+
* to offer the same way in, or the account is stranded with a red dot and no
|
|
134
|
+
* control that fixes it.
|
|
135
|
+
*/
|
|
136
|
+
function needsPassword(account: RemitImapAccountResponse): boolean {
|
|
137
|
+
return account.connectionState === "credentials_missing";
|
|
138
|
+
}
|
|
139
|
+
|
|
129
140
|
/* ------------------------------------------------------------------ */
|
|
130
141
|
/* Loading skeleton */
|
|
131
142
|
/* ------------------------------------------------------------------ */
|
|
@@ -410,6 +421,7 @@ function AccountsSettings() {
|
|
|
410
421
|
<div className="space-y-3">
|
|
411
422
|
{config.accounts.map((account) => {
|
|
412
423
|
const isReauth = needsReauth(account);
|
|
424
|
+
const isMissingPassword = needsPassword(account);
|
|
413
425
|
const isOAuthAccount = account.authType === "oauthMicrosoft";
|
|
414
426
|
// The mutation settling does not mean the window has gone anywhere:
|
|
415
427
|
// `assign` leaves the page here while the browser fetches Microsoft's
|
|
@@ -432,17 +444,23 @@ function AccountsSettings() {
|
|
|
432
444
|
});
|
|
433
445
|
},
|
|
434
446
|
}
|
|
435
|
-
:
|
|
447
|
+
: isMissingPassword
|
|
436
448
|
? {
|
|
437
|
-
label: "
|
|
449
|
+
label: "Enter password",
|
|
438
450
|
variant: "secondary",
|
|
439
451
|
onClick: () => setEditingAccountId(account.accountId),
|
|
440
452
|
}
|
|
441
|
-
:
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
453
|
+
: deriveState(account) === "error"
|
|
454
|
+
? {
|
|
455
|
+
label: "Reconnect",
|
|
456
|
+
variant: "secondary",
|
|
457
|
+
onClick: () => setEditingAccountId(account.accountId),
|
|
458
|
+
}
|
|
459
|
+
: {
|
|
460
|
+
label: "Manage",
|
|
461
|
+
variant: "ghost",
|
|
462
|
+
onClick: () => setEditingAccountId(account.accountId),
|
|
463
|
+
};
|
|
446
464
|
|
|
447
465
|
const trailingButton = (
|
|
448
466
|
<RowActions
|
|
@@ -465,7 +483,11 @@ function AccountsSettings() {
|
|
|
465
483
|
syncLabel={deriveSyncLabel(account)}
|
|
466
484
|
state={deriveState(account)}
|
|
467
485
|
errorDetail={
|
|
468
|
-
isReauth
|
|
486
|
+
isReauth
|
|
487
|
+
? "Re-authentication required"
|
|
488
|
+
: isMissingPassword
|
|
489
|
+
? "Imported from a config file, which carries no password. Enter one to start syncing."
|
|
490
|
+
: account.lastError
|
|
469
491
|
}
|
|
470
492
|
trailing={trailingButton}
|
|
471
493
|
/>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { SettingsShell } from "@remit/ui";
|
|
7
7
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
8
8
|
import { useState } from "react";
|
|
9
|
+
import { ConfigTransferPanel } from "@/components/settings/ConfigTransferPanel";
|
|
9
10
|
import { QuarantinePanel } from "@/components/settings/QuarantinePanel";
|
|
10
11
|
import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
|
|
11
12
|
import { TlsRootCaDownload } from "@/components/settings/TlsRootCaDownload";
|
|
@@ -40,9 +41,12 @@ function AdvancedSettings() {
|
|
|
40
41
|
>
|
|
41
42
|
<SelfUpdatePanel />
|
|
42
43
|
<QuarantinePanel />
|
|
44
|
+
<div className="mt-6 border-t border-line pt-4">
|
|
45
|
+
<ConfigTransferPanel />
|
|
46
|
+
</div>
|
|
43
47
|
<div className="mt-6 border-t border-line pt-4">
|
|
44
48
|
<p className="text-sm text-fg-muted">
|
|
45
|
-
Notification rules
|
|
49
|
+
Notification rules are coming in a future release.
|
|
46
50
|
</p>
|
|
47
51
|
</div>
|
|
48
52
|
<TlsRootCaDownload />
|