@remit/web-client 0.0.295 → 0.0.297
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 +2 -1
- package/src/components/mail/IntelligencePane.tsx +0 -6
- package/src/components/mail/daily-brief.stories.tsx +250 -0
- package/src/components/mail/drafts.stories.tsx +61 -0
- package/src/components/mail/flagged.stories.tsx +159 -0
- package/src/components/mail/outbox.stories.tsx +59 -0
- package/src/hooks/useIntelligenceData.ts +0 -1
- package/src/hooks/useUpdateAddressFlags.ts +1 -1
- package/src/mocks/story-frame/AppStory.tsx +47 -0
- package/src/mocks/story-frame/mail-handlers.ts +299 -0
- package/src/mocks/story-frame/mail-world.ts +389 -0
- package/src/mocks/story-frame/touch.ts +21 -0
- package/src/router.tsx +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.297",
|
|
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": {
|
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
"dictionary-nl": "2.0.0",
|
|
110
110
|
"esbuild": "*",
|
|
111
111
|
"jsdom": "^29.1.1",
|
|
112
|
+
"msw": "^2",
|
|
112
113
|
"react": "^19",
|
|
113
114
|
"react-dom": "^19.2.4",
|
|
114
115
|
"tailwindcss": "^4",
|
|
@@ -323,11 +323,6 @@ function WiredPanel({
|
|
|
323
323
|
updateFlags({ muted: { value: next } });
|
|
324
324
|
}, [data?.flags?.muted, updateFlags]);
|
|
325
325
|
|
|
326
|
-
const handleToggleUnsubscribe = useCallback(() => {
|
|
327
|
-
const next = !(data?.flags?.unsubscribed === true);
|
|
328
|
-
updateFlags({ unsubscribed: { value: next } });
|
|
329
|
-
}, [data?.flags?.unsubscribed, updateFlags]);
|
|
330
|
-
|
|
331
326
|
const handleReclassifySelect = useCallback(
|
|
332
327
|
(category: CategoryOverride) => {
|
|
333
328
|
setReclassifyOpen(false);
|
|
@@ -349,7 +344,6 @@ function WiredPanel({
|
|
|
349
344
|
const actions: IntelligenceQuickActions = {
|
|
350
345
|
onToggleVip: canUpdateFlags ? handleToggleVip : undefined,
|
|
351
346
|
onToggleMute: canUpdateFlags ? handleToggleMute : undefined,
|
|
352
|
-
onToggleUnsubscribe: canUpdateFlags ? handleToggleUnsubscribe : undefined,
|
|
353
347
|
onReclassify: canUpdateFlags ? () => setReclassifyOpen(true) : undefined,
|
|
354
348
|
onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
|
|
355
349
|
onReportSpam: spamAction === "reportSpam" ? handleReportSpam : undefined,
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import { mailWorld, newsletterBacklog } from "@/mocks/story-frame/mail-world";
|
|
6
|
+
import { longPress } from "@/mocks/story-frame/touch";
|
|
7
|
+
|
|
8
|
+
const world = mailWorld();
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Playground/Shipped/Mail/Daily brief",
|
|
12
|
+
component: AppStory,
|
|
13
|
+
args: { url: "/mail/brief" },
|
|
14
|
+
parameters: {
|
|
15
|
+
layout: "fullscreen",
|
|
16
|
+
msw: { handlers: mailHandlers(world) },
|
|
17
|
+
},
|
|
18
|
+
} satisfies Meta<typeof AppStory>;
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
const phone = { viewport: { value: "mobile", isRotated: false } };
|
|
24
|
+
|
|
25
|
+
const rows = (canvasElement: HTMLElement): HTMLElement[] =>
|
|
26
|
+
Array.from(canvasElement.querySelectorAll<HTMLElement>("[data-list-row]"));
|
|
27
|
+
|
|
28
|
+
const ticked = (canvasElement: HTMLElement): number =>
|
|
29
|
+
rows(canvasElement).filter((row) =>
|
|
30
|
+
row.querySelector('[aria-label="Deselect message"]'),
|
|
31
|
+
).length;
|
|
32
|
+
|
|
33
|
+
const openFilters = async (
|
|
34
|
+
canvasElement: HTMLElement,
|
|
35
|
+
label: "Expand filters" | "Search filters" = "Expand filters",
|
|
36
|
+
) => {
|
|
37
|
+
const canvas = within(canvasElement);
|
|
38
|
+
await userEvent.click(await canvas.findByLabelText(label));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const pick = async (
|
|
42
|
+
canvasElement: HTMLElement,
|
|
43
|
+
group: "Accounts" | "Categories" | "Attributes",
|
|
44
|
+
name: string,
|
|
45
|
+
) => {
|
|
46
|
+
const fieldset = await within(canvasElement).findByLabelText(group);
|
|
47
|
+
await userEvent.click(within(fieldset).getByRole("button", { name }));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
play: async ({ canvasElement }) => {
|
|
52
|
+
const canvas = within(canvasElement);
|
|
53
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
54
|
+
await expect(canvas.getByText("Autumn sale starts now")).toBeVisible();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Phone: Story = {
|
|
59
|
+
globals: phone,
|
|
60
|
+
play: async ({ canvasElement }) => {
|
|
61
|
+
const canvas = within(canvasElement);
|
|
62
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const CaughtUp: Story = {
|
|
67
|
+
parameters: {
|
|
68
|
+
msw: { handlers: mailHandlers(mailWorld({ threads: [] })) },
|
|
69
|
+
},
|
|
70
|
+
play: async ({ canvasElement }) => {
|
|
71
|
+
const canvas = within(canvasElement);
|
|
72
|
+
await expect(await canvas.findByText("You're caught up")).toBeVisible();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const FilteredToCategory: Story = {
|
|
77
|
+
play: async ({ canvasElement }) => {
|
|
78
|
+
const canvas = within(canvasElement);
|
|
79
|
+
await canvas.findByText("Q3 planning");
|
|
80
|
+
await openFilters(canvasElement);
|
|
81
|
+
await pick(canvasElement, "Categories", "Newsletter");
|
|
82
|
+
await waitFor(() =>
|
|
83
|
+
expect(canvas.queryByText("Q3 planning")).not.toBeInTheDocument(),
|
|
84
|
+
);
|
|
85
|
+
await expect(
|
|
86
|
+
await canvas.findByText("This week in type systems"),
|
|
87
|
+
).toBeVisible();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const WorkOnly: Story = {
|
|
92
|
+
play: async ({ canvasElement }) => {
|
|
93
|
+
const canvas = within(canvasElement);
|
|
94
|
+
await canvas.findByText("Dinner on Saturday?");
|
|
95
|
+
await openFilters(canvasElement);
|
|
96
|
+
await pick(canvasElement, "Accounts", "alice.tan");
|
|
97
|
+
await waitFor(() =>
|
|
98
|
+
expect(canvas.queryByText("Dinner on Saturday?")).not.toBeInTheDocument(),
|
|
99
|
+
);
|
|
100
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const SelectAllThenNarrow: Story = {
|
|
105
|
+
parameters: {
|
|
106
|
+
msw: {
|
|
107
|
+
handlers: mailHandlers(
|
|
108
|
+
mailWorld({
|
|
109
|
+
threads: [...world.threads, ...newsletterBacklog(14)],
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
play: async ({ canvasElement }) => {
|
|
115
|
+
const canvas = within(canvasElement);
|
|
116
|
+
await canvas.findByText("Q3 planning");
|
|
117
|
+
await expect(
|
|
118
|
+
await canvas.findByRole("button", { name: /^Show (all|\d+ more)/ }),
|
|
119
|
+
).toBeVisible();
|
|
120
|
+
await openFilters(canvasElement);
|
|
121
|
+
|
|
122
|
+
const firstRow = rows(canvasElement)[0];
|
|
123
|
+
firstRow?.focus();
|
|
124
|
+
await expect(document.activeElement).toBe(firstRow);
|
|
125
|
+
await userEvent.keyboard("{Meta>}a{/Meta}");
|
|
126
|
+
await waitFor(() =>
|
|
127
|
+
expect(
|
|
128
|
+
canvas.getByText(/(loaded selected|messages? selected)$/),
|
|
129
|
+
).toHaveTextContent(`All ${rows(canvasElement).length} loaded selected`),
|
|
130
|
+
);
|
|
131
|
+
const selectedEverywhere = ticked(canvasElement);
|
|
132
|
+
|
|
133
|
+
await expect(selectedEverywhere).toBe(rows(canvasElement).length);
|
|
134
|
+
|
|
135
|
+
await pick(canvasElement, "Categories", "Newsletter");
|
|
136
|
+
await expect(
|
|
137
|
+
await canvas.findByText("Issue 1: notes from the field"),
|
|
138
|
+
).toBeVisible();
|
|
139
|
+
await waitFor(() => expect(ticked(canvasElement)).toBe(0));
|
|
140
|
+
await expect(
|
|
141
|
+
canvas.queryByText(/(loaded selected|messages? selected)$/),
|
|
142
|
+
).toBeNull();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const ShiftArrowRange: Story = {
|
|
147
|
+
play: async ({ canvasElement }) => {
|
|
148
|
+
const canvas = within(canvasElement);
|
|
149
|
+
await canvas.findByText("Q3 planning");
|
|
150
|
+
rows(canvasElement)[0]?.focus();
|
|
151
|
+
await userEvent.keyboard("j");
|
|
152
|
+
await userEvent.keyboard("{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(canvas.getByText("2 messages selected")).toBeInTheDocument(),
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const OpenWithJ: Story = {
|
|
160
|
+
play: async ({ canvasElement }) => {
|
|
161
|
+
const canvas = within(canvasElement);
|
|
162
|
+
await canvas.findByText("Q3 planning");
|
|
163
|
+
rows(canvasElement)[0]?.focus();
|
|
164
|
+
await userEvent.keyboard("j");
|
|
165
|
+
await waitFor(() =>
|
|
166
|
+
expect(
|
|
167
|
+
canvas.getAllByText("Here is the countersigned copy for your records."),
|
|
168
|
+
).toHaveLength(2),
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const MultiSelect: Story = {
|
|
174
|
+
play: async ({ canvasElement }) => {
|
|
175
|
+
const canvas = within(canvasElement);
|
|
176
|
+
await canvas.findByText("Q3 planning");
|
|
177
|
+
const [first, , third] = rows(canvasElement);
|
|
178
|
+
for (const row of [first, third]) {
|
|
179
|
+
const toggle = row?.querySelector<HTMLElement>(
|
|
180
|
+
'[aria-label="Select message"]',
|
|
181
|
+
);
|
|
182
|
+
if (toggle) await userEvent.click(toggle);
|
|
183
|
+
}
|
|
184
|
+
await expect(await canvas.findByText("2 messages selected")).toBeVisible();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const MultiSelectPhone: Story = {
|
|
189
|
+
globals: phone,
|
|
190
|
+
play: async ({ canvasElement }) => {
|
|
191
|
+
const canvas = within(canvasElement);
|
|
192
|
+
await canvas.findByText("Q3 planning");
|
|
193
|
+
const first = rows(canvasElement)[0];
|
|
194
|
+
if (first) await longPress(first);
|
|
195
|
+
await expect(await canvas.findByText("1 message selected")).toBeVisible();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const Search: Story = {
|
|
200
|
+
args: { url: "/mail/brief?q=lease" },
|
|
201
|
+
play: async ({ canvasElement }) => {
|
|
202
|
+
const canvas = within(canvasElement);
|
|
203
|
+
await expect(
|
|
204
|
+
await canvas.findByText("Signed lease attached"),
|
|
205
|
+
).toBeVisible();
|
|
206
|
+
await expect(canvas.queryByText("Dinner on Saturday?")).toBeNull();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const SearchWithChips: Story = {
|
|
211
|
+
args: { url: "/mail/brief?q=your" },
|
|
212
|
+
play: async ({ canvasElement }) => {
|
|
213
|
+
const canvas = within(canvasElement);
|
|
214
|
+
await canvas.findByText("Signed lease attached");
|
|
215
|
+
await openFilters(canvasElement);
|
|
216
|
+
await pick(canvasElement, "Attributes", "Unread");
|
|
217
|
+
await waitFor(() =>
|
|
218
|
+
expect(canvas.getByLabelText("Search mail")).toHaveValue(
|
|
219
|
+
"your is:unread",
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const SearchOfChipsOnly: Story = {
|
|
226
|
+
args: { url: "/mail/brief?q=is:unread" },
|
|
227
|
+
play: async ({ canvasElement }) => {
|
|
228
|
+
const canvas = within(canvasElement);
|
|
229
|
+
const offer = await canvas.findByRole("button", {
|
|
230
|
+
name: "Make this a filter",
|
|
231
|
+
});
|
|
232
|
+
await userEvent.click(offer);
|
|
233
|
+
await expect(
|
|
234
|
+
await canvas.findByText(/isn't a filter condition/, { selector: "p" }),
|
|
235
|
+
).toBeVisible();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export const SearchHitOutsideTheBrief: Story = {
|
|
240
|
+
args: { url: "/mail/brief?q=outline" },
|
|
241
|
+
play: async ({ canvasElement }) => {
|
|
242
|
+
const canvas = within(canvasElement);
|
|
243
|
+
await userEvent.click(await canvas.findByText("Conference talk outline"));
|
|
244
|
+
await waitFor(() =>
|
|
245
|
+
expect(
|
|
246
|
+
canvas.getAllByText("Here is the rough structure for the keynote."),
|
|
247
|
+
).toHaveLength(2),
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import {
|
|
6
|
+
mailboxIdFor,
|
|
7
|
+
mailWorld,
|
|
8
|
+
PERSONAL,
|
|
9
|
+
} from "@/mocks/story-frame/mail-world";
|
|
10
|
+
|
|
11
|
+
const world = mailWorld();
|
|
12
|
+
const draftsMailboxId = mailboxIdFor(PERSONAL, "Drafts");
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "Playground/Shipped/Mail/Drafts",
|
|
16
|
+
component: AppStory,
|
|
17
|
+
args: { url: `/mail/${draftsMailboxId}` },
|
|
18
|
+
parameters: {
|
|
19
|
+
layout: "fullscreen",
|
|
20
|
+
msw: { handlers: mailHandlers(world) },
|
|
21
|
+
},
|
|
22
|
+
} satisfies Meta<typeof AppStory>;
|
|
23
|
+
export default meta;
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof meta>;
|
|
26
|
+
|
|
27
|
+
export const Segmented: Story = {
|
|
28
|
+
play: async ({ canvasElement }) => {
|
|
29
|
+
const canvas = within(canvasElement);
|
|
30
|
+
await expect(await canvas.findByText("Not yet sent (Remit)")).toBeVisible();
|
|
31
|
+
await expect(canvas.getByText("On the server")).toBeVisible();
|
|
32
|
+
await expect(canvas.getByText("Re: Q3 planning")).toBeVisible();
|
|
33
|
+
await expect(canvas.getByText("Re: Conference talk outline")).toBeVisible();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Phone: Story = {
|
|
38
|
+
globals: { viewport: { value: "mobile", isRotated: false } },
|
|
39
|
+
play: async ({ canvasElement }) => {
|
|
40
|
+
const canvas = within(canvasElement);
|
|
41
|
+
await expect(await canvas.findByText("Not yet sent (Remit)")).toBeVisible();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Empty: Story = {
|
|
46
|
+
parameters: {
|
|
47
|
+
msw: {
|
|
48
|
+
handlers: mailHandlers({
|
|
49
|
+
...world,
|
|
50
|
+
threads: world.threads.filter(
|
|
51
|
+
(row) => row.mailboxId !== draftsMailboxId,
|
|
52
|
+
),
|
|
53
|
+
outbox: world.outbox.filter((message) => message.status !== "draft"),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
play: async ({ canvasElement }) => {
|
|
58
|
+
const canvas = within(canvasElement);
|
|
59
|
+
await expect(await canvas.findByText("No drafts")).toBeVisible();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
6
|
+
|
|
7
|
+
const world = mailWorld();
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: "Playground/Shipped/Mail/Flagged",
|
|
11
|
+
component: AppStory,
|
|
12
|
+
args: { url: "/mail/flagged" },
|
|
13
|
+
parameters: {
|
|
14
|
+
layout: "fullscreen",
|
|
15
|
+
msw: { handlers: mailHandlers(world) },
|
|
16
|
+
},
|
|
17
|
+
} satisfies Meta<typeof AppStory>;
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
const filterToCategory = async (canvasElement: HTMLElement, name: string) => {
|
|
23
|
+
const canvas = within(canvasElement);
|
|
24
|
+
await userEvent.click(await canvas.findByLabelText("Expand filters"));
|
|
25
|
+
const categories = await canvas.findByLabelText("Categories");
|
|
26
|
+
await userEvent.click(within(categories).getByRole("button", { name }));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const listHeader = (canvasElement: HTMLElement): HTMLElement => {
|
|
30
|
+
const title = within(canvasElement)
|
|
31
|
+
.getAllByText("Starred")
|
|
32
|
+
.find((element) => element.closest("header"));
|
|
33
|
+
const header = title?.closest("header");
|
|
34
|
+
if (!header) throw new Error("The Starred list header is not on screen");
|
|
35
|
+
return header;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const Default: Story = {
|
|
39
|
+
play: async ({ canvasElement }) => {
|
|
40
|
+
const canvas = within(canvasElement);
|
|
41
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
42
|
+
await expect(canvas.getByText("Conference talk outline")).toBeVisible();
|
|
43
|
+
await expect(canvas.getByText("Invoice #1042")).toBeVisible();
|
|
44
|
+
await expect(canvas.queryByText("Dinner on Saturday?")).toBeNull();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Phone: Story = {
|
|
49
|
+
globals: { viewport: { value: "mobile", isRotated: false } },
|
|
50
|
+
play: async ({ canvasElement }) => {
|
|
51
|
+
const canvas = within(canvasElement);
|
|
52
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const CountUnavailable: Story = {
|
|
57
|
+
parameters: {
|
|
58
|
+
msw: { handlers: mailHandlers(world, { withholdCounts: true }) },
|
|
59
|
+
},
|
|
60
|
+
play: async ({ canvasElement }) => {
|
|
61
|
+
const canvas = within(canvasElement);
|
|
62
|
+
await canvas.findByText("Q3 planning");
|
|
63
|
+
await expect(listHeader(canvasElement).textContent).not.toMatch(/\d/);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const FilteredWithResults: Story = {
|
|
68
|
+
play: async ({ canvasElement }) => {
|
|
69
|
+
const canvas = within(canvasElement);
|
|
70
|
+
await canvas.findByText("Invoice #1042");
|
|
71
|
+
await filterToCategory(canvasElement, "Personal");
|
|
72
|
+
await waitFor(() =>
|
|
73
|
+
expect(canvas.queryByText("Invoice #1042")).not.toBeInTheDocument(),
|
|
74
|
+
);
|
|
75
|
+
await expect(await canvas.findByText("Q3 planning")).toBeVisible();
|
|
76
|
+
await expect(canvas.getByText("Conference talk outline")).toBeVisible();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const FilteredEmpty: Story = {
|
|
81
|
+
play: async ({ canvasElement }) => {
|
|
82
|
+
const canvas = within(canvasElement);
|
|
83
|
+
await canvas.findByText("Q3 planning");
|
|
84
|
+
await filterToCategory(canvasElement, "Newsletter");
|
|
85
|
+
await expect(
|
|
86
|
+
await canvas.findByText("Every message in this folder was checked."),
|
|
87
|
+
).toBeVisible();
|
|
88
|
+
await expect(canvas.queryByText("No messages in Starred")).toBeNull();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const FilteredSearchEmpty: Story = {
|
|
93
|
+
play: async ({ canvasElement }) => {
|
|
94
|
+
const canvas = within(canvasElement);
|
|
95
|
+
await canvas.findByText("Q3 planning");
|
|
96
|
+
await filterToCategory(canvasElement, "Newsletter");
|
|
97
|
+
await canvas.findByText("Every message in this folder was checked.");
|
|
98
|
+
await userEvent.type(
|
|
99
|
+
canvas.getByLabelText("Search mail"),
|
|
100
|
+
"planning{Enter}",
|
|
101
|
+
);
|
|
102
|
+
await expect(
|
|
103
|
+
await canvas.findByText("No results for “planning” in Newsletter mail"),
|
|
104
|
+
).toBeVisible();
|
|
105
|
+
await expect(
|
|
106
|
+
canvas.getByText("Every message in this folder was checked."),
|
|
107
|
+
).toBeVisible();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const FilteredWithMorePages: Story = {
|
|
112
|
+
parameters: {
|
|
113
|
+
msw: { handlers: mailHandlers(world, { pageSize: 2 }) },
|
|
114
|
+
},
|
|
115
|
+
play: async ({ canvasElement }) => {
|
|
116
|
+
const canvas = within(canvasElement);
|
|
117
|
+
await canvas.findByText("Q3 planning");
|
|
118
|
+
await filterToCategory(canvasElement, "Personal");
|
|
119
|
+
await expect(
|
|
120
|
+
await canvas.findByRole("button", { name: "Load more" }),
|
|
121
|
+
).toBeEnabled();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const FilteredFetchingMore: Story = {
|
|
126
|
+
parameters: {
|
|
127
|
+
msw: {
|
|
128
|
+
handlers: mailHandlers(world, { pageSize: 2, holdLaterPages: true }),
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
play: async ({ canvasElement }) => {
|
|
132
|
+
const canvas = within(canvasElement);
|
|
133
|
+
await canvas.findByText("Q3 planning");
|
|
134
|
+
await filterToCategory(canvasElement, "Personal");
|
|
135
|
+
await userEvent.click(
|
|
136
|
+
await canvas.findByRole("button", { name: "Load more" }),
|
|
137
|
+
);
|
|
138
|
+
await expect(
|
|
139
|
+
await canvas.findByRole("button", { name: "Loading…" }),
|
|
140
|
+
).toBeDisabled();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const Empty: Story = {
|
|
145
|
+
parameters: {
|
|
146
|
+
msw: {
|
|
147
|
+
handlers: mailHandlers({
|
|
148
|
+
...world,
|
|
149
|
+
threads: world.threads.filter((row) => !row.hasStars),
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
play: async ({ canvasElement }) => {
|
|
154
|
+
const canvas = within(canvasElement);
|
|
155
|
+
await expect(
|
|
156
|
+
await canvas.findByText("No messages in Starred"),
|
|
157
|
+
).toBeVisible();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: "Playground/Shipped/Mail/Outbox",
|
|
9
|
+
component: AppStory,
|
|
10
|
+
args: { url: "/mail/outbox" },
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: "fullscreen",
|
|
13
|
+
msw: { handlers: mailHandlers(mailWorld()) },
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<typeof AppStory>;
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof meta>;
|
|
19
|
+
|
|
20
|
+
export const AllStatuses: Story = {
|
|
21
|
+
play: async ({ canvasElement }) => {
|
|
22
|
+
const canvas = within(canvasElement);
|
|
23
|
+
await expect(await canvas.findByText("Design review notes")).toBeVisible();
|
|
24
|
+
for (const subject of [
|
|
25
|
+
"Re: Q3 planning",
|
|
26
|
+
"Weekly update",
|
|
27
|
+
"Signed lease",
|
|
28
|
+
"Re: contract",
|
|
29
|
+
]) {
|
|
30
|
+
await expect(canvas.getByText(subject)).toBeVisible();
|
|
31
|
+
}
|
|
32
|
+
for (const error of [
|
|
33
|
+
"Sent, but not filed: this account has no Sent folder",
|
|
34
|
+
"SMTP connection timed out",
|
|
35
|
+
"No SMTP server configured for this account",
|
|
36
|
+
]) {
|
|
37
|
+
await expect(canvas.getByText(error, { exact: false })).toBeVisible();
|
|
38
|
+
}
|
|
39
|
+
await expect(canvas.queryByText("Invoice #1042")).toBeNull();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const Phone: Story = {
|
|
44
|
+
globals: { viewport: { value: "mobile", isRotated: false } },
|
|
45
|
+
play: async ({ canvasElement }) => {
|
|
46
|
+
const canvas = within(canvasElement);
|
|
47
|
+
await expect(await canvas.findByText("Design review notes")).toBeVisible();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Empty: Story = {
|
|
52
|
+
parameters: {
|
|
53
|
+
msw: { handlers: mailHandlers(mailWorld({ outbox: [] })) },
|
|
54
|
+
},
|
|
55
|
+
play: async ({ canvasElement }) => {
|
|
56
|
+
const canvas = within(canvasElement);
|
|
57
|
+
await expect(await canvas.findByText("No outbox messages")).toBeVisible();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -55,7 +55,7 @@ function applyFlagPatch(
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Optimistic PATCH for per-sender flags (VIP / Mute / Block
|
|
58
|
+
* Optimistic PATCH for per-sender flags (VIP / Mute / Block).
|
|
59
59
|
* Mirrors the `useToggleTrusted` pattern: patch the cached
|
|
60
60
|
* address-search response so the toggle flips instantly, roll back on error,
|
|
61
61
|
* and invalidate on settle to reconcile with the server.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MutationCache,
|
|
3
|
+
QueryCache,
|
|
4
|
+
QueryClient,
|
|
5
|
+
QueryClientProvider,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import { createMemoryHistory, RouterProvider } from "@tanstack/react-router";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import {
|
|
10
|
+
handleMutationCacheError,
|
|
11
|
+
handleQueryCacheError,
|
|
12
|
+
} from "@/lib/query-error-handler";
|
|
13
|
+
import { noopTelemetry } from "@/lib/telemetry";
|
|
14
|
+
import { createAppRouter } from "@/router";
|
|
15
|
+
import "@/lib/i18n";
|
|
16
|
+
import "@/lib/client";
|
|
17
|
+
|
|
18
|
+
export interface AppStoryProps {
|
|
19
|
+
url: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const createStoryQueryClient = (): QueryClient =>
|
|
23
|
+
new QueryClient({
|
|
24
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
25
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
26
|
+
defaultOptions: {
|
|
27
|
+
queries: { staleTime: Number.POSITIVE_INFINITY, retry: false },
|
|
28
|
+
mutations: { retry: false },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function AppStory({ url }: AppStoryProps) {
|
|
33
|
+
const [queryClient] = useState(createStoryQueryClient);
|
|
34
|
+
const [router] = useState(() =>
|
|
35
|
+
createAppRouter(
|
|
36
|
+
queryClient,
|
|
37
|
+
noopTelemetry,
|
|
38
|
+
createMemoryHistory({ initialEntries: [url] }),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<QueryClientProvider client={queryClient}>
|
|
44
|
+
<RouterProvider router={router} />
|
|
45
|
+
</QueryClientProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FilterOperationsListFiltersResponse,
|
|
3
|
+
LabelOperationsListLabelsResponse,
|
|
4
|
+
MailboxOperationsListMailboxesResponse,
|
|
5
|
+
MeOperationsListQuarantineResponse,
|
|
6
|
+
MeOperationsListVipSuggestionsResponse,
|
|
7
|
+
MessageBulkOperationsUpdateFlagsResponse,
|
|
8
|
+
MessageCalendarSuggestionOperationsListMessageCalendarSuggestionsResponse,
|
|
9
|
+
OutboxOperationsListOutboxMessagesResponse,
|
|
10
|
+
RemitImapConfigDescriptionResponse,
|
|
11
|
+
RemitImapDescribeMessageResponse,
|
|
12
|
+
RemitImapThreadMessageResponse,
|
|
13
|
+
RemitImapThreadSearchResponse,
|
|
14
|
+
SemanticSearchOperationsSemanticSearchResponse,
|
|
15
|
+
SyncOperationsGetSyncStatusResponse,
|
|
16
|
+
SyncOperationsTriggerSyncResponse,
|
|
17
|
+
SystemOperationsGetSystemUpdateResponse,
|
|
18
|
+
ThreadDetailOperationsListThreadMessagesResponse,
|
|
19
|
+
ThreadOperationsListThreadsResponse,
|
|
20
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
21
|
+
import { type HttpHandler, HttpResponse, http } from "msw";
|
|
22
|
+
import { makeConfig } from "@/test-support/fixtures";
|
|
23
|
+
import type { MailWorld } from "./mail-world";
|
|
24
|
+
|
|
25
|
+
const API = "/api";
|
|
26
|
+
|
|
27
|
+
export interface MailHandlerOptions {
|
|
28
|
+
withholdCounts?: boolean;
|
|
29
|
+
pageSize?: number;
|
|
30
|
+
holdLaterPages?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const contentPath = (row: RemitImapThreadMessageResponse): string =>
|
|
34
|
+
`/content/accounts/${row.accountConfigId}/${row.accountId}/messages/${row.messageId}/parts/1`;
|
|
35
|
+
|
|
36
|
+
const describe = (
|
|
37
|
+
row: RemitImapThreadMessageResponse,
|
|
38
|
+
): RemitImapDescribeMessageResponse => ({
|
|
39
|
+
message: {
|
|
40
|
+
messageId: row.messageId,
|
|
41
|
+
mailboxId: row.mailboxId,
|
|
42
|
+
uid: 1,
|
|
43
|
+
rfc822Size: 2048,
|
|
44
|
+
internalDate: row.sentDate,
|
|
45
|
+
status: row.status,
|
|
46
|
+
syncStatus: row.syncStatus,
|
|
47
|
+
abandonedMutation: row.abandonedMutation,
|
|
48
|
+
},
|
|
49
|
+
envelope: {
|
|
50
|
+
messageId: row.messageId,
|
|
51
|
+
date: row.sentDate,
|
|
52
|
+
subject: row.subject,
|
|
53
|
+
from: [
|
|
54
|
+
{
|
|
55
|
+
addressId: `addr-${row.messageId}`,
|
|
56
|
+
displayName: row.fromName,
|
|
57
|
+
normalizedEmail: row.fromEmail ?? "",
|
|
58
|
+
addressRole: "from",
|
|
59
|
+
addressOrder: 0,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
to: [],
|
|
63
|
+
cc: [],
|
|
64
|
+
bcc: [],
|
|
65
|
+
replyTo: [],
|
|
66
|
+
category: row.category,
|
|
67
|
+
senderTrust: row.senderTrust,
|
|
68
|
+
},
|
|
69
|
+
flags: row.isRead ? ["\\Seen"] : [],
|
|
70
|
+
bodyParts: [
|
|
71
|
+
{
|
|
72
|
+
bodyPartId: `part-${row.messageId}`,
|
|
73
|
+
mediaType: "TEXT",
|
|
74
|
+
mediaSubtype: "plain",
|
|
75
|
+
sizeOctets: (row.snippet ?? "").length,
|
|
76
|
+
isMultipart: false,
|
|
77
|
+
contentUrl: contentPath(row),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
references: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const matchesText = (
|
|
84
|
+
row: RemitImapThreadMessageResponse,
|
|
85
|
+
needle: string,
|
|
86
|
+
fields: (keyof RemitImapThreadMessageResponse)[],
|
|
87
|
+
): boolean => {
|
|
88
|
+
const lowered = needle.toLowerCase();
|
|
89
|
+
return fields.some((field) =>
|
|
90
|
+
String(row[field] ?? "")
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.includes(lowered),
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const narrow = (
|
|
97
|
+
rows: RemitImapThreadMessageResponse[],
|
|
98
|
+
params: URLSearchParams,
|
|
99
|
+
): RemitImapThreadMessageResponse[] => {
|
|
100
|
+
const categories = params.getAll("category");
|
|
101
|
+
const query = params.get("query");
|
|
102
|
+
const from = params.get("from");
|
|
103
|
+
const subject = params.get("subject");
|
|
104
|
+
const accountId = params.get("accountId");
|
|
105
|
+
const mailboxId = params.get("mailboxId");
|
|
106
|
+
return rows.filter((row) => {
|
|
107
|
+
if (params.get("starred") === "true" && !row.hasStars) return false;
|
|
108
|
+
if (params.get("unread") === "true" && row.isRead) return false;
|
|
109
|
+
if (params.get("attachments") === "true" && !row.hasAttachment)
|
|
110
|
+
return false;
|
|
111
|
+
if (categories.length > 0 && !categories.includes(row.category))
|
|
112
|
+
return false;
|
|
113
|
+
if (accountId && row.accountId !== accountId) return false;
|
|
114
|
+
if (mailboxId && row.mailboxId !== mailboxId) return false;
|
|
115
|
+
if (query && !matchesText(row, query, ["subject", "fromName", "snippet"]))
|
|
116
|
+
return false;
|
|
117
|
+
if (from && !matchesText(row, from, ["fromName", "fromEmail"]))
|
|
118
|
+
return false;
|
|
119
|
+
if (subject && !matchesText(row, subject, ["subject"])) return false;
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const newestFirst = (
|
|
125
|
+
rows: RemitImapThreadMessageResponse[],
|
|
126
|
+
): RemitImapThreadMessageResponse[] =>
|
|
127
|
+
[...rows].sort((a, b) => b.sentDate - a.sentDate);
|
|
128
|
+
|
|
129
|
+
const listing = (
|
|
130
|
+
rows: RemitImapThreadMessageResponse[],
|
|
131
|
+
params: URLSearchParams,
|
|
132
|
+
options: MailHandlerOptions,
|
|
133
|
+
): RemitImapThreadSearchResponse => {
|
|
134
|
+
const offset = Number(params.get("continuationToken") ?? 0);
|
|
135
|
+
const size = options.pageSize ?? Number(params.get("limit") ?? rows.length);
|
|
136
|
+
const next = offset + size;
|
|
137
|
+
const counted = params.get("count") === "true" && !options.withholdCounts;
|
|
138
|
+
return {
|
|
139
|
+
items:
|
|
140
|
+
params.get("results") === "false"
|
|
141
|
+
? []
|
|
142
|
+
: newestFirst(rows).slice(offset, next),
|
|
143
|
+
continuationToken:
|
|
144
|
+
options.pageSize !== undefined && next < rows.length
|
|
145
|
+
? String(next)
|
|
146
|
+
: undefined,
|
|
147
|
+
count: counted ? new Set(rows.map((row) => row.threadId)).size : undefined,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const pending = (): Promise<never> => new Promise<never>(() => undefined);
|
|
152
|
+
|
|
153
|
+
export const mailHandlers = (
|
|
154
|
+
world: MailWorld,
|
|
155
|
+
options: MailHandlerOptions = {},
|
|
156
|
+
): HttpHandler[] => {
|
|
157
|
+
const byMessageId = new Map(world.threads.map((row) => [row.messageId, row]));
|
|
158
|
+
const inboxIds = new Set(
|
|
159
|
+
world.accounts.flatMap((account) =>
|
|
160
|
+
account.folderAppointments
|
|
161
|
+
.filter((appointment) => appointment.role === "Inbox")
|
|
162
|
+
.map((appointment) => appointment.mailboxId),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return [
|
|
167
|
+
http.get(`${API}/config`, () =>
|
|
168
|
+
HttpResponse.json<RemitImapConfigDescriptionResponse>(
|
|
169
|
+
makeConfig(world.accounts),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
http.get(`${API}/accounts/:accountId/mailboxes`, ({ params }) =>
|
|
173
|
+
HttpResponse.json<MailboxOperationsListMailboxesResponse>({
|
|
174
|
+
items: world.mailboxes.filter(
|
|
175
|
+
(mailbox) => mailbox.accountId === params.accountId,
|
|
176
|
+
),
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
http.get(`${API}/accounts/:accountId/sync/status`, ({ params }) =>
|
|
180
|
+
HttpResponse.json<SyncOperationsGetSyncStatusResponse>({
|
|
181
|
+
accountId: String(params.accountId),
|
|
182
|
+
syncPhase: "complete",
|
|
183
|
+
mailboxes: [],
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
http.get(`${API}/accounts/:accountId/labels`, () =>
|
|
187
|
+
HttpResponse.json<LabelOperationsListLabelsResponse>({ items: [] }),
|
|
188
|
+
),
|
|
189
|
+
http.get(`${API}/accounts/:accountId/filters`, () =>
|
|
190
|
+
HttpResponse.json<FilterOperationsListFiltersResponse>({ items: [] }),
|
|
191
|
+
),
|
|
192
|
+
http.post(`${API}/accounts/:accountId/sync`, () =>
|
|
193
|
+
HttpResponse.json<SyncOperationsTriggerSyncResponse>({
|
|
194
|
+
triggered: false,
|
|
195
|
+
message: "Already up to date",
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
http.get(`${API}/messages/:messageId/calendar-suggestions`, () =>
|
|
199
|
+
HttpResponse.json<MessageCalendarSuggestionOperationsListMessageCalendarSuggestionsResponse>(
|
|
200
|
+
{ items: [] },
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
http.get(`${API}/messages/:messageId`, ({ params }) => {
|
|
204
|
+
const row = byMessageId.get(String(params.messageId));
|
|
205
|
+
if (!row)
|
|
206
|
+
return HttpResponse.json(
|
|
207
|
+
{ status: 404, message: "Message not found" },
|
|
208
|
+
{ status: 404 },
|
|
209
|
+
);
|
|
210
|
+
return HttpResponse.json<RemitImapDescribeMessageResponse>(describe(row));
|
|
211
|
+
}),
|
|
212
|
+
http.post(`${API}/messages/flags`, () =>
|
|
213
|
+
HttpResponse.json<MessageBulkOperationsUpdateFlagsResponse>({
|
|
214
|
+
successCount: 1,
|
|
215
|
+
failureCount: 0,
|
|
216
|
+
}),
|
|
217
|
+
),
|
|
218
|
+
http.get(
|
|
219
|
+
"/content/accounts/:accountConfigId/:accountId/messages/:messageId/parts/*",
|
|
220
|
+
({ params }) =>
|
|
221
|
+
HttpResponse.text(
|
|
222
|
+
byMessageId.get(String(params.messageId))?.snippet ?? "",
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
http.get(`${API}/threads/:threadId/messages`, ({ params }) =>
|
|
226
|
+
HttpResponse.json<ThreadDetailOperationsListThreadMessagesResponse>({
|
|
227
|
+
items: world.threads.filter((row) => row.threadId === params.threadId),
|
|
228
|
+
}),
|
|
229
|
+
),
|
|
230
|
+
http.get(`${API}/threads`, ({ request }) => {
|
|
231
|
+
const params = new URL(request.url).searchParams;
|
|
232
|
+
const searching = params.has("query");
|
|
233
|
+
const scope = world.threads.filter(
|
|
234
|
+
(row) =>
|
|
235
|
+
searching ||
|
|
236
|
+
params.get("starred") === "true" ||
|
|
237
|
+
inboxIds.has(row.mailboxId),
|
|
238
|
+
);
|
|
239
|
+
if (options.holdLaterPages && params.has("continuationToken"))
|
|
240
|
+
return pending();
|
|
241
|
+
return HttpResponse.json(listing(narrow(scope, params), params, options));
|
|
242
|
+
}),
|
|
243
|
+
http.get(
|
|
244
|
+
`${API}/mailboxes/:mailboxId/threads/search`,
|
|
245
|
+
({ request, params }) =>
|
|
246
|
+
HttpResponse.json(
|
|
247
|
+
listing(
|
|
248
|
+
narrow(
|
|
249
|
+
world.threads.filter((row) => row.mailboxId === params.mailboxId),
|
|
250
|
+
new URL(request.url).searchParams,
|
|
251
|
+
),
|
|
252
|
+
new URL(request.url).searchParams,
|
|
253
|
+
options,
|
|
254
|
+
),
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
http.get(`${API}/mailboxes/:mailboxId/threads`, ({ params }) =>
|
|
258
|
+
HttpResponse.json<ThreadOperationsListThreadsResponse>({
|
|
259
|
+
items: newestFirst(
|
|
260
|
+
world.threads.filter((row) => row.mailboxId === params.mailboxId),
|
|
261
|
+
),
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
http.get(`${API}/outbox`, () =>
|
|
265
|
+
HttpResponse.json<OutboxOperationsListOutboxMessagesResponse>({
|
|
266
|
+
items: world.outbox,
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
http.get(`${API}/search/semantic`, () =>
|
|
270
|
+
HttpResponse.json<SemanticSearchOperationsSemanticSearchResponse>({
|
|
271
|
+
items: [],
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
http.get(`${API}/me/quarantine`, () =>
|
|
275
|
+
HttpResponse.json<MeOperationsListQuarantineResponse>({ entries: [] }),
|
|
276
|
+
),
|
|
277
|
+
http.get(`${API}/me/vip-suggestions`, () =>
|
|
278
|
+
HttpResponse.json<MeOperationsListVipSuggestionsResponse>({
|
|
279
|
+
suggestions: [],
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
http.get(`${API}/system/update`, () =>
|
|
283
|
+
HttpResponse.json<SystemOperationsGetSystemUpdateResponse>({
|
|
284
|
+
currentVersion: "0.0.0-storybook",
|
|
285
|
+
check: { status: "disabled" },
|
|
286
|
+
run: null,
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
http.all(`${API}/*`, ({ request }) =>
|
|
290
|
+
HttpResponse.json(
|
|
291
|
+
{
|
|
292
|
+
status: 501,
|
|
293
|
+
message: `No story handler for ${request.method} ${new URL(request.url).pathname}`,
|
|
294
|
+
},
|
|
295
|
+
{ status: 501 },
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
];
|
|
299
|
+
};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAccountResponse,
|
|
3
|
+
RemitImapCanonicalMailboxRole,
|
|
4
|
+
RemitImapMailboxResponse,
|
|
5
|
+
RemitImapMessageCategory,
|
|
6
|
+
RemitImapOutboxMessageResponse,
|
|
7
|
+
RemitImapOutboxMessageStatus,
|
|
8
|
+
RemitImapThreadMessageResponse,
|
|
9
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
10
|
+
import {
|
|
11
|
+
makeAccount,
|
|
12
|
+
makeMailbox,
|
|
13
|
+
makeThreadMessage,
|
|
14
|
+
} from "@/test-support/fixtures";
|
|
15
|
+
|
|
16
|
+
export interface MailWorld {
|
|
17
|
+
accounts: RemitImapAccountResponse[];
|
|
18
|
+
mailboxes: RemitImapMailboxResponse[];
|
|
19
|
+
threads: RemitImapThreadMessageResponse[];
|
|
20
|
+
outbox: RemitImapOutboxMessageResponse[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MINUTE = 60_000;
|
|
24
|
+
const HOUR = 60 * MINUTE;
|
|
25
|
+
const DAY = 24 * HOUR;
|
|
26
|
+
const NOW = Date.UTC(2026, 8, 24, 13, 0);
|
|
27
|
+
|
|
28
|
+
const ROLES = [
|
|
29
|
+
"Inbox",
|
|
30
|
+
"Drafts",
|
|
31
|
+
"Sent",
|
|
32
|
+
"Archive",
|
|
33
|
+
"Junk",
|
|
34
|
+
"Trash",
|
|
35
|
+
] as const satisfies readonly RemitImapCanonicalMailboxRole[];
|
|
36
|
+
|
|
37
|
+
const FOLDER_PATHS: Record<(typeof ROLES)[number], string> = {
|
|
38
|
+
Inbox: "INBOX",
|
|
39
|
+
Drafts: "Drafts",
|
|
40
|
+
Sent: "Sent",
|
|
41
|
+
Archive: "Archive",
|
|
42
|
+
Junk: "Junk",
|
|
43
|
+
Trash: "Trash",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const mailboxIdFor = (
|
|
47
|
+
accountId: string,
|
|
48
|
+
role: RemitImapCanonicalMailboxRole,
|
|
49
|
+
): string => `mbx-${accountId}-${role.toLowerCase()}`;
|
|
50
|
+
|
|
51
|
+
const accountWithFolders = (
|
|
52
|
+
accountId: string,
|
|
53
|
+
email: string,
|
|
54
|
+
displayName: string,
|
|
55
|
+
): RemitImapAccountResponse =>
|
|
56
|
+
makeAccount({
|
|
57
|
+
accountId,
|
|
58
|
+
email,
|
|
59
|
+
username: email,
|
|
60
|
+
smtpUsername: email,
|
|
61
|
+
displayName,
|
|
62
|
+
lastSyncAt: NOW,
|
|
63
|
+
folderAppointments: ROLES.map((role) => ({
|
|
64
|
+
role,
|
|
65
|
+
source: "Appointed",
|
|
66
|
+
mailboxId: mailboxIdFor(accountId, role),
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const foldersOf = (accountId: string): RemitImapMailboxResponse[] =>
|
|
71
|
+
ROLES.map((role) =>
|
|
72
|
+
makeMailbox({
|
|
73
|
+
accountId,
|
|
74
|
+
mailboxId: mailboxIdFor(accountId, role),
|
|
75
|
+
fullPath: FOLDER_PATHS[role],
|
|
76
|
+
specialUse: role === "Inbox" ? undefined : [role],
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export const PERSONAL = "acc-personal";
|
|
81
|
+
export const WORK = "acc-work";
|
|
82
|
+
|
|
83
|
+
const accounts = [
|
|
84
|
+
accountWithFolders(PERSONAL, "alice@example.com", "Personal"),
|
|
85
|
+
accountWithFolders(WORK, "alice.tan@acme.example", "Work"),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
interface ThreadSeed {
|
|
89
|
+
id: string;
|
|
90
|
+
accountId: string;
|
|
91
|
+
role?: RemitImapCanonicalMailboxRole;
|
|
92
|
+
fromName: string;
|
|
93
|
+
fromEmail: string;
|
|
94
|
+
subject: string;
|
|
95
|
+
snippet: string;
|
|
96
|
+
category: RemitImapMessageCategory;
|
|
97
|
+
ago: number;
|
|
98
|
+
isRead?: boolean;
|
|
99
|
+
starred?: boolean;
|
|
100
|
+
hasAttachment?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const thread = (seed: ThreadSeed): RemitImapThreadMessageResponse =>
|
|
104
|
+
makeThreadMessage({
|
|
105
|
+
messageId: `msg-${seed.id}`,
|
|
106
|
+
threadId: `thread-${seed.id}`,
|
|
107
|
+
threadMessageId: `tm-${seed.id}`,
|
|
108
|
+
accountId: seed.accountId,
|
|
109
|
+
mailboxId: mailboxIdFor(seed.accountId, seed.role ?? "Inbox"),
|
|
110
|
+
fromName: seed.fromName,
|
|
111
|
+
fromEmail: seed.fromEmail,
|
|
112
|
+
subject: seed.subject,
|
|
113
|
+
snippet: seed.snippet,
|
|
114
|
+
category: seed.category,
|
|
115
|
+
sentDate: NOW - seed.ago,
|
|
116
|
+
isRead: seed.isRead ?? false,
|
|
117
|
+
hasAttachment: seed.hasAttachment ?? false,
|
|
118
|
+
star: seed.starred ? "yellow" : "none",
|
|
119
|
+
hasStars: seed.starred ?? false,
|
|
120
|
+
syncStatus: "synced",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const inboxThreads: RemitImapThreadMessageResponse[] = [
|
|
124
|
+
thread({
|
|
125
|
+
id: "q3-planning",
|
|
126
|
+
accountId: WORK,
|
|
127
|
+
fromName: "Ada Lovelace",
|
|
128
|
+
fromEmail: "ada@acme.example",
|
|
129
|
+
subject: "Q3 planning",
|
|
130
|
+
snippet: "Can we move the review to Thursday? I have the numbers ready.",
|
|
131
|
+
category: "personal",
|
|
132
|
+
ago: 20 * MINUTE,
|
|
133
|
+
starred: true,
|
|
134
|
+
}),
|
|
135
|
+
thread({
|
|
136
|
+
id: "lease",
|
|
137
|
+
accountId: PERSONAL,
|
|
138
|
+
fromName: "Amara Okafor",
|
|
139
|
+
fromEmail: "amara@example.org",
|
|
140
|
+
subject: "Signed lease attached",
|
|
141
|
+
snippet: "Here is the countersigned copy for your records.",
|
|
142
|
+
category: "personal",
|
|
143
|
+
ago: 2 * HOUR,
|
|
144
|
+
hasAttachment: true,
|
|
145
|
+
starred: true,
|
|
146
|
+
}),
|
|
147
|
+
thread({
|
|
148
|
+
id: "dinner",
|
|
149
|
+
accountId: PERSONAL,
|
|
150
|
+
fromName: "Grace Hopper",
|
|
151
|
+
fromEmail: "grace@example.org",
|
|
152
|
+
subject: "Dinner on Saturday?",
|
|
153
|
+
snippet: "We are thinking the new place on the corner, around seven.",
|
|
154
|
+
category: "personal",
|
|
155
|
+
ago: 5 * HOUR,
|
|
156
|
+
isRead: true,
|
|
157
|
+
}),
|
|
158
|
+
thread({
|
|
159
|
+
id: "invoice",
|
|
160
|
+
accountId: WORK,
|
|
161
|
+
fromName: "Billing",
|
|
162
|
+
fromEmail: "billing@saas.example",
|
|
163
|
+
subject: "Invoice #1042",
|
|
164
|
+
snippet: "Your invoice for September is ready.",
|
|
165
|
+
category: "transactional",
|
|
166
|
+
ago: 7 * HOUR,
|
|
167
|
+
isRead: true,
|
|
168
|
+
starred: true,
|
|
169
|
+
}),
|
|
170
|
+
thread({
|
|
171
|
+
id: "shipping",
|
|
172
|
+
accountId: PERSONAL,
|
|
173
|
+
fromName: "Parcel Service",
|
|
174
|
+
fromEmail: "track@parcel.example",
|
|
175
|
+
subject: "Your order has shipped",
|
|
176
|
+
snippet: "Estimated delivery: tomorrow before noon.",
|
|
177
|
+
category: "transactional",
|
|
178
|
+
ago: 9 * HOUR,
|
|
179
|
+
}),
|
|
180
|
+
thread({
|
|
181
|
+
id: "weekly-digest",
|
|
182
|
+
accountId: PERSONAL,
|
|
183
|
+
fromName: "The Weekly",
|
|
184
|
+
fromEmail: "digest@weekly.example",
|
|
185
|
+
subject: "This week in type systems",
|
|
186
|
+
snippet: "Variance, soundness and a long read on gradual typing.",
|
|
187
|
+
category: "newsletter",
|
|
188
|
+
ago: DAY,
|
|
189
|
+
}),
|
|
190
|
+
thread({
|
|
191
|
+
id: "sale",
|
|
192
|
+
accountId: PERSONAL,
|
|
193
|
+
fromName: "Outdoor Store",
|
|
194
|
+
fromEmail: "news@outdoor.example",
|
|
195
|
+
subject: "Autumn sale starts now",
|
|
196
|
+
snippet: "Up to 40% off jackets and boots this weekend only.",
|
|
197
|
+
category: "marketing",
|
|
198
|
+
ago: DAY + 3 * HOUR,
|
|
199
|
+
}),
|
|
200
|
+
thread({
|
|
201
|
+
id: "mention",
|
|
202
|
+
accountId: WORK,
|
|
203
|
+
fromName: "Chat",
|
|
204
|
+
fromEmail: "notifications@chat.example",
|
|
205
|
+
subject: "Linus mentioned you in #design",
|
|
206
|
+
snippet: "Linus: can you take a look at the new header?",
|
|
207
|
+
category: "social",
|
|
208
|
+
ago: DAY + 5 * HOUR,
|
|
209
|
+
}),
|
|
210
|
+
thread({
|
|
211
|
+
id: "build",
|
|
212
|
+
accountId: WORK,
|
|
213
|
+
fromName: "CI",
|
|
214
|
+
fromEmail: "ci@builds.example",
|
|
215
|
+
subject: "Build passed on main",
|
|
216
|
+
snippet: "All 212 checks passed in 6m 12s.",
|
|
217
|
+
category: "automated",
|
|
218
|
+
ago: 2 * DAY,
|
|
219
|
+
isRead: true,
|
|
220
|
+
}),
|
|
221
|
+
thread({
|
|
222
|
+
id: "talk-outline",
|
|
223
|
+
accountId: PERSONAL,
|
|
224
|
+
role: "Archive",
|
|
225
|
+
fromName: "Ken Thompson",
|
|
226
|
+
fromEmail: "ken@example.org",
|
|
227
|
+
subject: "Conference talk outline",
|
|
228
|
+
snippet: "Here is the rough structure for the keynote.",
|
|
229
|
+
category: "personal",
|
|
230
|
+
ago: 3 * DAY,
|
|
231
|
+
isRead: true,
|
|
232
|
+
starred: true,
|
|
233
|
+
}),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const serverDrafts: RemitImapThreadMessageResponse[] = [
|
|
237
|
+
thread({
|
|
238
|
+
id: "draft-talk",
|
|
239
|
+
accountId: PERSONAL,
|
|
240
|
+
role: "Drafts",
|
|
241
|
+
fromName: "Alice",
|
|
242
|
+
fromEmail: "alice@example.com",
|
|
243
|
+
subject: "Re: Conference talk outline",
|
|
244
|
+
snippet: "Thanks Ken, I will send slides by Friday.",
|
|
245
|
+
category: "personal",
|
|
246
|
+
ago: 4 * HOUR,
|
|
247
|
+
isRead: true,
|
|
248
|
+
}),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
interface OutboxSeed {
|
|
252
|
+
id: string;
|
|
253
|
+
accountId: string;
|
|
254
|
+
to: string;
|
|
255
|
+
subject: string;
|
|
256
|
+
body: string;
|
|
257
|
+
status: RemitImapOutboxMessageStatus;
|
|
258
|
+
ago: number;
|
|
259
|
+
lastError?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const outboxMessage = (
|
|
263
|
+
seed: OutboxSeed,
|
|
264
|
+
): RemitImapOutboxMessageResponse => {
|
|
265
|
+
const from = accounts.find((account) => account.accountId === seed.accountId);
|
|
266
|
+
return {
|
|
267
|
+
outboxMessageId: `out-${seed.id}`,
|
|
268
|
+
accountId: seed.accountId,
|
|
269
|
+
fromAddress: from?.email ?? "alice@example.com",
|
|
270
|
+
fromName: "Alice",
|
|
271
|
+
toAddresses: [seed.to],
|
|
272
|
+
ccAddresses: [],
|
|
273
|
+
bccAddresses: [],
|
|
274
|
+
subject: seed.subject,
|
|
275
|
+
references: [],
|
|
276
|
+
textBody: seed.body,
|
|
277
|
+
status: seed.status,
|
|
278
|
+
lastError: seed.lastError,
|
|
279
|
+
sentAt: seed.status === "sent" ? NOW - seed.ago : undefined,
|
|
280
|
+
createdAt: NOW - seed.ago,
|
|
281
|
+
updatedAt: NOW - seed.ago,
|
|
282
|
+
attachments: [],
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const remitDrafts: RemitImapOutboxMessageResponse[] = [
|
|
287
|
+
outboxMessage({
|
|
288
|
+
id: "draft-q3",
|
|
289
|
+
accountId: PERSONAL,
|
|
290
|
+
to: "ada@acme.example",
|
|
291
|
+
subject: "Re: Q3 planning",
|
|
292
|
+
body: "Thanks, Thursday works for me.",
|
|
293
|
+
status: "draft",
|
|
294
|
+
ago: 10 * MINUTE,
|
|
295
|
+
}),
|
|
296
|
+
outboxMessage({
|
|
297
|
+
id: "draft-untitled",
|
|
298
|
+
accountId: PERSONAL,
|
|
299
|
+
to: "team@example.com",
|
|
300
|
+
subject: "",
|
|
301
|
+
body: "",
|
|
302
|
+
status: "draft",
|
|
303
|
+
ago: HOUR,
|
|
304
|
+
}),
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
export const outboxInEveryStatus: RemitImapOutboxMessageResponse[] = [
|
|
308
|
+
outboxMessage({
|
|
309
|
+
id: "queued",
|
|
310
|
+
accountId: WORK,
|
|
311
|
+
to: "ada@acme.example",
|
|
312
|
+
subject: "Re: Q3 planning",
|
|
313
|
+
body: "Thursday it is.",
|
|
314
|
+
status: "queued",
|
|
315
|
+
ago: 2 * MINUTE,
|
|
316
|
+
}),
|
|
317
|
+
outboxMessage({
|
|
318
|
+
id: "sending",
|
|
319
|
+
accountId: WORK,
|
|
320
|
+
to: "team@acme.example",
|
|
321
|
+
subject: "Weekly update",
|
|
322
|
+
body: "Highlights from this week.",
|
|
323
|
+
status: "sending",
|
|
324
|
+
ago: 3 * MINUTE,
|
|
325
|
+
}),
|
|
326
|
+
outboxMessage({
|
|
327
|
+
id: "sent",
|
|
328
|
+
accountId: WORK,
|
|
329
|
+
to: "grace@example.org",
|
|
330
|
+
subject: "Invoice #1042",
|
|
331
|
+
body: "Paid, thanks.",
|
|
332
|
+
status: "sent",
|
|
333
|
+
ago: 16 * MINUTE,
|
|
334
|
+
}),
|
|
335
|
+
outboxMessage({
|
|
336
|
+
id: "unfiled",
|
|
337
|
+
accountId: PERSONAL,
|
|
338
|
+
to: "amara@example.org",
|
|
339
|
+
subject: "Signed lease",
|
|
340
|
+
body: "Signed and attached.",
|
|
341
|
+
status: "unfiled",
|
|
342
|
+
ago: 22 * MINUTE,
|
|
343
|
+
lastError: "Sent, but not filed: this account has no Sent folder",
|
|
344
|
+
}),
|
|
345
|
+
outboxMessage({
|
|
346
|
+
id: "failed",
|
|
347
|
+
accountId: WORK,
|
|
348
|
+
to: "linus@example.org",
|
|
349
|
+
subject: "Design review notes",
|
|
350
|
+
body: "Notes from today.",
|
|
351
|
+
status: "failed",
|
|
352
|
+
ago: 34 * MINUTE,
|
|
353
|
+
lastError: "SMTP connection timed out",
|
|
354
|
+
}),
|
|
355
|
+
outboxMessage({
|
|
356
|
+
id: "blocked",
|
|
357
|
+
accountId: PERSONAL,
|
|
358
|
+
to: "ken@example.org",
|
|
359
|
+
subject: "Re: contract",
|
|
360
|
+
body: "Looks good to me.",
|
|
361
|
+
status: "blocked",
|
|
362
|
+
ago: DAY,
|
|
363
|
+
lastError: "No SMTP server configured for this account",
|
|
364
|
+
}),
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
export const newsletterBacklog = (
|
|
368
|
+
count: number,
|
|
369
|
+
): RemitImapThreadMessageResponse[] =>
|
|
370
|
+
Array.from({ length: count }, (_, index) =>
|
|
371
|
+
thread({
|
|
372
|
+
id: `digest-${index}`,
|
|
373
|
+
accountId: PERSONAL,
|
|
374
|
+
fromName: "The Weekly",
|
|
375
|
+
fromEmail: "digest@weekly.example",
|
|
376
|
+
subject: `Issue ${index + 1}: notes from the field`,
|
|
377
|
+
snippet: "Three long reads and a short one.",
|
|
378
|
+
category: "newsletter",
|
|
379
|
+
ago: 2 * DAY + index * HOUR,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
|
|
384
|
+
accounts,
|
|
385
|
+
mailboxes: accounts.flatMap((account) => foldersOf(account.accountId)),
|
|
386
|
+
threads: [...inboxThreads, ...serverDrafts],
|
|
387
|
+
outbox: [...remitDrafts, ...outboxInEveryStatus],
|
|
388
|
+
...overrides,
|
|
389
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const HOLD_MS = 700;
|
|
2
|
+
|
|
3
|
+
const touch = (target: HTMLElement, type: string) => {
|
|
4
|
+
const { left, top } = target.getBoundingClientRect();
|
|
5
|
+
target.dispatchEvent(
|
|
6
|
+
new PointerEvent(type, {
|
|
7
|
+
bubbles: true,
|
|
8
|
+
pointerType: "touch",
|
|
9
|
+
pointerId: 1,
|
|
10
|
+
isPrimary: true,
|
|
11
|
+
clientX: left + 20,
|
|
12
|
+
clientY: top + 10,
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const longPress = async (target: HTMLElement): Promise<void> => {
|
|
18
|
+
touch(target, "pointerdown");
|
|
19
|
+
await new Promise((resolve) => setTimeout(resolve, HOLD_MS));
|
|
20
|
+
touch(target, "pointerup");
|
|
21
|
+
};
|
package/src/router.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
-
import { createRouter } from "@tanstack/react-router";
|
|
2
|
+
import { createRouter, type RouterHistory } from "@tanstack/react-router";
|
|
3
3
|
import { AppShellSkeleton } from "./components/layout/AppShellSkeleton";
|
|
4
4
|
import { recordRoute } from "./lib/route-breadcrumbs";
|
|
5
5
|
import { stringifySearch } from "./lib/search-params";
|
|
@@ -13,10 +13,12 @@ export interface RouterContext {
|
|
|
13
13
|
export const createAppRouter = (
|
|
14
14
|
queryClient: QueryClient,
|
|
15
15
|
telemetry: Telemetry,
|
|
16
|
+
history?: RouterHistory,
|
|
16
17
|
) => {
|
|
17
18
|
const router = createRouter({
|
|
18
19
|
routeTree,
|
|
19
20
|
context: { queryClient },
|
|
21
|
+
history,
|
|
20
22
|
// A query param that carries a list writes one param per value
|
|
21
23
|
// (`lib/search-params.ts`), which is what the router's own reader already
|
|
22
24
|
// expects and what a hand-written link says.
|