@remit/ui 0.0.105 → 0.0.106

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/ui",
3
- "version": "0.0.105",
3
+ "version": "0.0.106",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
2
3
  import { type AttachmentItem, AttachmentList } from "./attachment-list.js";
3
4
 
4
5
  /**
@@ -25,57 +26,95 @@ const report: AttachmentItem = {
25
26
  download: { status: "idle" },
26
27
  };
27
28
 
29
+ const sitePlan: AttachmentItem = {
30
+ attachmentId: "part-3",
31
+ filename: "site-plan.png",
32
+ typeLabel: "PNG",
33
+ sizeOctets: 486_120,
34
+ download: { status: "idle" },
35
+ };
36
+
37
+ /** How long the app's own fetch takes on a small attachment, near enough. */
38
+ const DOWNLOAD_MS = 900;
39
+
40
+ /**
41
+ * The app owns the fetch and hands per-row state back. The harness plays that
42
+ * part: a press puts the row on the spinner, and the row returns to rest when
43
+ * the file has been handed to the browser. A row is only pressable once at a
44
+ * time, which is the state the component reads to disable it.
45
+ */
46
+ const Harness = ({
47
+ attachments,
48
+ hasUnlistedAttachment,
49
+ }: {
50
+ attachments: readonly AttachmentItem[];
51
+ hasUnlistedAttachment?: boolean;
52
+ }) => {
53
+ const [rows, setRows] = useState<readonly AttachmentItem[]>(attachments);
54
+
55
+ const setDownload = (attachmentId: string, item: AttachmentItem) =>
56
+ setRows((current) =>
57
+ current.map((row) => (row.attachmentId === attachmentId ? item : row)),
58
+ );
59
+
60
+ const download = (attachmentId: string) => {
61
+ const row = rows.find((item) => item.attachmentId === attachmentId);
62
+ if (!row || row.download.status === "downloading") return;
63
+ setDownload(attachmentId, { ...row, download: { status: "downloading" } });
64
+ setTimeout(
65
+ () => setDownload(attachmentId, { ...row, download: { status: "idle" } }),
66
+ DOWNLOAD_MS,
67
+ );
68
+ };
69
+
70
+ return (
71
+ <AttachmentList
72
+ attachments={rows}
73
+ onDownload={download}
74
+ hasUnlistedAttachment={hasUnlistedAttachment}
75
+ />
76
+ );
77
+ };
78
+
28
79
  export const OneAttachment: Story = {
29
- args: {
30
- attachments: [report],
31
- onDownload: (id) => alert(`Download ${id}`),
32
- },
80
+ render: () => <Harness attachments={[report]} />,
33
81
  };
34
82
 
35
83
  export const SeveralAttachments: Story = {
36
- args: {
37
- attachments: [
38
- report,
39
- {
40
- attachmentId: "part-3",
41
- filename: "site-plan.png",
42
- typeLabel: "PNG",
43
- sizeOctets: 486_120,
44
- download: { status: "idle" },
45
- },
46
- {
47
- attachmentId: "part-4",
48
- filename: "notes.txt",
49
- typeLabel: "PLAIN",
50
- sizeOctets: 812,
51
- download: { status: "idle" },
52
- },
53
- {
54
- attachmentId: "part-5",
55
- filename: "archive",
56
- typeLabel: "FILE",
57
- sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
58
- download: { status: "idle" },
59
- },
60
- ],
61
- onDownload: (id) => alert(`Download ${id}`),
62
- },
84
+ render: () => (
85
+ <Harness
86
+ attachments={[
87
+ report,
88
+ sitePlan,
89
+ {
90
+ attachmentId: "part-4",
91
+ filename: "notes.txt",
92
+ typeLabel: "PLAIN",
93
+ sizeOctets: 812,
94
+ download: { status: "idle" },
95
+ },
96
+ {
97
+ attachmentId: "part-5",
98
+ filename: "archive",
99
+ typeLabel: "FILE",
100
+ sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
101
+ download: { status: "idle" },
102
+ },
103
+ ]}
104
+ />
105
+ ),
63
106
  };
64
107
 
108
+ /** One row mid-fetch. The rows beside it are still pressable. */
65
109
  export const Downloading: Story = {
66
- args: {
67
- attachments: [
68
- { ...report, download: { status: "downloading" } },
69
- {
70
- attachmentId: "part-3",
71
- filename: "site-plan.png",
72
- typeLabel: "PNG",
73
- sizeOctets: 486_120,
74
- download: { status: "idle" },
75
- },
76
- ],
77
- onDownload: (id) => alert(`Download ${id}`),
78
- },
110
+ render: () => (
111
+ <Harness
112
+ attachments={[
113
+ { ...report, download: { status: "downloading" } },
114
+ sitePlan,
115
+ ]}
116
+ />
117
+ ),
79
118
  };
80
119
 
81
120
  /**
@@ -85,28 +124,23 @@ export const Downloading: Story = {
85
124
  * outcome this list exists to make impossible.
86
125
  */
87
126
  export const DownloadFailed: Story = {
88
- args: {
89
- attachments: [
90
- {
91
- ...report,
92
- download: {
93
- status: "failed",
94
- title: "This attachment is missing from storage",
95
- detail:
96
- "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
97
- reportUrl: "https://github.com/remit-mail/reader/issues/new",
127
+ render: () => (
128
+ <Harness
129
+ attachments={[
130
+ {
131
+ ...report,
132
+ download: {
133
+ status: "failed",
134
+ title: "This attachment is missing from storage",
135
+ detail:
136
+ "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
137
+ reportUrl: "https://github.com/remit-mail/reader/issues/new",
138
+ },
98
139
  },
99
- },
100
- {
101
- attachmentId: "part-3",
102
- filename: "site-plan.png",
103
- typeLabel: "PNG",
104
- sizeOctets: 486_120,
105
- download: { status: "idle" },
106
- },
107
- ],
108
- onDownload: (id) => alert(`Download ${id}`),
109
- },
140
+ sitePlan,
141
+ ]}
142
+ />
143
+ ),
110
144
  };
111
145
 
112
146
  /**
@@ -117,32 +151,33 @@ export const DownloadFailed: Story = {
117
151
  * what lands.
118
152
  */
119
153
  export const HostileFilename: Story = {
120
- args: {
121
- attachments: [
122
- {
123
- attachmentId: "part-6",
124
- filename: "passwd",
125
- typeLabel: "FILE",
126
- sizeOctets: 3_120,
127
- download: { status: "idle" },
128
- },
129
- {
130
- attachmentId: "part-7",
131
- filename: "invoicegnp.exe",
132
- typeLabel: "FILE",
133
- sizeOctets: 118_400,
134
- download: { status: "idle" },
135
- },
136
- {
137
- attachmentId: "part-8",
138
- filename: `${"long-name-".repeat(11)}report.pdf`,
139
- typeLabel: "PDF",
140
- sizeOctets: 44_000,
141
- download: { status: "idle" },
142
- },
143
- ],
144
- onDownload: (id) => alert(`Download ${id}`),
145
- },
154
+ render: () => (
155
+ <Harness
156
+ attachments={[
157
+ {
158
+ attachmentId: "part-6",
159
+ filename: "passwd",
160
+ typeLabel: "FILE",
161
+ sizeOctets: 3_120,
162
+ download: { status: "idle" },
163
+ },
164
+ {
165
+ attachmentId: "part-7",
166
+ filename: "invoicegnp.exe",
167
+ typeLabel: "FILE",
168
+ sizeOctets: 118_400,
169
+ download: { status: "idle" },
170
+ },
171
+ {
172
+ attachmentId: "part-8",
173
+ filename: `${"long-name-".repeat(11)}report.pdf`,
174
+ typeLabel: "PDF",
175
+ sizeOctets: 44_000,
176
+ download: { status: "idle" },
177
+ },
178
+ ]}
179
+ />
180
+ ),
146
181
  };
147
182
 
148
183
  /**
@@ -151,9 +186,5 @@ export const HostileFilename: Story = {
151
186
  * read as broken.
152
187
  */
153
188
  export const UnlistedAttachment: Story = {
154
- args: {
155
- attachments: [],
156
- onDownload: () => undefined,
157
- hasUnlistedAttachment: true,
158
- },
189
+ render: () => <Harness attachments={[]} hasUnlistedAttachment />,
159
190
  };
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
2
4
  import { ComposeActionBar } from "./compose-action-bar.js";
3
5
 
4
6
  const meta: Meta<typeof ComposeActionBar> = {
@@ -30,11 +32,43 @@ export const Sending: Story = {
30
32
  args: { sending: true },
31
33
  };
32
34
 
35
+ /**
36
+ * Send is never greyed out. Pressing it with nothing to send on reports the
37
+ * reason where the app would raise its banner — a control that swallowed the
38
+ * press would be the dead button this bar exists to avoid.
39
+ */
33
40
  export const CannotSend: Story = {
34
41
  name: "Cannot send — stays pressable",
35
- args: {
36
- canSend: false,
37
- unavailableReason: "SMTP not configured",
38
- onUnavailable: () => undefined,
42
+ render: () => {
43
+ const [reason, setReason] = useState<string>();
44
+ return (
45
+ <div className="space-y-2">
46
+ {reason && (
47
+ <div
48
+ role="alert"
49
+ data-testid="compose-unavailable"
50
+ className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger"
51
+ >
52
+ {reason}
53
+ </div>
54
+ )}
55
+ <ComposeActionBar
56
+ onSend={() => undefined}
57
+ onDiscard={() => undefined}
58
+ sending={false}
59
+ canSend={false}
60
+ saveStatus="idle"
61
+ unavailableReason="SMTP not configured"
62
+ onUnavailable={setReason}
63
+ />
64
+ </div>
65
+ );
66
+ },
67
+ play: async ({ canvasElement }) => {
68
+ const canvas = within(canvasElement);
69
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
70
+ await expect(canvas.getByTestId("compose-unavailable")).toHaveTextContent(
71
+ "SMTP not configured",
72
+ );
39
73
  },
40
74
  };
@@ -39,7 +39,7 @@ const Harness = ({
39
39
  initialHtml = "",
40
40
  initialText = "",
41
41
  startIn = "rich",
42
- onConversionError = () => undefined,
42
+ onConversionError,
43
43
  conversions,
44
44
  languages = LANGUAGES,
45
45
  quoted,
@@ -56,15 +56,29 @@ const Harness = ({
56
56
  quoted?: string;
57
57
  }) => {
58
58
  const [mode, setMode] = useState<"rich" | "plain">(startIn);
59
+ const [failure, setFailure] = useState<ConversionFailure>();
59
60
  return (
60
61
  <div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
62
+ {failure && (
63
+ <div
64
+ role="alert"
65
+ data-testid="compose-conversion-error"
66
+ className="border-b border-danger/30 bg-danger-soft px-3 py-2 text-xs"
67
+ >
68
+ <p className="font-medium text-danger">{failure.title}</p>
69
+ <p className="text-fg-muted">{failure.detail}</p>
70
+ </div>
71
+ )}
61
72
  <ComposeBody
62
73
  mode={mode}
63
74
  onModeChange={setMode}
64
75
  initialHtml={initialHtml}
65
76
  initialText={initialText}
66
- onChange={() => undefined}
67
- onConversionError={onConversionError}
77
+ onChange={noop}
78
+ onConversionError={(reported) => {
79
+ setFailure(reported);
80
+ onConversionError?.(reported);
81
+ }}
68
82
  conversions={conversions}
69
83
  languages={languages}
70
84
  onLanguageChange={noop}
@@ -278,6 +292,9 @@ export const ConversionCameBackEmpty: Story = {
278
292
  title: "Couldn't switch to rich text",
279
293
  detail: "The conversion came back empty, so your message is unchanged.",
280
294
  });
295
+ await expect(
296
+ within(canvasElement).getByTestId("compose-conversion-error"),
297
+ ).toHaveTextContent("Couldn't switch to rich text");
281
298
  const textarea = plainSurface(canvasElement);
282
299
  if (!textarea) throw new Error("the plain surface left");
283
300
  await expect(textarea.value).toBe("Everything I wrote this morning.");
@@ -15,22 +15,11 @@ const meta: Meta<typeof ComposeModeToggle> = {
15
15
  title: "Mail/ComposeModeToggle",
16
16
  component: ComposeModeToggle,
17
17
  parameters: { layout: "centered" },
18
- args: { onToggle: () => undefined },
19
18
  };
20
19
  export default meta;
21
20
 
22
21
  type Story = StoryObj<typeof ComposeModeToggle>;
23
22
 
24
- export const RichText: Story = {
25
- name: "Rich text — plain text on offer",
26
- args: { mode: "rich" },
27
- };
28
-
29
- export const PlainText: Story = {
30
- name: "Plain text — the mode is on",
31
- args: { mode: "plain" },
32
- };
33
-
34
23
  const Harness = ({ start }: { start: ComposeBodyMode }) => {
35
24
  const [mode, setMode] = useState<ComposeBodyMode>(start);
36
25
  return (
@@ -41,6 +30,16 @@ const Harness = ({ start }: { start: ComposeBodyMode }) => {
41
30
  );
42
31
  };
43
32
 
33
+ export const RichText: Story = {
34
+ name: "Rich text — plain text on offer",
35
+ render: () => <Harness start="rich" />,
36
+ };
37
+
38
+ export const PlainText: Story = {
39
+ name: "Plain text — the mode is on",
40
+ render: () => <Harness start="plain" />,
41
+ };
42
+
44
43
  export const PressedStateFollowsTheMode: Story = {
45
44
  render: () => <Harness start="rich" />,
46
45
  play: async ({ canvasElement }) => {
@@ -11,7 +11,7 @@ const meta: Meta<typeof ComposeSmtpMissingBanner> = {
11
11
  title: "Mail/ComposeSmtpMissingBanner",
12
12
  component: ComposeSmtpMissingBanner,
13
13
  parameters: { layout: "padded" },
14
- args: { onConfigure: () => undefined },
14
+ args: { onConfigure: fn().mockName("onConfigure") },
15
15
  };
16
16
  export default meta;
17
17
 
@@ -1,7 +1,12 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Paperclip, Star } from "lucide-react";
3
+ import { useState } from "react";
3
4
  import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
4
- import { AttachmentList } from "./attachment-list.js";
5
+ import {
6
+ type AttachmentDownloadState,
7
+ type AttachmentItem,
8
+ AttachmentList,
9
+ } from "./attachment-list.js";
5
10
  import { MessageBodyView } from "./message-body-view.js";
6
11
  import {
7
12
  CollapsedMessage,
@@ -198,6 +203,44 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
198
203
  ),
199
204
  };
200
205
 
206
+ /** The app owns the fetch and hands the row its state back; so does this. */
207
+ const ThreadAttachments = () => {
208
+ const [rows, setRows] = useState<AttachmentItem[]>([
209
+ {
210
+ attachmentId: "part-2",
211
+ filename: "Q3 board pack.pdf",
212
+ typeLabel: "PDF",
213
+ sizeOctets: 2_411_724,
214
+ download: { status: "idle" },
215
+ },
216
+ {
217
+ attachmentId: "part-3",
218
+ filename: "headcount.csv",
219
+ typeLabel: "CSV",
220
+ sizeOctets: 4_180,
221
+ download: { status: "idle" },
222
+ },
223
+ ]);
224
+
225
+ const setStatus = (attachmentId: string, download: AttachmentDownloadState) =>
226
+ setRows((current) =>
227
+ current.map((row) =>
228
+ row.attachmentId === attachmentId ? { ...row, download } : row,
229
+ ),
230
+ );
231
+
232
+ return (
233
+ <AttachmentList
234
+ className="mt-4 px-2 lg:px-0"
235
+ attachments={rows}
236
+ onDownload={(attachmentId) => {
237
+ setStatus(attachmentId, { status: "downloading" });
238
+ setTimeout(() => setStatus(attachmentId, { status: "idle" }), 900);
239
+ }}
240
+ />
241
+ );
242
+ };
243
+
201
244
  /**
202
245
  * The expanded row as `MessageCard` composes it when the message carries files
203
246
  * (#683): body first, attachment list under it. The indicators row holds no
@@ -222,26 +265,7 @@ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
222
265
  category="personal"
223
266
  allowImages
224
267
  />
225
- <AttachmentList
226
- className="mt-4 px-2 lg:px-0"
227
- attachments={[
228
- {
229
- attachmentId: "part-2",
230
- filename: "Q3 board pack.pdf",
231
- typeLabel: "PDF",
232
- sizeOctets: 2_411_724,
233
- download: { status: "idle" },
234
- },
235
- {
236
- attachmentId: "part-3",
237
- filename: "headcount.csv",
238
- typeLabel: "CSV",
239
- sizeOctets: 4_180,
240
- download: { status: "idle" },
241
- },
242
- ]}
243
- onDownload={(id) => alert(`Download ${id}`)}
244
- />
268
+ <ThreadAttachments />
245
269
  </div>
246
270
  }
247
271
  />
@@ -1,8 +1,12 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
2
3
  import { expect, userEvent } from "storybook/test";
3
4
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
4
5
  import { ComposeLanguageChip } from "./compose-language-chip.js";
5
- import { ComposeModeToggle } from "./compose-mode-toggle.js";
6
+ import {
7
+ type ComposeBodyMode,
8
+ ComposeModeToggle,
9
+ } from "./compose-mode-toggle.js";
6
10
  import { RichTextEditor } from "./rich-text-editor.js";
7
11
 
8
12
  /**
@@ -108,18 +112,29 @@ export const ClickBelowTheText: Story = {
108
112
  /**
109
113
  * The two pinned controls, in the order compose ships them: the chip first, so
110
114
  * one Shift+Tab out of the body still reaches the mode toggle and two reach the
111
- * chip.
115
+ * chip. Both hold their own state here — the editor knows nothing about either,
116
+ * and a pinned control that did not answer a press would read as a broken
117
+ * toolbar rather than a layout story.
112
118
  */
113
- const pinnedControls = (
114
- <>
115
- <ComposeLanguageChip
116
- language="nl"
117
- languages={["nl", "en", "de"]}
118
- onSelect={() => undefined}
119
- />
120
- <ComposeModeToggle mode="rich" onToggle={() => undefined} />
121
- </>
122
- );
119
+ const PinnedControls = () => {
120
+ const [language, setLanguage] = useState("nl");
121
+ const [mode, setMode] = useState<ComposeBodyMode>("rich");
122
+ return (
123
+ <>
124
+ <ComposeLanguageChip
125
+ language={language}
126
+ languages={["nl", "en", "de"]}
127
+ onSelect={setLanguage}
128
+ />
129
+ <ComposeModeToggle
130
+ mode={mode}
131
+ onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
132
+ />
133
+ </>
134
+ );
135
+ };
136
+
137
+ const pinnedControls = <PinnedControls />;
123
138
 
124
139
  /** The toolbar as compose ships it: the formatting cluster, then the two pinned controls. */
125
140
  export const ToolbarInRich: Story = {