@remit/ui 0.0.48 → 0.0.50
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/filter-rule-editor.stories.tsx +187 -6
- package/src/components/filter-rule-editor.tsx +88 -45
- package/src/components/filter-rule.render.test.ts +28 -15
- package/src/components/move-mailbox-picker.stories.tsx +199 -0
- package/src/components/move-mailbox-picker.tsx +19 -4
- package/src/lib/abort.ts +11 -0
package/package.json
CHANGED
|
@@ -55,7 +55,10 @@ function LiveEditor({
|
|
|
55
55
|
}: {
|
|
56
56
|
initialRule: FilterRule;
|
|
57
57
|
semanticAvailable?: boolean;
|
|
58
|
-
onCreateFolder?: (
|
|
58
|
+
onCreateFolder?: (
|
|
59
|
+
name: string,
|
|
60
|
+
signal?: AbortSignal,
|
|
61
|
+
) => Promise<FolderOption>;
|
|
59
62
|
}) {
|
|
60
63
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
61
64
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
@@ -170,6 +173,182 @@ export const WithNewFolderOption: Story = {
|
|
|
170
173
|
),
|
|
171
174
|
};
|
|
172
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Drive the destination field into its create sub-form: pick "+ New folder…",
|
|
178
|
+
* type a name, and press "Create folder". Used by the pending and error stories
|
|
179
|
+
* below so each lands in the state it documents without a manual click-through.
|
|
180
|
+
*/
|
|
181
|
+
async function openCreateAndSubmit(
|
|
182
|
+
canvasElement: HTMLElement,
|
|
183
|
+
folderName: string,
|
|
184
|
+
) {
|
|
185
|
+
const setSelectValue = Object.getOwnPropertyDescriptor(
|
|
186
|
+
HTMLSelectElement.prototype,
|
|
187
|
+
"value",
|
|
188
|
+
)?.set;
|
|
189
|
+
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
190
|
+
HTMLInputElement.prototype,
|
|
191
|
+
"value",
|
|
192
|
+
)?.set;
|
|
193
|
+
const select = canvasElement.querySelector<HTMLSelectElement>(
|
|
194
|
+
'select[aria-label="Destination folder"]',
|
|
195
|
+
);
|
|
196
|
+
if (!select) return;
|
|
197
|
+
setSelectValue?.call(select, CREATE_FOLDER_STORY_VALUE);
|
|
198
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
199
|
+
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
200
|
+
'input[aria-label="New folder name"]',
|
|
201
|
+
);
|
|
202
|
+
if (!input) return;
|
|
203
|
+
setInputValue?.call(input, folderName);
|
|
204
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
205
|
+
const createButton = Array.from(
|
|
206
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
207
|
+
).find((button) => button.textContent?.trim() === "Create folder");
|
|
208
|
+
createButton?.click();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Matches the internal CREATE_FOLDER_VALUE option in the destination select. */
|
|
212
|
+
const CREATE_FOLDER_STORY_VALUE = "__filter_create_folder__";
|
|
213
|
+
|
|
214
|
+
/** Mirrors the web-client wait's honest timeout copy. */
|
|
215
|
+
const TIMEOUT_MESSAGE =
|
|
216
|
+
"The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
|
|
217
|
+
|
|
218
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
219
|
+
|
|
220
|
+
const neverResolvesCreateFolder = (): Promise<FolderOption> =>
|
|
221
|
+
new Promise<FolderOption>(() => undefined);
|
|
222
|
+
|
|
223
|
+
const rejectingCreateFolder = (message: string) => (): Promise<FolderOption> =>
|
|
224
|
+
Promise.reject(new Error(message));
|
|
225
|
+
|
|
226
|
+
/** Rejects the first attempt, resolves the retry — the resume the hook performs. */
|
|
227
|
+
const failThenSucceedCreateFolder = () => {
|
|
228
|
+
let attempts = 0;
|
|
229
|
+
return (name: string): Promise<FolderOption> => {
|
|
230
|
+
attempts += 1;
|
|
231
|
+
return attempts === 1
|
|
232
|
+
? Promise.reject(new Error(TIMEOUT_MESSAGE))
|
|
233
|
+
: Promise.resolve({ id: "mbx-created", label: name });
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Never resolves on its own; rejects with an AbortError when the signal aborts. */
|
|
238
|
+
const abortAwareCreateFolder = (
|
|
239
|
+
_name: string,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): Promise<FolderOption> =>
|
|
242
|
+
new Promise<FolderOption>((_resolve, reject) => {
|
|
243
|
+
signal?.addEventListener("abort", () =>
|
|
244
|
+
reject(new DOMException("Aborted", "AbortError")),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The folder is a dependent write for the filter, so creating it waits for the
|
|
250
|
+
* mail server to confirm the folder before it can be picked as the destination.
|
|
251
|
+
* The wait shows as "Creating folder…" — held for the whole confirmation, not
|
|
252
|
+
* just a fast optimistic round-trip.
|
|
253
|
+
*/
|
|
254
|
+
export const NewFolderCreating: Story = {
|
|
255
|
+
name: "New folder — creating (waiting for the server)",
|
|
256
|
+
render: () => (
|
|
257
|
+
<LiveEditor
|
|
258
|
+
initialRule={demoRule}
|
|
259
|
+
onCreateFolder={neverResolvesCreateFolder}
|
|
260
|
+
/>
|
|
261
|
+
),
|
|
262
|
+
play: async ({ canvasElement }) => {
|
|
263
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The folder create failed on the mail server. The rule is not committed against
|
|
269
|
+
* a folder that does not exist: the error is surfaced inline with the create form
|
|
270
|
+
* still open, so the create can be retried or cancelled.
|
|
271
|
+
*/
|
|
272
|
+
export const NewFolderCreateFailed: Story = {
|
|
273
|
+
name: "New folder — create failed (retry / cancel)",
|
|
274
|
+
render: () => (
|
|
275
|
+
<LiveEditor
|
|
276
|
+
initialRule={demoRule}
|
|
277
|
+
onCreateFolder={rejectingCreateFolder(
|
|
278
|
+
"The folder couldn't be created on the mail server. Please try again.",
|
|
279
|
+
)}
|
|
280
|
+
/>
|
|
281
|
+
),
|
|
282
|
+
play: async ({ canvasElement }) => {
|
|
283
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The folder create was never confirmed within the wait bound. Distinct from a
|
|
289
|
+
* hard failure — the message names the timeout — and, like a failure, leaves no
|
|
290
|
+
* folder selected, so no filter is written against it.
|
|
291
|
+
*/
|
|
292
|
+
export const NewFolderCreateTimedOut: Story = {
|
|
293
|
+
name: "New folder — create timed out (retry / cancel)",
|
|
294
|
+
render: () => (
|
|
295
|
+
<LiveEditor
|
|
296
|
+
initialRule={demoRule}
|
|
297
|
+
onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
|
|
298
|
+
/>
|
|
299
|
+
),
|
|
300
|
+
play: async ({ canvasElement }) => {
|
|
301
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Retry is a resume: the first attempt times out (the folder was made but not yet
|
|
307
|
+
* confirmed), and pressing "Create folder" again with the same name resolves —
|
|
308
|
+
* the hook re-waits on the folder it already made rather than re-creating it, so
|
|
309
|
+
* the retry the failure message points at actually works.
|
|
310
|
+
*/
|
|
311
|
+
export const NewFolderCreateRetrySucceeds: Story = {
|
|
312
|
+
name: "New folder — retry resumes and succeeds",
|
|
313
|
+
render: () => (
|
|
314
|
+
<LiveEditor
|
|
315
|
+
initialRule={demoRule}
|
|
316
|
+
onCreateFolder={failThenSucceedCreateFolder()}
|
|
317
|
+
/>
|
|
318
|
+
),
|
|
319
|
+
play: async ({ canvasElement }) => {
|
|
320
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
321
|
+
await tick();
|
|
322
|
+
const retry = Array.from(
|
|
323
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
324
|
+
).find((button) => button.textContent?.trim() === "Create folder");
|
|
325
|
+
retry?.click();
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Cancelling while "Creating folder…" is in flight aborts the wait: the create
|
|
331
|
+
* promise rejects with an AbortError the field swallows, so no destination binds
|
|
332
|
+
* after the user backed out — the sub-form just closes.
|
|
333
|
+
*/
|
|
334
|
+
export const NewFolderCreateCancelledMidWait: Story = {
|
|
335
|
+
name: "New folder — cancel aborts the wait",
|
|
336
|
+
render: () => (
|
|
337
|
+
<LiveEditor
|
|
338
|
+
initialRule={demoRule}
|
|
339
|
+
onCreateFolder={abortAwareCreateFolder}
|
|
340
|
+
/>
|
|
341
|
+
),
|
|
342
|
+
play: async ({ canvasElement }) => {
|
|
343
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
344
|
+
await tick();
|
|
345
|
+
const cancel = Array.from(
|
|
346
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
347
|
+
).find((button) => button.textContent?.trim() === "Cancel");
|
|
348
|
+
cancel?.click();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
173
352
|
/** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
|
|
174
353
|
export const AnyOfTheseClauses: Story = {
|
|
175
354
|
render: () => <LiveEditor initialRule={demoVocabularyRule} />,
|
|
@@ -254,11 +433,13 @@ export const DegradedStandingWiden: Story = {
|
|
|
254
433
|
};
|
|
255
434
|
|
|
256
435
|
/**
|
|
257
|
-
* Editing a persisted filter (RFC 038 D6): scope and expiry
|
|
258
|
-
*
|
|
259
|
-
*
|
|
436
|
+
* Editing a persisted filter (RFC 038 D6, reader #266): scope and expiry stay
|
|
437
|
+
* live and editable — a standing filter can move to "until a date" and back,
|
|
438
|
+
* or its date can change. The semantic anchor is the one thing fixed at
|
|
439
|
+
* creation: the widen chip renders display-only with a one-line note, and
|
|
440
|
+
* "Just once" drops out of the scope toggle since no saved filter can hold it.
|
|
260
441
|
*/
|
|
261
|
-
export const
|
|
442
|
+
export const AnchorLocked: Story = {
|
|
262
443
|
args: {
|
|
263
444
|
rule: {
|
|
264
445
|
...demoRule,
|
|
@@ -269,7 +450,7 @@ export const LifecycleLocked: Story = {
|
|
|
269
450
|
folders: demoFolders,
|
|
270
451
|
preview: READY(31),
|
|
271
452
|
semanticAvailable: false,
|
|
272
|
-
|
|
453
|
+
anchorLocked: true,
|
|
273
454
|
},
|
|
274
455
|
};
|
|
275
456
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Fragment,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { isAbortError } from "../lib/abort.js";
|
|
2
10
|
import { BottomSheet } from "./bottom-sheet.js";
|
|
3
11
|
import { Button } from "./button.js";
|
|
4
12
|
import { Dialog } from "./dialog.js";
|
|
@@ -56,14 +64,20 @@ export interface FilterRuleEditorProps {
|
|
|
56
64
|
*/
|
|
57
65
|
clauseFields?: ClauseField[];
|
|
58
66
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* Whether the semantic anchor is fixed and cannot be added, removed, or
|
|
68
|
+
* repointed (RFC 038 D6, reader #266). True for an existing, persisted
|
|
69
|
+
* filter — the update endpoint carries no anchor field at all, so
|
|
70
|
+
* repointing one would silently change what a saved filter matches with
|
|
71
|
+
* nothing visible changing, and that deserves a new filter instead. The
|
|
72
|
+
* widen chip renders display-only (no remove affordance regardless of
|
|
73
|
+
* `onRemoveWiden`) and carries a one-line note explaining why.
|
|
74
|
+
*
|
|
75
|
+
* Scope and expiry are NOT covered by this flag — they stay editable even
|
|
76
|
+
* on an existing filter (reader #266). The only other thing this locks is
|
|
77
|
+
* the "Just once" scope option, meaningless for an already-persisted
|
|
78
|
+
* filter, which is dropped from the scope toggle.
|
|
65
79
|
*/
|
|
66
|
-
|
|
80
|
+
anchorLocked?: boolean;
|
|
67
81
|
/** The inline clause form, when adding or editing a clause. */
|
|
68
82
|
clauseEdit?: ClauseEditState;
|
|
69
83
|
onStartAddClause?: () => void;
|
|
@@ -78,12 +92,16 @@ export interface FilterRuleEditorProps {
|
|
|
78
92
|
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
79
93
|
onChangeMove?: (mailboxId: string) => void;
|
|
80
94
|
/**
|
|
81
|
-
* Create a new destination folder from within the editor. Given a folder
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
95
|
+
* Create a new destination folder from within the editor. Given a folder name
|
|
96
|
+
* and an abort signal, resolves to the created folder once the mail server
|
|
97
|
+
* confirms it. The editor aborts the signal on unmount or cancel. When absent,
|
|
98
|
+
* the "New folder…" option is not offered — the editor stays data-agnostic, so
|
|
99
|
+
* stories and consumers without wiring render unchanged.
|
|
85
100
|
*/
|
|
86
|
-
onCreateFolder?: (
|
|
101
|
+
onCreateFolder?: (
|
|
102
|
+
name: string,
|
|
103
|
+
signal?: AbortSignal,
|
|
104
|
+
) => Promise<FolderOption>;
|
|
87
105
|
onChangeScope?: (scope: RuleScope) => void;
|
|
88
106
|
onChangeName?: (name: string) => void;
|
|
89
107
|
onChangeUntil?: (date: string) => void;
|
|
@@ -110,6 +128,13 @@ const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
|
110
128
|
* name field; on resolve the new folder is added to the local option set (so it
|
|
111
129
|
* is selectable even before the caller's folder list refetches) and picked as
|
|
112
130
|
* the destination. Without `onCreateFolder` this is the bare select.
|
|
131
|
+
*
|
|
132
|
+
* The destination is a dependent write: the filter this editor commits binds to
|
|
133
|
+
* the folder, so `onCreateFolder` resolves only once the folder is confirmed on
|
|
134
|
+
* the mail server, not when the create is merely queued. The pending state holds
|
|
135
|
+
* "Creating folder…" for that whole wait, and a create that fails or never
|
|
136
|
+
* confirms rejects with its own message here — the folder is never selected, so
|
|
137
|
+
* the caller cannot commit a filter against a folder that does not exist.
|
|
113
138
|
*/
|
|
114
139
|
function MoveDestinationField({
|
|
115
140
|
folders,
|
|
@@ -120,13 +145,21 @@ function MoveDestinationField({
|
|
|
120
145
|
folders: FolderOption[];
|
|
121
146
|
value: string;
|
|
122
147
|
onChangeMove?: (mailboxId: string) => void;
|
|
123
|
-
onCreateFolder?: (
|
|
148
|
+
onCreateFolder?: (
|
|
149
|
+
name: string,
|
|
150
|
+
signal?: AbortSignal,
|
|
151
|
+
) => Promise<FolderOption>;
|
|
124
152
|
}) {
|
|
125
153
|
const [creating, setCreating] = useState(false);
|
|
126
154
|
const [name, setName] = useState("");
|
|
127
155
|
const [pending, setPending] = useState(false);
|
|
128
156
|
const [error, setError] = useState<string>();
|
|
129
157
|
const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
|
|
158
|
+
// The create waits for the mail server to confirm the folder; abort it on
|
|
159
|
+
// unmount or cancel so a late confirmation never binds the destination after
|
|
160
|
+
// the editor is gone or the sub-form dismissed.
|
|
161
|
+
const createAbort = useRef<AbortController | null>(null);
|
|
162
|
+
useEffect(() => () => createAbort.current?.abort(), []);
|
|
130
163
|
|
|
131
164
|
const options = useMemo(() => {
|
|
132
165
|
const known = new Set(folders.map((folder) => folder.id));
|
|
@@ -151,7 +184,10 @@ function MoveDestinationField({
|
|
|
151
184
|
if (trimmed === "") return;
|
|
152
185
|
setPending(true);
|
|
153
186
|
setError(undefined);
|
|
154
|
-
|
|
187
|
+
createAbort.current?.abort();
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
createAbort.current = controller;
|
|
190
|
+
onCreateFolder(trimmed, controller.signal)
|
|
155
191
|
.then((folder) => {
|
|
156
192
|
setCreatedFolders((prev) =>
|
|
157
193
|
prev.some((entry) => entry.id === folder.id)
|
|
@@ -164,6 +200,7 @@ function MoveDestinationField({
|
|
|
164
200
|
setPending(false);
|
|
165
201
|
})
|
|
166
202
|
.catch((error: unknown) => {
|
|
203
|
+
if (isAbortError(error)) return;
|
|
167
204
|
setError(
|
|
168
205
|
error instanceof Error
|
|
169
206
|
? error.message
|
|
@@ -174,9 +211,11 @@ function MoveDestinationField({
|
|
|
174
211
|
};
|
|
175
212
|
|
|
176
213
|
const cancel = () => {
|
|
214
|
+
createAbort.current?.abort();
|
|
177
215
|
setCreating(false);
|
|
178
216
|
setName("");
|
|
179
217
|
setError(undefined);
|
|
218
|
+
setPending(false);
|
|
180
219
|
};
|
|
181
220
|
|
|
182
221
|
return (
|
|
@@ -228,7 +267,7 @@ function MoveDestinationField({
|
|
|
228
267
|
onClick={submit}
|
|
229
268
|
disabled={pending || name.trim() === ""}
|
|
230
269
|
>
|
|
231
|
-
{pending ? "Creating…" : "Create folder"}
|
|
270
|
+
{pending ? "Creating folder…" : "Create folder"}
|
|
232
271
|
</Button>
|
|
233
272
|
<Button
|
|
234
273
|
variant="ghost"
|
|
@@ -252,7 +291,7 @@ export function FilterRuleEditor({
|
|
|
252
291
|
notice,
|
|
253
292
|
semanticAvailable = false,
|
|
254
293
|
clauseFields,
|
|
255
|
-
|
|
294
|
+
anchorLocked = false,
|
|
256
295
|
clauseEdit,
|
|
257
296
|
onStartAddClause,
|
|
258
297
|
onStartEditClause,
|
|
@@ -278,6 +317,12 @@ export function FilterRuleEditor({
|
|
|
278
317
|
const join = matchJoinWord(rule.matchOperator);
|
|
279
318
|
const blockedReason = commitBlockedReason(rule, preview);
|
|
280
319
|
const needsName = rule.scope === "standing" || rule.scope === "until";
|
|
320
|
+
// A persisted filter is always Standing or Temporary — "once" was never a
|
|
321
|
+
// scope a saved row could hold, so an existing filter's editor never offers
|
|
322
|
+
// it (reader #266).
|
|
323
|
+
const availableScopeOptions = anchorLocked
|
|
324
|
+
? scopeOptions.filter((option) => option.value !== "once")
|
|
325
|
+
: scopeOptions;
|
|
281
326
|
|
|
282
327
|
return (
|
|
283
328
|
<div className="flex min-h-0 flex-col">
|
|
@@ -314,16 +359,22 @@ export function FilterRuleEditor({
|
|
|
314
359
|
{join}
|
|
315
360
|
</span>
|
|
316
361
|
)}
|
|
317
|
-
<WidenChip
|
|
362
|
+
<WidenChip
|
|
363
|
+
widen={rule.widen}
|
|
364
|
+
onRemove={anchorLocked ? undefined : onRemoveWiden}
|
|
365
|
+
/>
|
|
318
366
|
</>
|
|
319
367
|
)}
|
|
320
368
|
|
|
321
369
|
{!clauseEdit && (
|
|
322
370
|
<AddChipButton label="Add clause" onClick={onStartAddClause} />
|
|
323
371
|
)}
|
|
324
|
-
{!rule.widen &&
|
|
325
|
-
|
|
326
|
-
|
|
372
|
+
{!rule.widen &&
|
|
373
|
+
!anchorLocked &&
|
|
374
|
+
semanticAvailable &&
|
|
375
|
+
!clauseEdit && (
|
|
376
|
+
<AddChipButton label="…and similar" onClick={onAddWiden} />
|
|
377
|
+
)}
|
|
327
378
|
</div>
|
|
328
379
|
|
|
329
380
|
{clauseEdit && (
|
|
@@ -338,6 +389,13 @@ export function FilterRuleEditor({
|
|
|
338
389
|
/>
|
|
339
390
|
)}
|
|
340
391
|
|
|
392
|
+
{anchorLocked && rule.widen && (
|
|
393
|
+
<p className="text-2xs text-fg-subtle">
|
|
394
|
+
This filter's similar-mail match is fixed to the message it was
|
|
395
|
+
created from — create a new filter to change it.
|
|
396
|
+
</p>
|
|
397
|
+
)}
|
|
398
|
+
|
|
341
399
|
{showOperator && (
|
|
342
400
|
<div className="flex items-center gap-2">
|
|
343
401
|
<span className="text-xs text-fg-muted">Match</span>
|
|
@@ -374,22 +432,14 @@ export function FilterRuleEditor({
|
|
|
374
432
|
|
|
375
433
|
<section className="space-y-2">
|
|
376
434
|
<p className="text-xs font-medium text-fg-muted">How long</p>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
name="rule-scope"
|
|
386
|
-
size="sm"
|
|
387
|
-
aria-label="Rule scope"
|
|
388
|
-
options={scopeOptions}
|
|
389
|
-
value={rule.scope}
|
|
390
|
-
onChange={(value) => onChangeScope?.(value)}
|
|
391
|
-
/>
|
|
392
|
-
)}
|
|
435
|
+
<SegmentedControl
|
|
436
|
+
name="rule-scope"
|
|
437
|
+
size="sm"
|
|
438
|
+
aria-label="Rule scope"
|
|
439
|
+
options={availableScopeOptions}
|
|
440
|
+
value={rule.scope}
|
|
441
|
+
onChange={(value) => onChangeScope?.(value)}
|
|
442
|
+
/>
|
|
393
443
|
{needsName && (
|
|
394
444
|
<Input
|
|
395
445
|
value={rule.name ?? ""}
|
|
@@ -399,7 +449,7 @@ export function FilterRuleEditor({
|
|
|
399
449
|
className="w-full"
|
|
400
450
|
/>
|
|
401
451
|
)}
|
|
402
|
-
{
|
|
452
|
+
{rule.scope === "until" && (
|
|
403
453
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
404
454
|
<span>Until</span>
|
|
405
455
|
<Input
|
|
@@ -411,13 +461,6 @@ export function FilterRuleEditor({
|
|
|
411
461
|
/>
|
|
412
462
|
</div>
|
|
413
463
|
)}
|
|
414
|
-
{lifecycleLocked && (
|
|
415
|
-
<p className="text-2xs text-fg-subtle">
|
|
416
|
-
{rule.widen
|
|
417
|
-
? "The scope, expiry, and similar-mail match are set when a filter is created."
|
|
418
|
-
: "The scope and expiry are set when a filter is created."}
|
|
419
|
-
</p>
|
|
420
|
-
)}
|
|
421
464
|
</section>
|
|
422
465
|
|
|
423
466
|
<div className="border-t border-line pt-3">
|
|
@@ -555,35 +555,48 @@ describe("FilterRuleEditor", () => {
|
|
|
555
555
|
assert.match(editor(), /47 messages match/);
|
|
556
556
|
});
|
|
557
557
|
|
|
558
|
-
it("
|
|
558
|
+
it("keeps scope and expiry live and editable on an anchor-locked (existing) filter (reader #266)", () => {
|
|
559
559
|
const html = editor({
|
|
560
560
|
rule: { ...demoRule, scope: "until", until: "2027-09-01" },
|
|
561
|
-
|
|
561
|
+
anchorLocked: true,
|
|
562
562
|
});
|
|
563
|
-
|
|
564
|
-
assert.
|
|
565
|
-
assert.doesNotMatch(html, /aria-label="Expiry date"/);
|
|
566
|
-
assert.match(html, /Until 2027-09-01/);
|
|
567
|
-
assert.match(html, /set when a filter is created/);
|
|
568
|
-
// The name stays editable.
|
|
563
|
+
assert.match(html, /aria-label="Rule scope"/);
|
|
564
|
+
assert.match(html, /aria-label="Expiry date"/);
|
|
569
565
|
assert.match(html, /aria-label="Rule name"/);
|
|
570
566
|
});
|
|
571
567
|
|
|
572
|
-
it("
|
|
568
|
+
it("drops the once option from the scope toggle on an anchor-locked filter", () => {
|
|
569
|
+
const locked = editor({ anchorLocked: true });
|
|
570
|
+
assert.doesNotMatch(locked, />Just once</);
|
|
571
|
+
const unlocked = editor({ anchorLocked: false });
|
|
572
|
+
assert.match(unlocked, />Just once</);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("locks the widen chip and explains why only when one is present", () => {
|
|
573
576
|
const withWiden = editor({
|
|
574
577
|
rule: { ...demoRule, widen: { anchorCount: 2 } },
|
|
575
|
-
|
|
578
|
+
anchorLocked: true,
|
|
576
579
|
});
|
|
577
|
-
assert.
|
|
580
|
+
assert.doesNotMatch(
|
|
578
581
|
withWiden,
|
|
579
|
-
/similar-mail
|
|
582
|
+
/aria-label="Remove the similar-mail widen"/,
|
|
580
583
|
);
|
|
584
|
+
assert.match(withWiden, /similar-mail match is fixed to the message/);
|
|
585
|
+
|
|
581
586
|
const literal = editor({
|
|
582
587
|
rule: { ...demoRule, widen: undefined },
|
|
583
|
-
|
|
588
|
+
anchorLocked: true,
|
|
589
|
+
});
|
|
590
|
+
assert.doesNotMatch(literal, /similar-mail match is fixed/);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("never offers to add a widen on an anchor-locked filter, even when the deployment can serve it", () => {
|
|
594
|
+
const html = editor({
|
|
595
|
+
rule: { ...demoRule, widen: undefined },
|
|
596
|
+
anchorLocked: true,
|
|
597
|
+
semanticAvailable: true,
|
|
584
598
|
});
|
|
585
|
-
assert.
|
|
586
|
-
assert.doesNotMatch(literal, /similar-mail match/);
|
|
599
|
+
assert.doesNotMatch(html, /…and similar/);
|
|
587
600
|
});
|
|
588
601
|
});
|
|
589
602
|
|
|
@@ -112,3 +112,202 @@ export const CreateAndMove: Story = {
|
|
|
112
112
|
name: "Create folder from search",
|
|
113
113
|
render: () => <CreatePicker />,
|
|
114
114
|
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Type a folder name into the search box and press the create-and-move row —
|
|
118
|
+
* used by the pending and error stories so each lands in its state without a
|
|
119
|
+
* manual click-through.
|
|
120
|
+
*/
|
|
121
|
+
async function typeAndCreate(canvasElement: HTMLElement, folderName: string) {
|
|
122
|
+
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
123
|
+
HTMLInputElement.prototype,
|
|
124
|
+
"value",
|
|
125
|
+
)?.set;
|
|
126
|
+
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
127
|
+
'input[type="search"]',
|
|
128
|
+
);
|
|
129
|
+
if (!input) return;
|
|
130
|
+
setInputValue?.call(input, folderName);
|
|
131
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
132
|
+
const createButton = Array.from(
|
|
133
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
134
|
+
).find((button) => button.textContent?.includes(`Create "${folderName}"`));
|
|
135
|
+
createButton?.click();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Mirrors the web-client wait's honest timeout copy. */
|
|
139
|
+
const TIMEOUT_MESSAGE =
|
|
140
|
+
"The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
|
|
141
|
+
|
|
142
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
143
|
+
|
|
144
|
+
const neverResolvesCreateFolder = (): Promise<MoveMailboxOption> =>
|
|
145
|
+
new Promise<MoveMailboxOption>(() => undefined);
|
|
146
|
+
|
|
147
|
+
const rejectingCreateFolder =
|
|
148
|
+
(message: string) => (): Promise<MoveMailboxOption> =>
|
|
149
|
+
Promise.reject(new Error(message));
|
|
150
|
+
|
|
151
|
+
/** Rejects the first attempt, resolves the retry — the resume the hook performs. */
|
|
152
|
+
const failThenSucceedCreateFolder = () => {
|
|
153
|
+
let attempts = 0;
|
|
154
|
+
return (name: string): Promise<MoveMailboxOption> => {
|
|
155
|
+
attempts += 1;
|
|
156
|
+
return attempts === 1
|
|
157
|
+
? Promise.reject(new Error(TIMEOUT_MESSAGE))
|
|
158
|
+
: Promise.resolve({ id: "mbx-created", label: name });
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Never resolves on its own; rejects with an AbortError when the signal aborts. */
|
|
163
|
+
const abortAwareCreateFolder = (
|
|
164
|
+
_name: string,
|
|
165
|
+
signal?: AbortSignal,
|
|
166
|
+
): Promise<MoveMailboxOption> =>
|
|
167
|
+
new Promise<MoveMailboxOption>((_resolve, reject) => {
|
|
168
|
+
signal?.addEventListener("abort", () =>
|
|
169
|
+
reject(new DOMException("Aborted", "AbortError")),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The move is a dependent write on the folder: the create-and-move row does not
|
|
175
|
+
* resolve until the mail server confirms the folder, so the move never races the
|
|
176
|
+
* folder into existence. The wait shows as "Creating folder…".
|
|
177
|
+
*/
|
|
178
|
+
export const CreateFolderInFlight: Story = {
|
|
179
|
+
name: "Create folder — waiting for the server",
|
|
180
|
+
render: () => (
|
|
181
|
+
<MoveMailboxPicker
|
|
182
|
+
mailboxes={mailboxes}
|
|
183
|
+
onSelect={() => undefined}
|
|
184
|
+
onCreateFolder={neverResolvesCreateFolder}
|
|
185
|
+
/>
|
|
186
|
+
),
|
|
187
|
+
play: async ({ canvasElement }) => {
|
|
188
|
+
await typeAndCreate(canvasElement, "Taxes");
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The folder create failed on the mail server. No move runs; the error is shown
|
|
194
|
+
* inline and the create row can be pressed again to retry.
|
|
195
|
+
*/
|
|
196
|
+
export const CreateFolderFailed: Story = {
|
|
197
|
+
name: "Create folder — failed (retry)",
|
|
198
|
+
render: () => (
|
|
199
|
+
<MoveMailboxPicker
|
|
200
|
+
mailboxes={mailboxes}
|
|
201
|
+
onSelect={() => undefined}
|
|
202
|
+
onCreateFolder={rejectingCreateFolder(
|
|
203
|
+
"The folder couldn't be created on the mail server. Please try again.",
|
|
204
|
+
)}
|
|
205
|
+
/>
|
|
206
|
+
),
|
|
207
|
+
play: async ({ canvasElement }) => {
|
|
208
|
+
await typeAndCreate(canvasElement, "Taxes");
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The folder create was never confirmed within the wait bound — the timeout is
|
|
214
|
+
* named distinctly, and no move runs.
|
|
215
|
+
*/
|
|
216
|
+
export const CreateFolderTimedOut: Story = {
|
|
217
|
+
name: "Create folder — timed out (retry)",
|
|
218
|
+
render: () => (
|
|
219
|
+
<MoveMailboxPicker
|
|
220
|
+
mailboxes={mailboxes}
|
|
221
|
+
onSelect={() => undefined}
|
|
222
|
+
onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
|
|
223
|
+
/>
|
|
224
|
+
),
|
|
225
|
+
play: async ({ canvasElement }) => {
|
|
226
|
+
await typeAndCreate(canvasElement, "Taxes");
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Retry is a resume: the first create times out, and pressing the create row
|
|
232
|
+
* again with the same name resolves and moves — the hook re-waits on the folder
|
|
233
|
+
* it already made rather than re-creating it.
|
|
234
|
+
*/
|
|
235
|
+
export const CreateFolderRetrySucceeds: Story = {
|
|
236
|
+
name: "Create folder — retry resumes and moves",
|
|
237
|
+
render: () => {
|
|
238
|
+
const RetryStage = () => {
|
|
239
|
+
const [moved, setMoved] = useState<string | null>(null);
|
|
240
|
+
return (
|
|
241
|
+
<div className="flex flex-col">
|
|
242
|
+
<MoveMailboxPicker
|
|
243
|
+
mailboxes={mailboxes}
|
|
244
|
+
onSelect={setMoved}
|
|
245
|
+
onCreateFolder={failThenSucceedCreateFolder()}
|
|
246
|
+
/>
|
|
247
|
+
{moved && (
|
|
248
|
+
<p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
|
|
249
|
+
Moved to {moved}
|
|
250
|
+
</p>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
return <RetryStage />;
|
|
256
|
+
},
|
|
257
|
+
play: async ({ canvasElement }) => {
|
|
258
|
+
await typeAndCreate(canvasElement, "Taxes");
|
|
259
|
+
await tick();
|
|
260
|
+
const retry = Array.from(
|
|
261
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
262
|
+
).find((button) => button.textContent?.includes('Create "Taxes"'));
|
|
263
|
+
retry?.click();
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Closing the picker while "Creating folder…" is in flight aborts the wait: the
|
|
269
|
+
* create promise rejects with an AbortError, so a folder that would confirm later
|
|
270
|
+
* never fires the move after the picker is gone. Here "Close picker" unmounts it
|
|
271
|
+
* mid-wait; no "Moved to" line appears.
|
|
272
|
+
*/
|
|
273
|
+
export const CreateFolderClosedMidWait: Story = {
|
|
274
|
+
name: "Create folder — closing aborts the move",
|
|
275
|
+
render: () => {
|
|
276
|
+
const AbortStage = () => {
|
|
277
|
+
const [open, setOpen] = useState(true);
|
|
278
|
+
const [moved, setMoved] = useState<string | null>(null);
|
|
279
|
+
return (
|
|
280
|
+
<div className="flex flex-col">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
onClick={() => setOpen(false)}
|
|
284
|
+
className="border-b border-line px-3 py-2 text-left text-xs text-fg-muted"
|
|
285
|
+
>
|
|
286
|
+
Close picker
|
|
287
|
+
</button>
|
|
288
|
+
{open && (
|
|
289
|
+
<MoveMailboxPicker
|
|
290
|
+
mailboxes={mailboxes}
|
|
291
|
+
onSelect={setMoved}
|
|
292
|
+
onCreateFolder={abortAwareCreateFolder}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
295
|
+
{moved && (
|
|
296
|
+
<p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
|
|
297
|
+
Moved to {moved}
|
|
298
|
+
</p>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
return <AbortStage />;
|
|
304
|
+
},
|
|
305
|
+
play: async ({ canvasElement }) => {
|
|
306
|
+
await typeAndCreate(canvasElement, "Taxes");
|
|
307
|
+
await tick();
|
|
308
|
+
const close = Array.from(
|
|
309
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
310
|
+
).find((button) => button.textContent?.trim() === "Close picker");
|
|
311
|
+
close?.click();
|
|
312
|
+
},
|
|
313
|
+
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
|
+
import { isAbortError } from "../lib/abort.js";
|
|
10
11
|
import { cn } from "../lib/cn.js";
|
|
11
12
|
import { Input } from "./input.js";
|
|
12
13
|
|
|
@@ -60,10 +61,15 @@ export interface MoveMailboxPickerProps {
|
|
|
60
61
|
/**
|
|
61
62
|
* Create a folder named by the current search query. When provided and the
|
|
62
63
|
* query names no existing folder, a create-and-move row is offered at the
|
|
63
|
-
* bottom of the list; resolving it
|
|
64
|
-
* (moved into)
|
|
64
|
+
* bottom of the list; resolving it — once the mail server confirms the folder
|
|
65
|
+
* — yields the new folder, which is selected (moved into). The picker aborts
|
|
66
|
+
* the passed signal on unmount, so a folder that confirms after the picker is
|
|
67
|
+
* closed never fires the move. Absent means no create affordance renders.
|
|
65
68
|
*/
|
|
66
|
-
onCreateFolder?: (
|
|
69
|
+
onCreateFolder?: (
|
|
70
|
+
name: string,
|
|
71
|
+
signal?: AbortSignal,
|
|
72
|
+
) => Promise<MoveMailboxOption>;
|
|
67
73
|
/**
|
|
68
74
|
* Called when the user dismisses the picker via Escape. Trigger consumers
|
|
69
75
|
* use this to close their popover/drawer; the picker never owns
|
|
@@ -153,6 +159,11 @@ export const MoveMailboxPicker = ({
|
|
|
153
159
|
);
|
|
154
160
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
155
161
|
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
162
|
+
// The create waits for the mail server to confirm the folder; abort it when
|
|
163
|
+
// the picker unmounts (its popover/drawer closes) so a late confirmation never
|
|
164
|
+
// fires the move after the picker is gone.
|
|
165
|
+
const createAbort = useRef<AbortController | null>(null);
|
|
166
|
+
useEffect(() => () => createAbort.current?.abort(), []);
|
|
156
167
|
|
|
157
168
|
useEffect(() => {
|
|
158
169
|
if (autoFocus) inputRef.current?.focus();
|
|
@@ -206,12 +217,16 @@ export const MoveMailboxPicker = ({
|
|
|
206
217
|
if (name === "") return;
|
|
207
218
|
setCreating(true);
|
|
208
219
|
setCreateError(undefined);
|
|
209
|
-
|
|
220
|
+
createAbort.current?.abort();
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
createAbort.current = controller;
|
|
223
|
+
onCreateFolder(name, controller.signal)
|
|
210
224
|
.then((folder) => {
|
|
211
225
|
onSelect(folder.id);
|
|
212
226
|
setCreating(false);
|
|
213
227
|
})
|
|
214
228
|
.catch((error: unknown) => {
|
|
229
|
+
if (isAbortError(error)) return;
|
|
215
230
|
setCreateError(
|
|
216
231
|
error instanceof Error ? error.message : text.createError,
|
|
217
232
|
);
|
package/src/lib/abort.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True for the rejection an aborted `AbortSignal` produces (a `DOMException`
|
|
3
|
+
* named `AbortError`, or any error carrying that name). A create affordance that
|
|
4
|
+
* is unmounted or cancelled aborts its in-flight folder create; the rejection is
|
|
5
|
+
* expected, not a failure to show — the surface is already gone.
|
|
6
|
+
*/
|
|
7
|
+
export const isAbortError = (error: unknown): boolean =>
|
|
8
|
+
typeof error === "object" &&
|
|
9
|
+
error !== null &&
|
|
10
|
+
"name" in error &&
|
|
11
|
+
(error as { name?: unknown }).name === "AbortError";
|