@remit/web-client 0.0.137 → 0.0.139
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 -2
- package/src/components/compose/AddressField.tsx +20 -188
- package/src/components/compose/ComposeForm.tsx +101 -91
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +10 -35
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/mail/MessageList.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.tsx +6 -2
- package/src/components/settings/AccountFormPanel.tsx +23 -0
- package/src/components/settings/label-delete-confirm.stories.tsx +1 -1
- package/src/hooks/useComposeLanguages.ts +69 -0
- package/src/routes/settings/labels.tsx +1 -1
- package/src/components/compose/ComposeBody.stories.tsx +0 -274
- package/src/components/compose/ComposeBody.tsx +0 -170
- package/src/components/compose/SubjectField.tsx +0 -19
- package/src/components/compose/compose-mode.test.ts +0 -76
- package/src/components/compose/compose-mode.ts +0 -50
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -54
- package/src/components/ui/ConfirmDialog.tsx +0 -140
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
* are not rendered: focus stops moving, the highlight disappears, and the next
|
|
17
17
|
* verb acts on a message the user cannot see.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ConfirmDialog,
|
|
21
|
+
SelectionTopBar,
|
|
22
|
+
useListCursor,
|
|
23
|
+
type Verb,
|
|
24
|
+
} from "@remit/ui";
|
|
20
25
|
import {
|
|
21
26
|
createContext,
|
|
22
27
|
type ReactNode,
|
|
@@ -28,7 +33,6 @@ import {
|
|
|
28
33
|
useRef,
|
|
29
34
|
useState,
|
|
30
35
|
} from "react";
|
|
31
|
-
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
32
36
|
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
33
37
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
34
38
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
9
|
import {
|
|
10
10
|
Button,
|
|
11
|
+
ComposeLanguageSetting,
|
|
11
12
|
Input,
|
|
12
13
|
PasswordInput,
|
|
13
14
|
Select,
|
|
@@ -19,6 +20,7 @@ import { Check, Loader2, X } from "lucide-react";
|
|
|
19
20
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
20
21
|
import { useForm } from "react-hook-form";
|
|
21
22
|
import { z } from "zod";
|
|
23
|
+
import { useComposeLanguages } from "../../hooks/useComposeLanguages";
|
|
22
24
|
import { useSignature } from "../../hooks/useSignature";
|
|
23
25
|
import {
|
|
24
26
|
getPresetById,
|
|
@@ -319,6 +321,25 @@ export const AccountFormPanel = ({
|
|
|
319
321
|
} = useSignature(account?.accountId);
|
|
320
322
|
const [signatureText, setSignatureText] = useState(signature.plainText);
|
|
321
323
|
|
|
324
|
+
const {
|
|
325
|
+
languages,
|
|
326
|
+
setLanguages,
|
|
327
|
+
isSaving: isLanguagesSaving,
|
|
328
|
+
} = useComposeLanguages(account?.accountId);
|
|
329
|
+
|
|
330
|
+
const languagesSection = (
|
|
331
|
+
<section>
|
|
332
|
+
<h3 className="text-2xs font-semibold text-fg-subtle uppercase tracking-wider mb-3">
|
|
333
|
+
Writing languages
|
|
334
|
+
</h3>
|
|
335
|
+
<ComposeLanguageSetting
|
|
336
|
+
value={languages}
|
|
337
|
+
onChange={setLanguages}
|
|
338
|
+
busy={isLanguagesSaving}
|
|
339
|
+
/>
|
|
340
|
+
</section>
|
|
341
|
+
);
|
|
342
|
+
|
|
322
343
|
useEffect(() => {
|
|
323
344
|
setSignatureText(signature.plainText);
|
|
324
345
|
}, [signature.plainText]);
|
|
@@ -475,6 +496,7 @@ export const AccountFormPanel = ({
|
|
|
475
496
|
</div>
|
|
476
497
|
</section>
|
|
477
498
|
)}
|
|
499
|
+
{isEditing && languagesSection}
|
|
478
500
|
</div>
|
|
479
501
|
</SlidePanel>
|
|
480
502
|
);
|
|
@@ -940,6 +962,7 @@ export const AccountFormPanel = ({
|
|
|
940
962
|
</div>
|
|
941
963
|
</section>
|
|
942
964
|
)}
|
|
965
|
+
{isEditing && languagesSection}
|
|
943
966
|
</form>
|
|
944
967
|
</SlidePanel>
|
|
945
968
|
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accountDetailOperationsUpdateAccountMutation,
|
|
3
|
+
configOperationsGetConfigOptions,
|
|
4
|
+
configOperationsGetConfigQueryKey,
|
|
5
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
6
|
+
import { defaultComposeLanguages } from "@remit/ui";
|
|
7
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { useCallback, useMemo } from "react";
|
|
9
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
|
+
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The account's writing languages: the menu the composer's language chip
|
|
14
|
+
* offers, and the set detection is allowed to choose inside. Absent on the
|
|
15
|
+
* server means the user has never been here, and the browser's own ordered
|
|
16
|
+
* answer stands in.
|
|
17
|
+
*/
|
|
18
|
+
export const useComposeLanguages = (accountId?: string) => {
|
|
19
|
+
const queryClient = useQueryClient();
|
|
20
|
+
const { pushError } = useErrorBanners();
|
|
21
|
+
|
|
22
|
+
const { data: config } = useQuery({
|
|
23
|
+
...configOperationsGetConfigOptions(),
|
|
24
|
+
staleTime: Infinity,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const configured = config?.accounts.find(
|
|
28
|
+
(account) => account.accountId === accountId,
|
|
29
|
+
)?.composeLanguages;
|
|
30
|
+
|
|
31
|
+
const languages = useMemo<string[]>(
|
|
32
|
+
() =>
|
|
33
|
+
configured && configured.length > 0
|
|
34
|
+
? [...configured]
|
|
35
|
+
: defaultComposeLanguages(navigator.languages),
|
|
36
|
+
[configured],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const mutation = useMutation({
|
|
40
|
+
...accountDetailOperationsUpdateAccountMutation(),
|
|
41
|
+
onSuccess: () => {
|
|
42
|
+
queryClient.invalidateQueries({
|
|
43
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
onError: (error) => {
|
|
47
|
+
pushError(
|
|
48
|
+
buildMutationErrorBanner(
|
|
49
|
+
"Couldn't save languages",
|
|
50
|
+
"The writing languages weren't saved.",
|
|
51
|
+
error,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const setLanguages = useCallback(
|
|
58
|
+
(next: string[]) => {
|
|
59
|
+
if (!accountId) return;
|
|
60
|
+
mutation.mutate({
|
|
61
|
+
path: { accountId },
|
|
62
|
+
body: { composeLanguages: next },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[accountId, mutation],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return { languages, setLanguages, isSaving: mutation.isPending };
|
|
69
|
+
};
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
import {
|
|
8
8
|
Banner,
|
|
9
9
|
Button,
|
|
10
|
+
ConfirmDialog,
|
|
10
11
|
Input,
|
|
11
12
|
labelColorOptions,
|
|
12
13
|
Select,
|
|
@@ -16,7 +17,6 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
16
17
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
17
18
|
import { useState } from "react";
|
|
18
19
|
import { LabelsList } from "@/components/settings/LabelsList";
|
|
19
|
-
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
20
20
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
21
21
|
import {
|
|
22
22
|
useCreateLabel,
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import type { RichTextValue } from "@remit/ui/rich-text";
|
|
2
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { expect, fn, userEvent, within } from "storybook/test";
|
|
5
|
-
import { ComposeBody, type ConversionFailure } from "./ComposeBody";
|
|
6
|
-
|
|
7
|
-
const RICH_DOCUMENT = [
|
|
8
|
-
"<h2>Quarterly numbers</h2>",
|
|
9
|
-
"<p>Revenue is <strong>up</strong> on the quarter.</p>",
|
|
10
|
-
"<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
|
|
11
|
-
"<tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
|
|
12
|
-
].join("");
|
|
13
|
-
|
|
14
|
-
const PLAIN_PARAGRAPHS =
|
|
15
|
-
"<p>Thanks — that works.</p><p></p><p>See you then.</p>";
|
|
16
|
-
|
|
17
|
-
const UNDERLINED = "<p>Please <u>read this</u> before Friday.</p>";
|
|
18
|
-
|
|
19
|
-
const PLAIN_MARKDOWN = [
|
|
20
|
-
"## Quarterly numbers",
|
|
21
|
-
"",
|
|
22
|
-
"| Region | Total |",
|
|
23
|
-
"| --- | --- |",
|
|
24
|
-
"| EMEA | 412 |",
|
|
25
|
-
].join("\n");
|
|
26
|
-
|
|
27
|
-
const Harness = ({
|
|
28
|
-
initialHtml = "",
|
|
29
|
-
initialText = "",
|
|
30
|
-
startIn = "rich",
|
|
31
|
-
onConversionError = () => undefined,
|
|
32
|
-
conversions,
|
|
33
|
-
}: {
|
|
34
|
-
initialHtml?: string;
|
|
35
|
-
initialText?: string;
|
|
36
|
-
startIn?: "rich" | "plain";
|
|
37
|
-
onConversionError?: (failure: ConversionFailure) => void;
|
|
38
|
-
conversions?: {
|
|
39
|
-
toPlain: (value: RichTextValue) => string;
|
|
40
|
-
toRich: (text: string) => string;
|
|
41
|
-
};
|
|
42
|
-
}) => {
|
|
43
|
-
const [mode, setMode] = useState<"rich" | "plain">(startIn);
|
|
44
|
-
return (
|
|
45
|
-
<div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
|
|
46
|
-
<ComposeBody
|
|
47
|
-
mode={mode}
|
|
48
|
-
onModeChange={setMode}
|
|
49
|
-
initialHtml={initialHtml}
|
|
50
|
-
initialText={initialText}
|
|
51
|
-
onChange={() => undefined}
|
|
52
|
-
onConversionError={onConversionError}
|
|
53
|
-
conversions={conversions}
|
|
54
|
-
/>
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The mode switch as the compose window runs it: the toolbar control, the one
|
|
61
|
-
* warning it raises, and the two surfaces it swaps between. The live
|
|
62
|
-
* `ComposeForm` adds the recipients, the autosave and the send around this.
|
|
63
|
-
*/
|
|
64
|
-
const meta: Meta<typeof Harness> = {
|
|
65
|
-
title: "Screens/WebClient/ComposeModes",
|
|
66
|
-
component: Harness,
|
|
67
|
-
parameters: { layout: "centered" },
|
|
68
|
-
};
|
|
69
|
-
export default meta;
|
|
70
|
-
|
|
71
|
-
type Story = StoryObj<typeof Harness>;
|
|
72
|
-
|
|
73
|
-
const toggleOf = (canvasElement: HTMLElement): HTMLElement => {
|
|
74
|
-
const toggle = canvasElement.querySelector<HTMLElement>(
|
|
75
|
-
"[data-testid=compose-mode-toggle]",
|
|
76
|
-
);
|
|
77
|
-
if (!toggle) throw new Error("the mode toggle is not mounted");
|
|
78
|
-
return toggle;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const plainSurface = (canvasElement: HTMLElement): HTMLTextAreaElement | null =>
|
|
82
|
-
canvasElement.querySelector<HTMLTextAreaElement>(
|
|
83
|
-
"[data-testid=compose-body-plain]",
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
export const RichDocument: Story = {
|
|
87
|
-
name: "Rich, with formatting",
|
|
88
|
-
args: { initialHtml: RICH_DOCUMENT },
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export const PlainDraft: Story = {
|
|
92
|
-
name: "Plain, reopened from Markdown",
|
|
93
|
-
args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Cancel changes nothing: the mode stays rich, the document is untouched, and
|
|
98
|
-
* focus comes back to the control that was pressed. `aria-pressed` never flips
|
|
99
|
-
* optimistically.
|
|
100
|
-
*/
|
|
101
|
-
export const WarningCancelled: Story = {
|
|
102
|
-
name: "The warning, cancelled",
|
|
103
|
-
args: { initialHtml: RICH_DOCUMENT },
|
|
104
|
-
play: async ({ canvasElement }) => {
|
|
105
|
-
const toggle = toggleOf(canvasElement);
|
|
106
|
-
await userEvent.click(toggle);
|
|
107
|
-
|
|
108
|
-
const dialog = within(document.body).getByRole("dialog");
|
|
109
|
-
await expect(dialog).toHaveTextContent("Switch to plain text?");
|
|
110
|
-
await expect(dialog).toHaveTextContent(
|
|
111
|
-
"Formatting becomes Markdown. Bold keeps its asterisks, a table becomes rows of pipes, and that text is what the recipient gets. No formatted version is sent alongside it.",
|
|
112
|
-
);
|
|
113
|
-
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
114
|
-
|
|
115
|
-
await userEvent.click(
|
|
116
|
-
within(dialog).getByRole("button", { name: "Cancel" }),
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
await expect(plainSurface(canvasElement)).toBeNull();
|
|
120
|
-
await expect(
|
|
121
|
-
canvasElement.querySelector("[data-testid=compose-body] table"),
|
|
122
|
-
).not.toBeNull();
|
|
123
|
-
await expect(toggleOf(canvasElement)).toHaveFocus();
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export const WarningConfirmed: Story = {
|
|
128
|
-
name: "The warning, confirmed",
|
|
129
|
-
args: { initialHtml: RICH_DOCUMENT },
|
|
130
|
-
play: async ({ canvasElement }) => {
|
|
131
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
132
|
-
await userEvent.click(
|
|
133
|
-
within(within(document.body).getByRole("dialog")).getByRole("button", {
|
|
134
|
-
name: "Switch to plain text",
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
const textarea = plainSurface(canvasElement);
|
|
139
|
-
if (!textarea) throw new Error("the plain surface did not arrive");
|
|
140
|
-
await expect(textarea.value).toContain("## Quarterly numbers");
|
|
141
|
-
await expect(textarea.value).toContain("**up**");
|
|
142
|
-
await expect(textarea.value).toContain("| EMEA | 412 |");
|
|
143
|
-
|
|
144
|
-
// The formatting buttons leave with the rich surface.
|
|
145
|
-
await expect(
|
|
146
|
-
canvasElement.querySelector("[aria-label='Bold (Ctrl+B)']"),
|
|
147
|
-
).toBeNull();
|
|
148
|
-
await expect(toggleOf(canvasElement)).toHaveAttribute(
|
|
149
|
-
"aria-pressed",
|
|
150
|
-
"true",
|
|
151
|
-
);
|
|
152
|
-
await expect(textarea).toHaveFocus();
|
|
153
|
-
await expect(textarea.selectionStart).toBe(textarea.value.length);
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
/** Nothing but paragraphs and a blank line: switching changes nothing, so nothing is asked. */
|
|
158
|
-
export const PlainProseSwitchesSilently: Story = {
|
|
159
|
-
name: "Plain paragraphs switch without asking",
|
|
160
|
-
args: { initialHtml: PLAIN_PARAGRAPHS },
|
|
161
|
-
play: async ({ canvasElement }) => {
|
|
162
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
163
|
-
|
|
164
|
-
await expect(within(document.body).queryByRole("dialog")).toBeNull();
|
|
165
|
-
const textarea = plainSurface(canvasElement);
|
|
166
|
-
if (!textarea) throw new Error("the plain surface did not arrive");
|
|
167
|
-
await expect(textarea.value).toContain("Thanks");
|
|
168
|
-
await expect(textarea.value).toContain("See you then.");
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* An underlined word exports identical to its own characters, so a comparison
|
|
174
|
-
* of the two strings would switch in silence and destroy it. The rule reads the
|
|
175
|
-
* document instead.
|
|
176
|
-
*/
|
|
177
|
-
export const UnderlineStillWarns: Story = {
|
|
178
|
-
name: "An underline alone still warns",
|
|
179
|
-
args: { initialHtml: UNDERLINED },
|
|
180
|
-
play: async ({ canvasElement }) => {
|
|
181
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
182
|
-
|
|
183
|
-
await expect(within(document.body).getByRole("dialog")).toHaveTextContent(
|
|
184
|
-
"Switch to plain text?",
|
|
185
|
-
);
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
export const PlainToRich: Story = {
|
|
190
|
-
name: "Markdown back to rich, without asking",
|
|
191
|
-
args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
|
|
192
|
-
play: async ({ canvasElement }) => {
|
|
193
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
194
|
-
|
|
195
|
-
await expect(within(document.body).queryByRole("dialog")).toBeNull();
|
|
196
|
-
const editable = canvasElement.querySelector("[data-testid=compose-body]");
|
|
197
|
-
if (!editable) throw new Error("the rich surface did not arrive");
|
|
198
|
-
await expect(editable.querySelector("h2")).not.toBeNull();
|
|
199
|
-
await expect(editable.querySelector("table td")).not.toBeNull();
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
/** Switching an ordinary note to rich must not reflow it. */
|
|
204
|
-
export const PlainProseToRich: Story = {
|
|
205
|
-
name: "Prose with no Markdown in it",
|
|
206
|
-
args: {
|
|
207
|
-
startIn: "plain",
|
|
208
|
-
initialText: "Thanks — that works.\n\nI'll send the deck tomorrow.",
|
|
209
|
-
},
|
|
210
|
-
play: async ({ canvasElement }) => {
|
|
211
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
212
|
-
|
|
213
|
-
const editable = canvasElement.querySelector("[data-testid=compose-body]");
|
|
214
|
-
if (!editable) throw new Error("the rich surface did not arrive");
|
|
215
|
-
await expect(editable.querySelectorAll("p").length).toBe(2);
|
|
216
|
-
await expect(editable.textContent).toContain("Thanks — that works.");
|
|
217
|
-
await expect(editable.textContent).toContain(
|
|
218
|
-
"I'll send the deck tomorrow.",
|
|
219
|
-
);
|
|
220
|
-
},
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* A conversion that would blank a written message does not happen: autosave
|
|
225
|
-
* would persist the empty body a moment later and the draft would be gone with
|
|
226
|
-
* nothing said.
|
|
227
|
-
*/
|
|
228
|
-
export const ConversionCameBackEmpty: Story = {
|
|
229
|
-
name: "A conversion that came back empty",
|
|
230
|
-
args: {
|
|
231
|
-
startIn: "plain",
|
|
232
|
-
initialText: "Everything I wrote this morning.",
|
|
233
|
-
onConversionError: fn(),
|
|
234
|
-
conversions: {
|
|
235
|
-
toPlain: (value) => value.text,
|
|
236
|
-
toRich: () => "",
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
play: async ({ args, canvasElement }) => {
|
|
240
|
-
await userEvent.click(toggleOf(canvasElement));
|
|
241
|
-
|
|
242
|
-
await expect(args.onConversionError).toHaveBeenCalledWith({
|
|
243
|
-
outcome: "blocked",
|
|
244
|
-
title: "Couldn't switch to rich text",
|
|
245
|
-
detail: "The conversion came back empty, so your message is unchanged.",
|
|
246
|
-
});
|
|
247
|
-
const textarea = plainSurface(canvasElement);
|
|
248
|
-
if (!textarea) throw new Error("the plain surface left");
|
|
249
|
-
await expect(textarea.value).toBe("Everything I wrote this morning.");
|
|
250
|
-
await expect(toggleOf(canvasElement)).toHaveAttribute(
|
|
251
|
-
"aria-pressed",
|
|
252
|
-
"true",
|
|
253
|
-
);
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
/** One Shift+Tab out of the body reaches the toggle, and Enter acts. */
|
|
258
|
-
export const ReachableFromTheBody: Story = {
|
|
259
|
-
name: "Shift+Tab from the body reaches it",
|
|
260
|
-
args: { initialHtml: PLAIN_PARAGRAPHS },
|
|
261
|
-
play: async ({ canvasElement }) => {
|
|
262
|
-
const editable = canvasElement.querySelector<HTMLElement>(
|
|
263
|
-
"[data-testid=compose-body]",
|
|
264
|
-
);
|
|
265
|
-
if (!editable) throw new Error("the rich surface is not mounted");
|
|
266
|
-
|
|
267
|
-
await userEvent.click(editable);
|
|
268
|
-
await userEvent.tab({ shift: true });
|
|
269
|
-
await expect(toggleOf(canvasElement)).toHaveFocus();
|
|
270
|
-
|
|
271
|
-
await userEvent.keyboard("{Enter}");
|
|
272
|
-
await expect(plainSurface(canvasElement)).not.toBeNull();
|
|
273
|
-
},
|
|
274
|
-
};
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ComposeBodyMode,
|
|
3
|
-
ComposeModeToggle,
|
|
4
|
-
markdownToHtml,
|
|
5
|
-
PlainTextEditor,
|
|
6
|
-
RichTextEditor,
|
|
7
|
-
type RichTextValue,
|
|
8
|
-
} from "@remit/ui/rich-text";
|
|
9
|
-
import { useRef, useState } from "react";
|
|
10
|
-
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
|
11
|
-
import { conversionOutcome, switchNeedsWarning } from "./compose-mode";
|
|
12
|
-
|
|
13
|
-
export interface ConversionFailure {
|
|
14
|
-
title: string;
|
|
15
|
-
detail: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The two directions of the mode switch, injectable so a story can drive the
|
|
20
|
-
* conversion that comes back empty — the branch that keeps autosave from
|
|
21
|
-
* persisting a blanked draft, and the one case no real document produces on
|
|
22
|
-
* demand.
|
|
23
|
-
*/
|
|
24
|
-
export interface ComposeConversions {
|
|
25
|
-
toPlain: (value: RichTextValue) => string;
|
|
26
|
-
toRich: (text: string) => string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const DEFAULT_COMPOSE_CONVERSIONS: ComposeConversions = {
|
|
30
|
-
toPlain: (value) => value.text,
|
|
31
|
-
toRich: (text) => markdownToHtml(text),
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const textOf = (html: string): string =>
|
|
35
|
-
new DOMParser().parseFromString(html, "text/html").body.textContent ?? "";
|
|
36
|
-
|
|
37
|
-
const plainValue = (text: string): RichTextValue => ({
|
|
38
|
-
html: "",
|
|
39
|
-
text,
|
|
40
|
-
formatting: [],
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
interface ComposeBodyProps {
|
|
44
|
-
mode: ComposeBodyMode;
|
|
45
|
-
onModeChange: (mode: ComposeBodyMode) => void;
|
|
46
|
-
initialHtml: string;
|
|
47
|
-
initialText: string;
|
|
48
|
-
onChange: (value: RichTextValue) => void;
|
|
49
|
-
onSubmit?: () => void;
|
|
50
|
-
autoFocus?: boolean;
|
|
51
|
-
onConversionError: (failure: ConversionFailure) => void;
|
|
52
|
-
conversions?: ComposeConversions;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The compose writing surface and the control that swaps it. Rich text is the
|
|
57
|
-
* WYSIWYG document; plain text is a textarea whose content is the Markdown that
|
|
58
|
-
* will be sent verbatim. The conversion runs over an in-memory document, so the
|
|
59
|
-
* surface swaps in the same frame the choice is made.
|
|
60
|
-
*/
|
|
61
|
-
export const ComposeBody = ({
|
|
62
|
-
mode,
|
|
63
|
-
onModeChange,
|
|
64
|
-
initialHtml,
|
|
65
|
-
initialText,
|
|
66
|
-
onChange,
|
|
67
|
-
onSubmit,
|
|
68
|
-
autoFocus = false,
|
|
69
|
-
onConversionError,
|
|
70
|
-
conversions = DEFAULT_COMPOSE_CONVERSIONS,
|
|
71
|
-
}: ComposeBodyProps) => {
|
|
72
|
-
const [richHtml, setRichHtml] = useState(initialHtml);
|
|
73
|
-
const [richGeneration, setRichGeneration] = useState(0);
|
|
74
|
-
const [plainText, setPlainText] = useState(initialText);
|
|
75
|
-
const [confirming, setConfirming] = useState(false);
|
|
76
|
-
// The caret does not survive a conversion: a rich selection is a node path
|
|
77
|
-
// and Markdown is a character offset. The surface that arrives takes focus
|
|
78
|
-
// with the caret at the end; the toggle keeps it when the mode did not change.
|
|
79
|
-
const [focusSwitchedSurface, setFocusSwitchedSurface] = useState(false);
|
|
80
|
-
const richValue = useRef<RichTextValue>({
|
|
81
|
-
html: initialHtml,
|
|
82
|
-
text: initialText,
|
|
83
|
-
formatting: [],
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const handleRichChange = (value: RichTextValue) => {
|
|
87
|
-
richValue.current = value;
|
|
88
|
-
onChange(value);
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const handlePlainChange = (text: string) => {
|
|
92
|
-
setPlainText(text);
|
|
93
|
-
onChange(plainValue(text));
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const switchToPlain = () => {
|
|
97
|
-
const value = richValue.current;
|
|
98
|
-
const converted = conversions.toPlain(value);
|
|
99
|
-
const decision = conversionOutcome("plain", textOf(value.html), converted);
|
|
100
|
-
if (decision.outcome === "blocked") {
|
|
101
|
-
onConversionError(decision);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
setPlainText(converted);
|
|
105
|
-
setFocusSwitchedSurface(true);
|
|
106
|
-
onChange(plainValue(converted));
|
|
107
|
-
onModeChange("plain");
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const switchToRich = () => {
|
|
111
|
-
const converted = conversions.toRich(plainText);
|
|
112
|
-
const decision = conversionOutcome("rich", plainText, textOf(converted));
|
|
113
|
-
if (decision.outcome === "blocked") {
|
|
114
|
-
onConversionError(decision);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
setRichHtml(converted);
|
|
118
|
-
setRichGeneration((generation) => generation + 1);
|
|
119
|
-
setFocusSwitchedSurface(true);
|
|
120
|
-
onModeChange("rich");
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const handleToggle = () => {
|
|
124
|
-
if (mode === "plain") {
|
|
125
|
-
switchToRich();
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (switchNeedsWarning("plain", richValue.current.formatting)) {
|
|
129
|
-
setConfirming(true);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
switchToPlain();
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const toggle = <ComposeModeToggle mode={mode} onToggle={handleToggle} />;
|
|
136
|
-
|
|
137
|
-
return (
|
|
138
|
-
<>
|
|
139
|
-
{mode === "plain" ? (
|
|
140
|
-
<PlainTextEditor
|
|
141
|
-
value={plainText}
|
|
142
|
-
onChange={handlePlainChange}
|
|
143
|
-
onSubmit={onSubmit}
|
|
144
|
-
autoFocus={focusSwitchedSurface}
|
|
145
|
-
trailing={toggle}
|
|
146
|
-
/>
|
|
147
|
-
) : (
|
|
148
|
-
<RichTextEditor
|
|
149
|
-
key={richGeneration}
|
|
150
|
-
initialHtml={richHtml}
|
|
151
|
-
onChange={handleRichChange}
|
|
152
|
-
onSubmit={onSubmit}
|
|
153
|
-
autoFocus={autoFocus || focusSwitchedSurface}
|
|
154
|
-
trailing={toggle}
|
|
155
|
-
/>
|
|
156
|
-
)}
|
|
157
|
-
<ConfirmDialog
|
|
158
|
-
isOpen={confirming}
|
|
159
|
-
title="Switch to plain text?"
|
|
160
|
-
description="Formatting becomes Markdown. Bold keeps its asterisks, a table becomes rows of pipes, and that text is what the recipient gets. No formatted version is sent alongside it."
|
|
161
|
-
confirmLabel="Switch to plain text"
|
|
162
|
-
onConfirm={() => {
|
|
163
|
-
setConfirming(false);
|
|
164
|
-
switchToPlain();
|
|
165
|
-
}}
|
|
166
|
-
onCancel={() => setConfirming(false)}
|
|
167
|
-
/>
|
|
168
|
-
</>
|
|
169
|
-
);
|
|
170
|
-
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
interface SubjectFieldProps {
|
|
2
|
-
value: string;
|
|
3
|
-
onChange: (value: string) => void;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export const SubjectField = ({ value, onChange }: SubjectFieldProps) => (
|
|
7
|
-
<div className="flex items-start gap-2">
|
|
8
|
-
{/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
|
|
9
|
-
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
|
|
10
|
-
<input
|
|
11
|
-
type="text"
|
|
12
|
-
value={value}
|
|
13
|
-
onChange={(e) => onChange(e.target.value)}
|
|
14
|
-
className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
|
|
15
|
-
placeholder="Subject"
|
|
16
|
-
data-subject-field
|
|
17
|
-
/>
|
|
18
|
-
</div>
|
|
19
|
-
);
|