@remit/web-client 0.0.235 → 0.0.237
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/AgendaStrip.stories.tsx +28 -0
- package/src/components/calendar/AgendaView.stories.tsx +116 -0
- package/src/components/calendar/CalendarShell.stories.tsx +175 -0
- package/src/components/calendar/CalendarViewPlaceholder.stories.tsx +54 -0
- package/src/components/calendar/OpenCalendarEvent.stories.tsx +155 -0
- package/src/components/calendar/WriteCalendarEvent.stories.tsx +89 -0
- package/src/components/calendar/calendar-story-fixtures.ts +137 -0
- package/src/components/calendar/calendar-story-server.tsx +152 -0
- package/src/index.css +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.237",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@remit/ui";
|
|
9
9
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
10
10
|
import { useState } from "react";
|
|
11
|
+
import { expect, within } from "storybook/test";
|
|
11
12
|
import { freeStretchesByDate } from "@/hooks/calendar";
|
|
12
13
|
import { AgendaStrip } from "./AgendaStrip";
|
|
13
14
|
|
|
@@ -148,6 +149,7 @@ function Strip({
|
|
|
148
149
|
loading,
|
|
149
150
|
error,
|
|
150
151
|
busy,
|
|
152
|
+
atCap,
|
|
151
153
|
}: {
|
|
152
154
|
events: CalendarEventData[];
|
|
153
155
|
initialDensity: Density;
|
|
@@ -156,6 +158,8 @@ function Strip({
|
|
|
156
158
|
error?: unknown;
|
|
157
159
|
/** Busy time the strip is not drawing, as `/calendar-free-busy` returns it. */
|
|
158
160
|
busy: { start: string; end: string }[];
|
|
161
|
+
/** The run has grown as far as scrolling takes it, at one end or both. */
|
|
162
|
+
atCap?: boolean;
|
|
159
163
|
}) {
|
|
160
164
|
const [density] = useState<Density>(initialDensity);
|
|
161
165
|
const [selected, setSelected] = useState("");
|
|
@@ -185,6 +189,10 @@ function Strip({
|
|
|
185
189
|
onGoToDate={() => undefined}
|
|
186
190
|
onReachStart={() => undefined}
|
|
187
191
|
onReachEnd={() => undefined}
|
|
192
|
+
atStartCap={atCap}
|
|
193
|
+
atEndCap={atCap}
|
|
194
|
+
onLoadEarlier={() => undefined}
|
|
195
|
+
onLoadLater={() => undefined}
|
|
188
196
|
onVisibleDayChange={() => undefined}
|
|
189
197
|
/>
|
|
190
198
|
);
|
|
@@ -254,6 +262,26 @@ export const BusyOnACalendarNotDrawn: Story = {
|
|
|
254
262
|
},
|
|
255
263
|
};
|
|
256
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The run has grown as far as scrolling takes it, and stops there rather than
|
|
267
|
+
* fetching its way across years nobody asked for. A sparse diary draws shorter
|
|
268
|
+
* than the distance the strip fetches at, so an end measured off the content
|
|
269
|
+
* alone is reached on the first layout pass and never stops being reached —
|
|
270
|
+
* which is why the cap says so and hands the reader the next stretch instead.
|
|
271
|
+
*/
|
|
272
|
+
export const AtTheCap: Story = {
|
|
273
|
+
args: { atCap: true },
|
|
274
|
+
play: async ({ canvasElement }) => {
|
|
275
|
+
const canvas = within(canvasElement);
|
|
276
|
+
await expect(
|
|
277
|
+
await canvas.findByRole("button", { name: "Show earlier days" }),
|
|
278
|
+
).toBeVisible();
|
|
279
|
+
await expect(
|
|
280
|
+
canvas.getByRole("button", { name: "Show later days" }),
|
|
281
|
+
).toBeVisible();
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
257
285
|
/** A read that was refused. An empty strip would say the opposite of this. */
|
|
258
286
|
export const CouldNotLoad: Story = {
|
|
259
287
|
args: {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Density } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
4
|
+
import { AgendaView } from "./AgendaView";
|
|
5
|
+
import {
|
|
6
|
+
calendars,
|
|
7
|
+
fortnight,
|
|
8
|
+
instancesWithin,
|
|
9
|
+
STORY_DATE,
|
|
10
|
+
} from "./calendar-story-fixtures";
|
|
11
|
+
import {
|
|
12
|
+
type CalendarServer,
|
|
13
|
+
CalendarStory,
|
|
14
|
+
json,
|
|
15
|
+
never,
|
|
16
|
+
} from "./calendar-story-server";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The agenda bound to the address, which is the only way it exists.
|
|
20
|
+
*
|
|
21
|
+
* It holds no days of its own: the run comes from the day the URL names, the
|
|
22
|
+
* weeks are fetched one at a time as the reader scrolls, and every move it makes
|
|
23
|
+
* leaves as a new address. So the states worth a story are the ones the reader
|
|
24
|
+
* cannot tell apart by looking — a fortnight with two things in it, a fortnight
|
|
25
|
+
* nobody has heard back about, and a fortnight the server refused. Drawn wrong,
|
|
26
|
+
* the last two are the first one, and the reader plans a clear week they do not
|
|
27
|
+
* have.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const AGENDA = `/calendar/agenda/${STORY_DATE}`;
|
|
31
|
+
|
|
32
|
+
const answering = (events: (url: URL) => Response | Promise<Response>) =>
|
|
33
|
+
((request: Request) => {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
|
|
36
|
+
if (url.pathname.endsWith("/calendar-events")) return events(url);
|
|
37
|
+
return json({ items: [] });
|
|
38
|
+
}) satisfies CalendarServer;
|
|
39
|
+
|
|
40
|
+
function Agenda({
|
|
41
|
+
density,
|
|
42
|
+
server,
|
|
43
|
+
}: {
|
|
44
|
+
density: Density;
|
|
45
|
+
server: CalendarServer;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex h-dvh flex-col bg-canvas">
|
|
49
|
+
<CalendarStory
|
|
50
|
+
entry={AGENDA}
|
|
51
|
+
server={server}
|
|
52
|
+
pane={<AgendaView density={density} onPickSlot={() => undefined} />}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const meta: Meta<typeof Agenda> = {
|
|
59
|
+
title: "App/Calendar/Agenda view",
|
|
60
|
+
component: Agenda,
|
|
61
|
+
parameters: { layout: "fullscreen" },
|
|
62
|
+
args: {
|
|
63
|
+
density: "comfortable",
|
|
64
|
+
server: answering((url) =>
|
|
65
|
+
json({ items: instancesWithin(url, fortnight) }),
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
export default meta;
|
|
70
|
+
|
|
71
|
+
type Story = StoryObj<typeof Agenda>;
|
|
72
|
+
|
|
73
|
+
/** The weeks around the day the address names, drawn from what the server sent. */
|
|
74
|
+
export const Populated: Story = {
|
|
75
|
+
play: async ({ canvasElement }) => {
|
|
76
|
+
const canvas = within(canvasElement);
|
|
77
|
+
await expect(await canvas.findByText("Roadmap review")).toBeVisible();
|
|
78
|
+
await expect(canvas.getByText("Standup")).toBeVisible();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The weeks are still out. Every day keeps the row it will occupy, so nothing
|
|
84
|
+
* jumps when the answers land — and, which matters more, an unanswered day says
|
|
85
|
+
* so rather than drawing as a day with nothing booked on it.
|
|
86
|
+
*/
|
|
87
|
+
export const LoadingWeeks: Story = {
|
|
88
|
+
args: { server: answering(never) },
|
|
89
|
+
play: async ({ canvasElement }) => {
|
|
90
|
+
const canvas = within(canvasElement);
|
|
91
|
+
await expect(
|
|
92
|
+
await canvas.findByTestId(`agenda-day-pending-${STORY_DATE}`),
|
|
93
|
+
).toBeVisible();
|
|
94
|
+
await expect(canvas.queryByText("Roadmap review")).toBeNull();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The read was refused. This is the state an empty strip must never be confused
|
|
100
|
+
* with: a fortnight drawn blank because the request failed reads as a fortnight
|
|
101
|
+
* with nothing in it, and the reader gives the time away.
|
|
102
|
+
*/
|
|
103
|
+
export const RefusedRead: Story = {
|
|
104
|
+
args: {
|
|
105
|
+
server: answering(() =>
|
|
106
|
+
json({ message: "The window has to be shorter than a year." }, 400),
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
play: async ({ canvasElement }) => {
|
|
110
|
+
const canvas = within(canvasElement);
|
|
111
|
+
await expect(
|
|
112
|
+
await canvas.findByText("Couldn't load these days"),
|
|
113
|
+
).toBeVisible();
|
|
114
|
+
await expect(canvas.queryByText("Roadmap review")).toBeNull();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarColorId,
|
|
3
|
+
type CalendarEventData,
|
|
4
|
+
type Density,
|
|
5
|
+
NavSidebar,
|
|
6
|
+
} from "@remit/ui";
|
|
7
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
10
|
+
import { CalendarShell, CalendarShellProvider } from "./CalendarShell";
|
|
11
|
+
import { CalendarWorkspace } from "./CalendarWorkspace";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The chrome the calendar shares with mail, at the two widths that change what
|
|
15
|
+
* the reader can reach.
|
|
16
|
+
*
|
|
17
|
+
* Above the boundary the nav is a pane and the calendar sits beside it. Below
|
|
18
|
+
* it the nav is a slide-over, nothing opens a slide-over on its own, and the
|
|
19
|
+
* calendar is the whole width of the app — so the way back to the mail has to be
|
|
20
|
+
* a control the calendar itself carries. Losing it strands a reader inside their
|
|
21
|
+
* diary with no route out, which is what these two stories hold the shell to.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
25
|
+
const DATE = "2026-06-10";
|
|
26
|
+
const NOW = `${DATE}T09:30:00+02:00`;
|
|
27
|
+
const CALENDAR = "cal_work";
|
|
28
|
+
|
|
29
|
+
const colorByCalendarId: Record<string, CalendarColorId> = {
|
|
30
|
+
[CALENDAR]: "cal-1",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const roadmap: CalendarEventData = {
|
|
34
|
+
id: "roadmap",
|
|
35
|
+
calendarId: CALENDAR,
|
|
36
|
+
title: "Roadmap review",
|
|
37
|
+
start: `${DATE}T10:00:00+02:00`,
|
|
38
|
+
end: `${DATE}T11:30:00+02:00`,
|
|
39
|
+
allDay: false,
|
|
40
|
+
location: "",
|
|
41
|
+
notes: "",
|
|
42
|
+
attendees: [],
|
|
43
|
+
myRsvp: "accepted",
|
|
44
|
+
threadId: "",
|
|
45
|
+
threadSubject: "",
|
|
46
|
+
timeZone: TIME_ZONE,
|
|
47
|
+
zoneCertainty: "explicit",
|
|
48
|
+
recurrenceRule: "",
|
|
49
|
+
seriesId: "",
|
|
50
|
+
seriesException: false,
|
|
51
|
+
status: "confirmed",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const nav = (
|
|
55
|
+
<NavSidebar
|
|
56
|
+
accounts={[
|
|
57
|
+
{
|
|
58
|
+
id: "acct_work",
|
|
59
|
+
label: "Work",
|
|
60
|
+
email: "work@example.invalid",
|
|
61
|
+
mailboxes: [
|
|
62
|
+
{ id: "mb_inbox", name: "Inbox", role: "inbox", unseen: 3 },
|
|
63
|
+
{ id: "mb_sent", name: "Sent", role: "sent" },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]}
|
|
67
|
+
selectedNavId="calendar"
|
|
68
|
+
calendarNav="shown"
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const reading = (
|
|
73
|
+
<div className="flex h-full items-center justify-center bg-surface text-sm text-fg-muted">
|
|
74
|
+
Nothing open.
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** The layout publishes the chrome; a story holds the slide-over's own state. */
|
|
79
|
+
function Shell({
|
|
80
|
+
width,
|
|
81
|
+
isSinglePane,
|
|
82
|
+
}: {
|
|
83
|
+
/** The shell measures its own box, so the story gives it one. */
|
|
84
|
+
width: number;
|
|
85
|
+
isSinglePane: boolean;
|
|
86
|
+
}) {
|
|
87
|
+
const [navOpen, setNavOpen] = useState(false);
|
|
88
|
+
const [density, setDensity] = useState<Density>("comfortable");
|
|
89
|
+
return (
|
|
90
|
+
<div style={{ width }} className="bg-canvas">
|
|
91
|
+
<CalendarShellProvider
|
|
92
|
+
chrome={{
|
|
93
|
+
isSinglePane,
|
|
94
|
+
isLoading: false,
|
|
95
|
+
nav,
|
|
96
|
+
navOpen,
|
|
97
|
+
onOpenNav: () => setNavOpen(true),
|
|
98
|
+
onCloseNav: () => setNavOpen(false),
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<CalendarShell
|
|
102
|
+
hasOpenEvent={false}
|
|
103
|
+
reading={reading}
|
|
104
|
+
workspace={
|
|
105
|
+
<CalendarWorkspace
|
|
106
|
+
view="week"
|
|
107
|
+
date={DATE}
|
|
108
|
+
events={[roadmap]}
|
|
109
|
+
colorByCalendarId={colorByCalendarId}
|
|
110
|
+
agenda={null}
|
|
111
|
+
density={density}
|
|
112
|
+
selectedEventId=""
|
|
113
|
+
timeZone={TIME_ZONE}
|
|
114
|
+
now={NOW}
|
|
115
|
+
onChangeView={() => undefined}
|
|
116
|
+
onToday={() => undefined}
|
|
117
|
+
onStep={() => undefined}
|
|
118
|
+
onChangeDensity={setDensity}
|
|
119
|
+
onSelectEvent={() => undefined}
|
|
120
|
+
onPickSlot={() => undefined}
|
|
121
|
+
/>
|
|
122
|
+
}
|
|
123
|
+
/>
|
|
124
|
+
</CalendarShellProvider>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const meta: Meta<typeof Shell> = {
|
|
130
|
+
title: "App/Calendar/Shell",
|
|
131
|
+
component: Shell,
|
|
132
|
+
parameters: { layout: "fullscreen" },
|
|
133
|
+
};
|
|
134
|
+
export default meta;
|
|
135
|
+
|
|
136
|
+
type Story = StoryObj<typeof Shell>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Below the boundary. The nav is a slide-over with nothing else to open it, so
|
|
140
|
+
* the calendar's own header carries the control — and pressing it is the whole
|
|
141
|
+
* of the way back to the mail.
|
|
142
|
+
*/
|
|
143
|
+
export const NavAsSlideOver: Story = {
|
|
144
|
+
args: { width: 900, isSinglePane: true },
|
|
145
|
+
play: async ({ canvasElement }) => {
|
|
146
|
+
const canvas = within(canvasElement);
|
|
147
|
+
await expect(canvas.queryByRole("button", { name: /Inbox/ })).toBeNull();
|
|
148
|
+
|
|
149
|
+
const folders = await canvas.findByRole("button", {
|
|
150
|
+
name: "Open folders",
|
|
151
|
+
});
|
|
152
|
+
await userEvent.click(folders);
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
await canvas.findByRole("button", { name: /Inbox/ }),
|
|
156
|
+
).toBeVisible();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Above it. The nav is a column of its own, so a second control that opened it
|
|
162
|
+
* would be a button for something already on screen.
|
|
163
|
+
*/
|
|
164
|
+
export const NavAsPane: Story = {
|
|
165
|
+
args: { width: 1440, isSinglePane: false },
|
|
166
|
+
play: async ({ canvasElement }) => {
|
|
167
|
+
const canvas = within(canvasElement);
|
|
168
|
+
await expect(
|
|
169
|
+
await canvas.findByRole("button", { name: /Inbox/ }),
|
|
170
|
+
).toBeVisible();
|
|
171
|
+
await expect(
|
|
172
|
+
canvas.queryByRole("button", { name: "Open folders" }),
|
|
173
|
+
).toBeNull();
|
|
174
|
+
},
|
|
175
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { CalendarViewPlaceholder } from "./CalendarViewPlaceholder";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A zoom the ladder offers and does not draw yet.
|
|
7
|
+
*
|
|
8
|
+
* The route is addressable at all five zooms, so two of them land on this. What
|
|
9
|
+
* it has to do is say which of the two things it is: a view that rendered
|
|
10
|
+
* nothing would be indistinguishable from a month with nothing booked in it,
|
|
11
|
+
* and the reader would plan around an empty screen.
|
|
12
|
+
*/
|
|
13
|
+
const meta: Meta<typeof CalendarViewPlaceholder> = {
|
|
14
|
+
title: "App/Calendar/Not built yet",
|
|
15
|
+
component: CalendarViewPlaceholder,
|
|
16
|
+
parameters: { layout: "fullscreen" },
|
|
17
|
+
render: (args) => (
|
|
18
|
+
<div className="h-dvh bg-canvas">
|
|
19
|
+
<CalendarViewPlaceholder {...args} />
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
};
|
|
23
|
+
export default meta;
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof CalendarViewPlaceholder>;
|
|
26
|
+
|
|
27
|
+
/** It names the zoom it is waiting on, and the three that work today. */
|
|
28
|
+
export const Year: Story = {
|
|
29
|
+
args: { view: "year" },
|
|
30
|
+
play: async ({ canvasElement }) => {
|
|
31
|
+
const canvas = within(canvasElement);
|
|
32
|
+
await expect(
|
|
33
|
+
canvas.getByTestId("calendar-placeholder-year"),
|
|
34
|
+
).toHaveTextContent(
|
|
35
|
+
"The year grid arrives with the rest of the zoom ladder.",
|
|
36
|
+
);
|
|
37
|
+
await expect(canvas.getByText("Not built yet")).toBeVisible();
|
|
38
|
+
await expect(
|
|
39
|
+
canvas.getByText(/Week, Day and Agenda work now\./),
|
|
40
|
+
).toBeVisible();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Month: Story = {
|
|
45
|
+
args: { view: "month" },
|
|
46
|
+
play: async ({ canvasElement }) => {
|
|
47
|
+
const canvas = within(canvasElement);
|
|
48
|
+
await expect(
|
|
49
|
+
canvas.getByTestId("calendar-placeholder-month"),
|
|
50
|
+
).toHaveTextContent(
|
|
51
|
+
"The month grid arrives with the rest of the zoom ladder.",
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import {
|
|
4
|
+
calendars,
|
|
5
|
+
fortnight,
|
|
6
|
+
instancesWithin,
|
|
7
|
+
ROADMAP_OBJECT,
|
|
8
|
+
roadmapResource,
|
|
9
|
+
STANDUP_OBJECT,
|
|
10
|
+
STANDUP_RECURRENCE,
|
|
11
|
+
STORY_WEEK,
|
|
12
|
+
standupResource,
|
|
13
|
+
} from "./calendar-story-fixtures";
|
|
14
|
+
import {
|
|
15
|
+
type CalendarServer,
|
|
16
|
+
CalendarStory,
|
|
17
|
+
json,
|
|
18
|
+
} from "./calendar-story-server";
|
|
19
|
+
import { OpenCalendarEvent } from "./OpenCalendarEvent";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The event the address names, and everything a reader can do to it.
|
|
23
|
+
*
|
|
24
|
+
* The pane is reached at two addresses — a resource on its own, and one
|
|
25
|
+
* occurrence under a series — and which one it is decides whether an edit has a
|
|
26
|
+
* question to answer first. That question is the state worth holding: it is
|
|
27
|
+
* asked here and nowhere else, and asking it late, on the way out, would mean
|
|
28
|
+
* the reader has already typed the change before being told what it reaches.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const answering = (
|
|
32
|
+
fail?: (request: Request) => Response | undefined,
|
|
33
|
+
): CalendarServer => {
|
|
34
|
+
return (request) => {
|
|
35
|
+
const refusal = fail?.(request);
|
|
36
|
+
if (refusal) return refusal;
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
|
|
39
|
+
if (url.pathname.endsWith("/calendar-events"))
|
|
40
|
+
return json({ items: instancesWithin(url, fortnight) });
|
|
41
|
+
if (url.pathname.includes(STANDUP_OBJECT)) return json(standupResource);
|
|
42
|
+
if (url.pathname.includes(ROADMAP_OBJECT)) return json(roadmapResource);
|
|
43
|
+
return json({ items: [] });
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function Opened({
|
|
48
|
+
calendarObjectId,
|
|
49
|
+
recurrenceId,
|
|
50
|
+
server,
|
|
51
|
+
}: {
|
|
52
|
+
calendarObjectId: string;
|
|
53
|
+
recurrenceId?: string;
|
|
54
|
+
server: CalendarServer;
|
|
55
|
+
}) {
|
|
56
|
+
const address =
|
|
57
|
+
recurrenceId === undefined
|
|
58
|
+
? `${STORY_WEEK}/${calendarObjectId}`
|
|
59
|
+
: `${STORY_WEEK}/${calendarObjectId}/${recurrenceId}`;
|
|
60
|
+
return (
|
|
61
|
+
<div className="h-dvh max-w-xl border-l border-line bg-canvas">
|
|
62
|
+
<CalendarStory
|
|
63
|
+
entry={address}
|
|
64
|
+
server={server}
|
|
65
|
+
pane={
|
|
66
|
+
<OpenCalendarEvent
|
|
67
|
+
calendarObjectId={calendarObjectId}
|
|
68
|
+
recurrenceId={recurrenceId}
|
|
69
|
+
/>
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const meta: Meta<typeof Opened> = {
|
|
77
|
+
title: "App/Calendar/Open event",
|
|
78
|
+
component: Opened,
|
|
79
|
+
parameters: { layout: "fullscreen" },
|
|
80
|
+
args: { calendarObjectId: ROADMAP_OBJECT, server: answering() },
|
|
81
|
+
};
|
|
82
|
+
export default meta;
|
|
83
|
+
|
|
84
|
+
type Story = StoryObj<typeof Opened>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A resource that does not recur. Edit and Delete mean the whole of it, so
|
|
88
|
+
* there is nothing to ask and they open on the press.
|
|
89
|
+
*/
|
|
90
|
+
export const NonRecurring: Story = {
|
|
91
|
+
play: async ({ canvasElement }) => {
|
|
92
|
+
const canvas = within(canvasElement);
|
|
93
|
+
await expect(await canvas.findByText("Roadmap review")).toBeVisible();
|
|
94
|
+
// Off the stored resource rather than the listing, so this is also the
|
|
95
|
+
// proof the pane read the version its writes will be conditional on.
|
|
96
|
+
await expect(await canvas.findByText("Room Zuid")).toBeVisible();
|
|
97
|
+
await expect(canvas.getByRole("button", { name: "Edit" })).toBeVisible();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One morning of a series. Which occurrences the edit reaches changes what the
|
|
103
|
+
* edit is, so it is settled while it can still be answered — and the rule is
|
|
104
|
+
* read back in words, not as an RRULE.
|
|
105
|
+
*/
|
|
106
|
+
export const RecurringScopePrompt: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
calendarObjectId: STANDUP_OBJECT,
|
|
109
|
+
recurrenceId: STANDUP_RECURRENCE,
|
|
110
|
+
},
|
|
111
|
+
play: async ({ canvasElement }) => {
|
|
112
|
+
const canvas = within(canvasElement);
|
|
113
|
+
await expect(await canvas.findByText("Standup")).toBeVisible();
|
|
114
|
+
await expect(
|
|
115
|
+
canvas.queryByText("What should the change apply to?"),
|
|
116
|
+
).toBeNull();
|
|
117
|
+
|
|
118
|
+
await userEvent.click(await canvas.findByRole("button", { name: "Edit" }));
|
|
119
|
+
|
|
120
|
+
await expect(await canvas.findByText("Standup repeats")).toBeVisible();
|
|
121
|
+
await expect(
|
|
122
|
+
canvas.getByText("What should the change apply to?"),
|
|
123
|
+
).toBeVisible();
|
|
124
|
+
await expect(
|
|
125
|
+
canvas.getByRole("button", { name: /This event/ }),
|
|
126
|
+
).toBeVisible();
|
|
127
|
+
await expect(
|
|
128
|
+
canvas.getByRole("button", { name: /The whole series/ }),
|
|
129
|
+
).toBeVisible();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Somebody replaced the event between it being read and the delete going out —
|
|
135
|
+
* over CalDAV, or in another tab. Nothing was removed, and the pane says so
|
|
136
|
+
* where the reader is looking rather than resolving it by winning.
|
|
137
|
+
*/
|
|
138
|
+
export const ChangedElsewhere: Story = {
|
|
139
|
+
args: {
|
|
140
|
+
server: answering((request) =>
|
|
141
|
+
request.method === "DELETE"
|
|
142
|
+
? json({ message: "The event has changed." }, 412)
|
|
143
|
+
: undefined,
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
play: async ({ canvasElement }) => {
|
|
147
|
+
const canvas = within(canvasElement);
|
|
148
|
+
await userEvent.click(
|
|
149
|
+
await canvas.findByRole("button", { name: "Delete" }),
|
|
150
|
+
);
|
|
151
|
+
await expect(await canvas.findByRole("alert")).toHaveTextContent(
|
|
152
|
+
/changed somewhere else/,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CalendarSlotPick } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
4
|
+
import { calendars, STORY_DATE, STORY_WEEK } from "./calendar-story-fixtures";
|
|
5
|
+
import {
|
|
6
|
+
type CalendarServer,
|
|
7
|
+
CalendarStory,
|
|
8
|
+
json,
|
|
9
|
+
} from "./calendar-story-server";
|
|
10
|
+
import { WriteCalendarEvent } from "./WriteCalendarEvent";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Writing a new event, at `/calendar/{view}/{date}/new`.
|
|
14
|
+
*
|
|
15
|
+
* The draft it opens on comes from two places and neither is a prop: the day is
|
|
16
|
+
* the address's, and the hours are the slot the reader dragged out of the grid,
|
|
17
|
+
* handed over by the click that opened the form. The slot is deliberately not in
|
|
18
|
+
* the URL — a half-written event is not a fact about where the reader is — so
|
|
19
|
+
* the composer opened any other way starts on the day the calendar is showing,
|
|
20
|
+
* which is the second story here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const server: CalendarServer = (request) => {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
|
|
26
|
+
return json({ items: [] });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const slot: CalendarSlotPick = {
|
|
30
|
+
date: "2026-06-11",
|
|
31
|
+
startTime: "14:00",
|
|
32
|
+
endTime: "15:00",
|
|
33
|
+
allDay: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Composer({ pick }: { pick?: CalendarSlotPick }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="h-dvh max-w-2xl border-l border-line bg-canvas">
|
|
39
|
+
<CalendarStory
|
|
40
|
+
entry={`${STORY_WEEK}/new`}
|
|
41
|
+
server={server}
|
|
42
|
+
pick={pick}
|
|
43
|
+
pane={<WriteCalendarEvent onClose={() => undefined} />}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const meta: Meta<typeof Composer> = {
|
|
50
|
+
title: "App/Calendar/Write event",
|
|
51
|
+
component: Composer,
|
|
52
|
+
parameters: { layout: "fullscreen" },
|
|
53
|
+
};
|
|
54
|
+
export default meta;
|
|
55
|
+
|
|
56
|
+
type Story = StoryObj<typeof Composer>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dragged out of Thursday afternoon. The hours the reader picked are already in
|
|
60
|
+
* the form, so the gesture is the answer rather than a hint they now retype.
|
|
61
|
+
*/
|
|
62
|
+
export const SeededFromASlotPick: Story = {
|
|
63
|
+
args: { pick: slot },
|
|
64
|
+
play: async ({ canvasElement }) => {
|
|
65
|
+
const canvas = within(canvasElement);
|
|
66
|
+
await expect(await canvas.findByLabelText("Date")).toHaveValue(slot.date);
|
|
67
|
+
await expect(canvas.getByLabelText("Start time")).toHaveValue(
|
|
68
|
+
slot.startTime,
|
|
69
|
+
);
|
|
70
|
+
await expect(canvas.getByLabelText("End time")).toHaveValue(slot.endTime);
|
|
71
|
+
await expect(canvas.getByLabelText("Title")).toHaveValue("");
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Opened with no slot behind it. It starts on the day the calendar is showing,
|
|
77
|
+
* and on the calendar the listing provisioned — a reader who has written nothing
|
|
78
|
+
* yet should not have to choose where it goes before they can type.
|
|
79
|
+
*/
|
|
80
|
+
export const Empty: Story = {
|
|
81
|
+
play: async ({ canvasElement }) => {
|
|
82
|
+
const canvas = within(canvasElement);
|
|
83
|
+
await expect(await canvas.findByLabelText("Date")).toHaveValue(STORY_DATE);
|
|
84
|
+
await expect(canvas.getByLabelText("Title")).toHaveValue("");
|
|
85
|
+
await expect(
|
|
86
|
+
await canvas.findByRole("radio", { name: "Northwind" }),
|
|
87
|
+
).toBeChecked();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapCalendarEventInstance,
|
|
3
|
+
RemitImapCalendarEventResponse,
|
|
4
|
+
RemitImapCalendarResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The synthetic account the calendar stories read.
|
|
9
|
+
*
|
|
10
|
+
* One collection, on a real zone, with occurrences spelled the way the listing
|
|
11
|
+
* spells them. Nothing here is anybody's data: the ids are fixed strings and the
|
|
12
|
+
* week is one the calendar will never be looking at by accident.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const WORK_CALENDAR = "11111111-1111-4111-8111-111111111111";
|
|
16
|
+
export const ROADMAP_OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
17
|
+
export const STANDUP_OBJECT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
18
|
+
export const STANDUP_RECURRENCE = "2026-06-10T07:15:00Z";
|
|
19
|
+
|
|
20
|
+
export const STORY_DATE = "2026-06-10";
|
|
21
|
+
export const STORY_WEEK = `/calendar/week/${STORY_DATE}`;
|
|
22
|
+
|
|
23
|
+
export const calendars: RemitImapCalendarResponse[] = [
|
|
24
|
+
{
|
|
25
|
+
calendarId: WORK_CALENDAR,
|
|
26
|
+
accountConfigId: "cfg-story",
|
|
27
|
+
urlSegment: "work",
|
|
28
|
+
displayName: "Northwind",
|
|
29
|
+
color: "Cal1",
|
|
30
|
+
componentSet: "VeventOnly",
|
|
31
|
+
source: "UserCreated",
|
|
32
|
+
timezone: "Europe/Amsterdam",
|
|
33
|
+
syncSequence: 1,
|
|
34
|
+
createdAt: 0,
|
|
35
|
+
updatedAt: 0,
|
|
36
|
+
} as RemitImapCalendarResponse,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const template = {
|
|
40
|
+
calendarId: WORK_CALENDAR,
|
|
41
|
+
calendarObjectId: ROADMAP_OBJECT,
|
|
42
|
+
recurrenceId: "",
|
|
43
|
+
icalUid: "uid-roadmap",
|
|
44
|
+
summary: "",
|
|
45
|
+
start: "",
|
|
46
|
+
end: "",
|
|
47
|
+
allDay: false,
|
|
48
|
+
status: "Confirmed",
|
|
49
|
+
transparency: "Opaque",
|
|
50
|
+
zoneCertainty: "Explicit",
|
|
51
|
+
etag: "etag-1",
|
|
52
|
+
hasRecurrence: false,
|
|
53
|
+
} as RemitImapCalendarEventInstance;
|
|
54
|
+
|
|
55
|
+
const at = (
|
|
56
|
+
summary: string,
|
|
57
|
+
day: string,
|
|
58
|
+
from: string,
|
|
59
|
+
to: string,
|
|
60
|
+
over: Partial<RemitImapCalendarEventInstance> = {},
|
|
61
|
+
): RemitImapCalendarEventInstance =>
|
|
62
|
+
({
|
|
63
|
+
...template,
|
|
64
|
+
...over,
|
|
65
|
+
summary,
|
|
66
|
+
start: `2026-06-${day}T${from}:00+02:00`,
|
|
67
|
+
end: `2026-06-${day}T${to}:00+02:00`,
|
|
68
|
+
}) as RemitImapCalendarEventInstance;
|
|
69
|
+
|
|
70
|
+
export const roadmap = at("Roadmap review", "10", "10:00", "11:30");
|
|
71
|
+
|
|
72
|
+
export const standup = at("Standup", "10", "09:15", "09:30", {
|
|
73
|
+
calendarObjectId: STANDUP_OBJECT,
|
|
74
|
+
icalUid: "uid-standup",
|
|
75
|
+
recurrenceId: STANDUP_RECURRENCE,
|
|
76
|
+
hasRecurrence: true,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** A fortnight that is mostly empty, which is what a real fortnight looks like. */
|
|
80
|
+
export const fortnight: RemitImapCalendarEventInstance[] = [
|
|
81
|
+
standup,
|
|
82
|
+
roadmap,
|
|
83
|
+
at("Dentist", "11", "11:00", "12:00", { icalUid: "uid-dentist" }),
|
|
84
|
+
at("Retro", "18", "16:00", "17:00", { icalUid: "uid-retro" }),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export const roadmapResource = {
|
|
88
|
+
calendarObjectId: ROADMAP_OBJECT,
|
|
89
|
+
calendarId: WORK_CALENDAR,
|
|
90
|
+
resourceName: "roadmap.ics",
|
|
91
|
+
icalUid: "uid-roadmap",
|
|
92
|
+
icalData: [
|
|
93
|
+
"BEGIN:VCALENDAR",
|
|
94
|
+
"BEGIN:VEVENT",
|
|
95
|
+
"SUMMARY:Roadmap review",
|
|
96
|
+
"LOCATION:Room Zuid",
|
|
97
|
+
"DESCRIPTION:Bring the staffing numbers.",
|
|
98
|
+
"END:VEVENT",
|
|
99
|
+
"END:VCALENDAR",
|
|
100
|
+
].join("\r\n"),
|
|
101
|
+
etag: "etag-1",
|
|
102
|
+
} as RemitImapCalendarEventResponse;
|
|
103
|
+
|
|
104
|
+
export const standupResource = {
|
|
105
|
+
...roadmapResource,
|
|
106
|
+
calendarObjectId: STANDUP_OBJECT,
|
|
107
|
+
resourceName: "standup.ics",
|
|
108
|
+
icalUid: "uid-standup",
|
|
109
|
+
icalData: [
|
|
110
|
+
"BEGIN:VCALENDAR",
|
|
111
|
+
"BEGIN:VEVENT",
|
|
112
|
+
"SUMMARY:Standup",
|
|
113
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
114
|
+
"END:VEVENT",
|
|
115
|
+
"END:VCALENDAR",
|
|
116
|
+
].join("\r\n"),
|
|
117
|
+
} as RemitImapCalendarEventResponse;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The occurrences inside the window a request asked for.
|
|
121
|
+
*
|
|
122
|
+
* The panes read a week at a time and hold several weeks at once, so a server
|
|
123
|
+
* that answered every window with the same list would draw each day's events
|
|
124
|
+
* once per window it appears in.
|
|
125
|
+
*/
|
|
126
|
+
export const instancesWithin = (
|
|
127
|
+
url: URL,
|
|
128
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
129
|
+
): RemitImapCalendarEventInstance[] => {
|
|
130
|
+
const from = Date.parse(url.searchParams.get("from") ?? "");
|
|
131
|
+
const to = Date.parse(url.searchParams.get("to") ?? "");
|
|
132
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
|
|
133
|
+
return instances.filter((instance) => {
|
|
134
|
+
const start = Date.parse(instance.start);
|
|
135
|
+
return start >= from && start < to;
|
|
136
|
+
});
|
|
137
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { CalendarSlotPick } from "@remit/ui";
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
type AnyRouter,
|
|
5
|
+
createMemoryHistory,
|
|
6
|
+
createRootRoute,
|
|
7
|
+
createRoute,
|
|
8
|
+
createRouter,
|
|
9
|
+
Outlet,
|
|
10
|
+
RouterProvider,
|
|
11
|
+
} from "@tanstack/react-router";
|
|
12
|
+
import { type ReactNode, useState } from "react";
|
|
13
|
+
import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
|
|
14
|
+
import { calendarSearchSchema } from "@/lib/calendar-route";
|
|
15
|
+
import { client } from "@/lib/client";
|
|
16
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The calendar's addresses and its server, stood up for a story.
|
|
20
|
+
*
|
|
21
|
+
* `AgendaView`, `OpenCalendarEvent` and `WriteCalendarEvent` are the three panes
|
|
22
|
+
* that read the address and read the API, so a story cannot hand them props:
|
|
23
|
+
* what they draw comes out of a URL and a response. Both are supplied here, with
|
|
24
|
+
* synthetic answers, so the story still names one state and asserts it.
|
|
25
|
+
*
|
|
26
|
+
* The routes are built out of the real paths rather than out of the generated
|
|
27
|
+
* tree — the route objects are module singletons and re-parenting one would
|
|
28
|
+
* mutate it for every other story in the run. Their ids are what the panes read
|
|
29
|
+
* their params `from`, which is what makes these the app's addresses rather than
|
|
30
|
+
* an imitation of them.
|
|
31
|
+
*
|
|
32
|
+
* The server is the app's own generated client with its `fetch` swapped, so the
|
|
33
|
+
* responses run through the same error interceptor the app installs: a 412 comes
|
|
34
|
+
* back as the conflict the pane states, and not as a message it happens to
|
|
35
|
+
* print.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** A reply to one request, at a status the client classifies the way it would. */
|
|
39
|
+
export const json = (body: unknown, status = 200): Response =>
|
|
40
|
+
new Response(JSON.stringify(body), {
|
|
41
|
+
status,
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A request that never comes back, which is what a loading state is. */
|
|
46
|
+
export const never = (): Promise<Response> => new Promise<Response>(() => {});
|
|
47
|
+
|
|
48
|
+
export type CalendarServer = (request: Request) => Response | Promise<Response>;
|
|
49
|
+
|
|
50
|
+
/** Nowhere. Every request is answered before it can leave. */
|
|
51
|
+
const STORY_BASE_URL = "https://calendar.story.invalid";
|
|
52
|
+
|
|
53
|
+
function storyRouter(
|
|
54
|
+
entry: string,
|
|
55
|
+
pane: ReactNode,
|
|
56
|
+
pick: CalendarSlotPick | undefined,
|
|
57
|
+
): AnyRouter {
|
|
58
|
+
const leaf = () => <>{pane}</>;
|
|
59
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
60
|
+
const viewRoute = createRoute({
|
|
61
|
+
getParentRoute: () => rootRoute,
|
|
62
|
+
path: "/calendar/$view/$date",
|
|
63
|
+
validateSearch: calendarSearchSchema,
|
|
64
|
+
component: () => (
|
|
65
|
+
<CalendarComposeSeedProvider pick={pick}>
|
|
66
|
+
<Outlet />
|
|
67
|
+
</CalendarComposeSeedProvider>
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
const eventRoute = createRoute({
|
|
71
|
+
getParentRoute: () => viewRoute,
|
|
72
|
+
path: "$calendarObjectId",
|
|
73
|
+
component: Outlet,
|
|
74
|
+
});
|
|
75
|
+
const routeTree = rootRoute.addChildren([
|
|
76
|
+
viewRoute.addChildren([
|
|
77
|
+
createRoute({
|
|
78
|
+
getParentRoute: () => viewRoute,
|
|
79
|
+
path: "/",
|
|
80
|
+
component: leaf,
|
|
81
|
+
}),
|
|
82
|
+
createRoute({
|
|
83
|
+
getParentRoute: () => viewRoute,
|
|
84
|
+
path: "new",
|
|
85
|
+
component: leaf,
|
|
86
|
+
}),
|
|
87
|
+
eventRoute.addChildren([
|
|
88
|
+
createRoute({
|
|
89
|
+
getParentRoute: () => eventRoute,
|
|
90
|
+
path: "/",
|
|
91
|
+
component: leaf,
|
|
92
|
+
}),
|
|
93
|
+
createRoute({
|
|
94
|
+
getParentRoute: () => eventRoute,
|
|
95
|
+
path: "$recurrenceId",
|
|
96
|
+
component: leaf,
|
|
97
|
+
}),
|
|
98
|
+
]),
|
|
99
|
+
]),
|
|
100
|
+
]);
|
|
101
|
+
return createRouter({
|
|
102
|
+
routeTree,
|
|
103
|
+
stringifySearch,
|
|
104
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
105
|
+
}) as unknown as AnyRouter;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CalendarStoryProps {
|
|
109
|
+
/** The pane under test, mounted at whichever address the story opens on. */
|
|
110
|
+
pane: ReactNode;
|
|
111
|
+
/** The address the story opens on. */
|
|
112
|
+
entry: string;
|
|
113
|
+
/** What the server answers, request by request. */
|
|
114
|
+
server: CalendarServer;
|
|
115
|
+
/** The slot a reader dragged out of the grid, for the composer. */
|
|
116
|
+
pick?: CalendarSlotPick;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function CalendarStory({
|
|
120
|
+
pane,
|
|
121
|
+
entry,
|
|
122
|
+
server,
|
|
123
|
+
pick,
|
|
124
|
+
}: CalendarStoryProps) {
|
|
125
|
+
// Built once, and the client configured before anything can ask it a
|
|
126
|
+
// question: a pane that fetched against the default host would reach for the
|
|
127
|
+
// network on its first render.
|
|
128
|
+
const [mounted] = useState(() => {
|
|
129
|
+
client.setConfig({
|
|
130
|
+
baseUrl: STORY_BASE_URL,
|
|
131
|
+
fetch: ((input: RequestInfo | URL) =>
|
|
132
|
+
Promise.resolve(
|
|
133
|
+
server(input instanceof Request ? input : new Request(String(input))),
|
|
134
|
+
)) as typeof globalThis.fetch,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
router: storyRouter(entry, pane, pick),
|
|
138
|
+
queryClient: new QueryClient({
|
|
139
|
+
defaultOptions: {
|
|
140
|
+
queries: { retry: false },
|
|
141
|
+
mutations: { retry: false },
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<QueryClientProvider client={mounted.queryClient}>
|
|
149
|
+
<RouterProvider router={mounted.router} />
|
|
150
|
+
</QueryClientProvider>
|
|
151
|
+
);
|
|
152
|
+
}
|
package/src/index.css
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
the .dark ancestor scope used everywhere in this app. */
|
|
5
5
|
@import "@remit/ui/tokens.css";
|
|
6
6
|
|
|
7
|
+
/* The structural sheet the calendar grid's engine needs, which the kit ships
|
|
8
|
+
beside the component and every app that mounts it has to ask for. Without it
|
|
9
|
+
the week and day grids collapse into a stack and draw no events at all: the
|
|
10
|
+
engine lays its columns out against this sheet, and a column with no
|
|
11
|
+
geometry has nowhere to put an occurrence. */
|
|
12
|
+
@import "@remit/ui/calendar.css";
|
|
13
|
+
|
|
7
14
|
/* Tailwind v4 content scanning: name every source Tailwind must inspect for
|
|
8
15
|
utility class usage. Automatic detection is rooted at the Vite root, which
|
|
9
16
|
for the distributor harness build is a throwaway directory holding only the
|