@remit/ui 0.0.105 → 0.0.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/attachment-list.stories.tsx +127 -96
- package/src/components/compose-action-bar.stories.tsx +38 -4
- package/src/components/compose-body.stories.tsx +20 -3
- package/src/components/compose-mode-toggle.stories.tsx +10 -11
- package/src/components/compose-smtp-missing-banner.stories.tsx +1 -1
- package/src/components/reading-pane.stories.tsx +45 -21
- package/src/components/rich-text-editor.stories.tsx +191 -13
- package/src/components/rich-text-editor.tsx +364 -53
- package/src/components/rich-text-spellcheck-provider.test.ts +217 -0
- package/src/components/rich-text-spellcheck-provider.ts +83 -0
- package/src/components/rich-text-spellcheck-words.ts +214 -0
- package/src/components/rich-text-spellcheck-worker-provider.ts +42 -0
- package/src/components/rich-text-spellcheck-worker.ts +54 -0
- package/src/components/rich-text-spellcheck.test.ts +638 -0
- package/src/components/rich-text-spellcheck.ts +75 -0
- package/src/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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={
|
|
67
|
-
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: ()
|
|
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 {
|
|
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
|
-
<
|
|
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
|
/>
|