@remit/web-client 0.0.296 → 0.0.298

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.296",
3
+ "version": "0.0.298",
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",
@@ -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
+ };
@@ -138,8 +138,8 @@ function joinDomains(domains: readonly string[]): string {
138
138
  /**
139
139
  * The clauses naming what does not line up on a message whose signature checks
140
140
  * out. Empty when everything the backend compared agreed — including when it
141
- * compared nothing, which is every message the provider's filter did not
142
- * already call spam.
141
+ * compared nothing, which is every message outside Junk that the provider's
142
+ * filter did not already call spam.
143
143
  *
144
144
  * Each clause leads with the concern and names `auth.fromDomain` — the
145
145
  * domain `classifyDisplayNameCorrespondence` actually compared the display
@@ -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
+ }