@remit/ui 0.0.49 → 0.0.51
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/app-shell-types.ts +8 -0
- package/src/components/filter-rule-editor.stories.tsx +267 -1
- package/src/components/filter-rule-editor.tsx +214 -18
- package/src/components/filter-rule.render.test.ts +45 -6
- package/src/components/filter-rule.ts +26 -9
- package/src/components/label-chip.render.test.ts +45 -0
- package/src/components/label-chip.tsx +49 -0
- package/src/components/message-row.render.test.ts +24 -0
- package/src/components/message-row.tsx +4 -0
- package/src/components/move-mailbox-picker.stories.tsx +199 -0
- package/src/components/move-mailbox-picker.tsx +19 -4
- package/src/index.ts +13 -0
- package/src/lib/abort.ts +11 -0
- package/src/lib/label-color.test.ts +25 -0
- package/src/lib/label-color.ts +46 -0
package/package.json
CHANGED
|
@@ -191,6 +191,12 @@ export const briefCategories: ReadonlyArray<{
|
|
|
191
191
|
{ id: "social", label: "Social" },
|
|
192
192
|
];
|
|
193
193
|
|
|
194
|
+
export interface ThreadRowLabel {
|
|
195
|
+
labelId: string;
|
|
196
|
+
name: string;
|
|
197
|
+
color: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
194
200
|
export interface ThreadRowData {
|
|
195
201
|
id: string;
|
|
196
202
|
accountId: string;
|
|
@@ -212,6 +218,8 @@ export interface ThreadRowData {
|
|
|
212
218
|
messageCount?: number;
|
|
213
219
|
/** Authenticity heuristics flagged this row (DKIM/From mismatch). */
|
|
214
220
|
suspicious?: boolean;
|
|
221
|
+
/** Labels applied to this message (issue #26) — filter-, organize-, and manually-applied alike. */
|
|
222
|
+
labels?: ThreadRowLabel[];
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
export interface ThreadSection {
|
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
type ClauseField,
|
|
7
7
|
clauseFieldLabel,
|
|
8
8
|
demoFolders,
|
|
9
|
+
demoLabels,
|
|
9
10
|
demoRule,
|
|
10
11
|
demoSenderFallbackRule,
|
|
11
12
|
demoVocabularyRule,
|
|
12
13
|
type FilterRule,
|
|
13
14
|
type FolderOption,
|
|
15
|
+
type LabelOption,
|
|
14
16
|
type PreviewCount,
|
|
15
17
|
type RuleClause,
|
|
16
18
|
} from "./filter-rule.js";
|
|
@@ -51,11 +53,18 @@ const READY = (count: number, stale?: boolean): PreviewCount => ({
|
|
|
51
53
|
function LiveEditor({
|
|
52
54
|
initialRule,
|
|
53
55
|
semanticAvailable = true,
|
|
56
|
+
labels = demoLabels,
|
|
54
57
|
onCreateFolder,
|
|
58
|
+
onCreateLabel,
|
|
55
59
|
}: {
|
|
56
60
|
initialRule: FilterRule;
|
|
57
61
|
semanticAvailable?: boolean;
|
|
58
|
-
|
|
62
|
+
labels?: LabelOption[];
|
|
63
|
+
onCreateFolder?: (
|
|
64
|
+
name: string,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
) => Promise<FolderOption>;
|
|
67
|
+
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
59
68
|
}) {
|
|
60
69
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
61
70
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
@@ -123,6 +132,8 @@ function LiveEditor({
|
|
|
123
132
|
},
|
|
124
133
|
onChangeMove: (moveMailboxId) =>
|
|
125
134
|
setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
|
|
135
|
+
onChangeLabel: (labelId) =>
|
|
136
|
+
setRule((r) => ({ ...r, labelId: labelId || undefined })),
|
|
126
137
|
onChangeScope: (scope) => setRule((r) => ({ ...r, scope })),
|
|
127
138
|
onChangeName: (name) => setRule((r) => ({ ...r, name })),
|
|
128
139
|
onChangeUntil: (until) => setRule((r) => ({ ...r, until })),
|
|
@@ -132,10 +143,12 @@ function LiveEditor({
|
|
|
132
143
|
<FilterRuleEditor
|
|
133
144
|
rule={rule}
|
|
134
145
|
folders={demoFolders}
|
|
146
|
+
labels={labels}
|
|
135
147
|
preview={preview}
|
|
136
148
|
semanticAvailable={semanticAvailable}
|
|
137
149
|
clauseEdit={clauseEdit}
|
|
138
150
|
onCreateFolder={onCreateFolder}
|
|
151
|
+
onCreateLabel={onCreateLabel}
|
|
139
152
|
onCommit={() => {}}
|
|
140
153
|
onCancel={() => {}}
|
|
141
154
|
{...handlers}
|
|
@@ -170,6 +183,259 @@ export const WithNewFolderOption: Story = {
|
|
|
170
183
|
),
|
|
171
184
|
};
|
|
172
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Drive the destination field into its create sub-form: pick "+ New folder…",
|
|
188
|
+
* type a name, and press "Create folder". Used by the pending and error stories
|
|
189
|
+
* below so each lands in the state it documents without a manual click-through.
|
|
190
|
+
*/
|
|
191
|
+
async function openCreateAndSubmit(
|
|
192
|
+
canvasElement: HTMLElement,
|
|
193
|
+
folderName: string,
|
|
194
|
+
) {
|
|
195
|
+
const setSelectValue = Object.getOwnPropertyDescriptor(
|
|
196
|
+
HTMLSelectElement.prototype,
|
|
197
|
+
"value",
|
|
198
|
+
)?.set;
|
|
199
|
+
const setInputValue = Object.getOwnPropertyDescriptor(
|
|
200
|
+
HTMLInputElement.prototype,
|
|
201
|
+
"value",
|
|
202
|
+
)?.set;
|
|
203
|
+
const select = canvasElement.querySelector<HTMLSelectElement>(
|
|
204
|
+
'select[aria-label="Destination folder"]',
|
|
205
|
+
);
|
|
206
|
+
if (!select) return;
|
|
207
|
+
setSelectValue?.call(select, CREATE_FOLDER_STORY_VALUE);
|
|
208
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
209
|
+
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
210
|
+
'input[aria-label="New folder name"]',
|
|
211
|
+
);
|
|
212
|
+
if (!input) return;
|
|
213
|
+
setInputValue?.call(input, folderName);
|
|
214
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
215
|
+
const createButton = Array.from(
|
|
216
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
217
|
+
).find((button) => button.textContent?.trim() === "Create folder");
|
|
218
|
+
createButton?.click();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Matches the internal CREATE_FOLDER_VALUE option in the destination select. */
|
|
222
|
+
const CREATE_FOLDER_STORY_VALUE = "__filter_create_folder__";
|
|
223
|
+
|
|
224
|
+
/** Mirrors the web-client wait's honest timeout copy. */
|
|
225
|
+
const TIMEOUT_MESSAGE =
|
|
226
|
+
"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.";
|
|
227
|
+
|
|
228
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
229
|
+
|
|
230
|
+
const neverResolvesCreateFolder = (): Promise<FolderOption> =>
|
|
231
|
+
new Promise<FolderOption>(() => undefined);
|
|
232
|
+
|
|
233
|
+
const rejectingCreateFolder = (message: string) => (): Promise<FolderOption> =>
|
|
234
|
+
Promise.reject(new Error(message));
|
|
235
|
+
|
|
236
|
+
/** Rejects the first attempt, resolves the retry — the resume the hook performs. */
|
|
237
|
+
const failThenSucceedCreateFolder = () => {
|
|
238
|
+
let attempts = 0;
|
|
239
|
+
return (name: string): Promise<FolderOption> => {
|
|
240
|
+
attempts += 1;
|
|
241
|
+
return attempts === 1
|
|
242
|
+
? Promise.reject(new Error(TIMEOUT_MESSAGE))
|
|
243
|
+
: Promise.resolve({ id: "mbx-created", label: name });
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Never resolves on its own; rejects with an AbortError when the signal aborts. */
|
|
248
|
+
const abortAwareCreateFolder = (
|
|
249
|
+
_name: string,
|
|
250
|
+
signal?: AbortSignal,
|
|
251
|
+
): Promise<FolderOption> =>
|
|
252
|
+
new Promise<FolderOption>((_resolve, reject) => {
|
|
253
|
+
signal?.addEventListener("abort", () =>
|
|
254
|
+
reject(new DOMException("Aborted", "AbortError")),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The folder is a dependent write for the filter, so creating it waits for the
|
|
260
|
+
* mail server to confirm the folder before it can be picked as the destination.
|
|
261
|
+
* The wait shows as "Creating folder…" — held for the whole confirmation, not
|
|
262
|
+
* just a fast optimistic round-trip.
|
|
263
|
+
*/
|
|
264
|
+
export const NewFolderCreating: Story = {
|
|
265
|
+
name: "New folder — creating (waiting for the server)",
|
|
266
|
+
render: () => (
|
|
267
|
+
<LiveEditor
|
|
268
|
+
initialRule={demoRule}
|
|
269
|
+
onCreateFolder={neverResolvesCreateFolder}
|
|
270
|
+
/>
|
|
271
|
+
),
|
|
272
|
+
play: async ({ canvasElement }) => {
|
|
273
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The folder create failed on the mail server. The rule is not committed against
|
|
279
|
+
* a folder that does not exist: the error is surfaced inline with the create form
|
|
280
|
+
* still open, so the create can be retried or cancelled.
|
|
281
|
+
*/
|
|
282
|
+
export const NewFolderCreateFailed: Story = {
|
|
283
|
+
name: "New folder — create failed (retry / cancel)",
|
|
284
|
+
render: () => (
|
|
285
|
+
<LiveEditor
|
|
286
|
+
initialRule={demoRule}
|
|
287
|
+
onCreateFolder={rejectingCreateFolder(
|
|
288
|
+
"The folder couldn't be created on the mail server. Please try again.",
|
|
289
|
+
)}
|
|
290
|
+
/>
|
|
291
|
+
),
|
|
292
|
+
play: async ({ canvasElement }) => {
|
|
293
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The folder create was never confirmed within the wait bound. Distinct from a
|
|
299
|
+
* hard failure — the message names the timeout — and, like a failure, leaves no
|
|
300
|
+
* folder selected, so no filter is written against it.
|
|
301
|
+
*/
|
|
302
|
+
export const NewFolderCreateTimedOut: Story = {
|
|
303
|
+
name: "New folder — create timed out (retry / cancel)",
|
|
304
|
+
render: () => (
|
|
305
|
+
<LiveEditor
|
|
306
|
+
initialRule={demoRule}
|
|
307
|
+
onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
|
|
308
|
+
/>
|
|
309
|
+
),
|
|
310
|
+
play: async ({ canvasElement }) => {
|
|
311
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Retry is a resume: the first attempt times out (the folder was made but not yet
|
|
317
|
+
* confirmed), and pressing "Create folder" again with the same name resolves —
|
|
318
|
+
* the hook re-waits on the folder it already made rather than re-creating it, so
|
|
319
|
+
* the retry the failure message points at actually works.
|
|
320
|
+
*/
|
|
321
|
+
export const NewFolderCreateRetrySucceeds: Story = {
|
|
322
|
+
name: "New folder — retry resumes and succeeds",
|
|
323
|
+
render: () => (
|
|
324
|
+
<LiveEditor
|
|
325
|
+
initialRule={demoRule}
|
|
326
|
+
onCreateFolder={failThenSucceedCreateFolder()}
|
|
327
|
+
/>
|
|
328
|
+
),
|
|
329
|
+
play: async ({ canvasElement }) => {
|
|
330
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
331
|
+
await tick();
|
|
332
|
+
const retry = Array.from(
|
|
333
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
334
|
+
).find((button) => button.textContent?.trim() === "Create folder");
|
|
335
|
+
retry?.click();
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Cancelling while "Creating folder…" is in flight aborts the wait: the create
|
|
341
|
+
* promise rejects with an AbortError the field swallows, so no destination binds
|
|
342
|
+
* after the user backed out — the sub-form just closes.
|
|
343
|
+
*/
|
|
344
|
+
export const NewFolderCreateCancelledMidWait: Story = {
|
|
345
|
+
name: "New folder — cancel aborts the wait",
|
|
346
|
+
render: () => (
|
|
347
|
+
<LiveEditor
|
|
348
|
+
initialRule={demoRule}
|
|
349
|
+
onCreateFolder={abortAwareCreateFolder}
|
|
350
|
+
/>
|
|
351
|
+
),
|
|
352
|
+
play: async ({ canvasElement }) => {
|
|
353
|
+
await openCreateAndSubmit(canvasElement, "Receipts");
|
|
354
|
+
await tick();
|
|
355
|
+
const cancel = Array.from(
|
|
356
|
+
canvasElement.querySelectorAll<HTMLButtonElement>("button"),
|
|
357
|
+
).find((button) => button.textContent?.trim() === "Cancel");
|
|
358
|
+
cancel?.click();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** No labels exist yet in the account — the select offers only "No label". */
|
|
363
|
+
export const NoLabels: Story = {
|
|
364
|
+
render: () => <LiveEditor initialRule={demoRule} labels={[]} />,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** A rule that already applies a label — the chip renders next to the select. */
|
|
368
|
+
export const WithLabelSelected: Story = {
|
|
369
|
+
render: () => (
|
|
370
|
+
<LiveEditor initialRule={{ ...demoRule, labelId: "lbl-receipts" }} />
|
|
371
|
+
),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Many labels in the account — the select scrolls rather than the layout growing. */
|
|
375
|
+
export const ManyLabels: Story = {
|
|
376
|
+
render: () => (
|
|
377
|
+
<LiveEditor
|
|
378
|
+
initialRule={demoRule}
|
|
379
|
+
labels={Array.from({ length: 20 }, (_, i) => ({
|
|
380
|
+
id: `lbl-${i}`,
|
|
381
|
+
name: `Label ${i + 1}`,
|
|
382
|
+
color: demoLabels[i % demoLabels.length].color,
|
|
383
|
+
}))}
|
|
384
|
+
/>
|
|
385
|
+
),
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** A long label name truncates in the chip rather than overflowing the row. */
|
|
389
|
+
export const LongLabelNames: Story = {
|
|
390
|
+
render: () => (
|
|
391
|
+
<LiveEditor
|
|
392
|
+
initialRule={{ ...demoRule, labelId: "lbl-long" }}
|
|
393
|
+
labels={[
|
|
394
|
+
{
|
|
395
|
+
id: "lbl-long",
|
|
396
|
+
name: "Quarterly compliance filings that need a second look",
|
|
397
|
+
color: "Purple",
|
|
398
|
+
},
|
|
399
|
+
...demoLabels,
|
|
400
|
+
]}
|
|
401
|
+
/>
|
|
402
|
+
),
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
let newLabelSeq = 0;
|
|
406
|
+
const mockCreateLabel = (name: string): Promise<LabelOption> =>
|
|
407
|
+
new Promise((resolve) => {
|
|
408
|
+
newLabelSeq += 1;
|
|
409
|
+
setTimeout(
|
|
410
|
+
() => resolve({ id: `lbl-new-${newLabelSeq}`, name, color: "Default" }),
|
|
411
|
+
400,
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The label select offers a "+ New label…" option because `onCreateLabel` is
|
|
417
|
+
* wired (issue #26). Choosing it reveals a name field; on resolve the label is
|
|
418
|
+
* added to the select and picked as the action. Without the prop the option
|
|
419
|
+
* never shows — the editor stays data-agnostic.
|
|
420
|
+
*/
|
|
421
|
+
export const WithNewLabelOption: Story = {
|
|
422
|
+
render: () => (
|
|
423
|
+
<LiveEditor initialRule={demoRule} onCreateLabel={mockCreateLabel} />
|
|
424
|
+
),
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const mockCreateLabelFailure = (): Promise<LabelOption> =>
|
|
428
|
+
new Promise((_resolve, reject) => {
|
|
429
|
+
setTimeout(() => reject(new Error("That name is already taken.")), 400);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
/** Creating a label from the picker can fail — the field stays open with the reason. */
|
|
433
|
+
export const LabelCreateError: Story = {
|
|
434
|
+
render: () => (
|
|
435
|
+
<LiveEditor initialRule={demoRule} onCreateLabel={mockCreateLabelFailure} />
|
|
436
|
+
),
|
|
437
|
+
};
|
|
438
|
+
|
|
173
439
|
/** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
|
|
174
440
|
export const AnyOfTheseClauses: Story = {
|
|
175
441
|
render: () => <LiveEditor initialRule={demoVocabularyRule} />,
|
|
@@ -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";
|
|
@@ -16,6 +24,7 @@ import {
|
|
|
16
24
|
commitLabel,
|
|
17
25
|
type FilterRule,
|
|
18
26
|
type FolderOption,
|
|
27
|
+
type LabelOption,
|
|
19
28
|
type MatchOperator,
|
|
20
29
|
matchJoinWord,
|
|
21
30
|
matchOperatorLabel,
|
|
@@ -23,6 +32,7 @@ import {
|
|
|
23
32
|
type RuleScope,
|
|
24
33
|
} from "./filter-rule.js";
|
|
25
34
|
import { Input } from "./input.js";
|
|
35
|
+
import { LabelChip } from "./label-chip.js";
|
|
26
36
|
import { SegmentedControl } from "./segmented-control.js";
|
|
27
37
|
import { Select } from "./select.js";
|
|
28
38
|
|
|
@@ -36,6 +46,8 @@ export interface ClauseEditState {
|
|
|
36
46
|
export interface FilterRuleEditorProps {
|
|
37
47
|
rule: FilterRule;
|
|
38
48
|
folders: FolderOption[];
|
|
49
|
+
/** The account's labels the apply-label action can target (issue #26). */
|
|
50
|
+
labels?: LabelOption[];
|
|
39
51
|
preview: PreviewCount;
|
|
40
52
|
/**
|
|
41
53
|
* Content rendered above the clause chips — the filter-from-search conversion
|
|
@@ -84,12 +96,23 @@ export interface FilterRuleEditorProps {
|
|
|
84
96
|
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
85
97
|
onChangeMove?: (mailboxId: string) => void;
|
|
86
98
|
/**
|
|
87
|
-
* Create a new destination folder from within the editor. Given a folder
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
99
|
+
* Create a new destination folder from within the editor. Given a folder name
|
|
100
|
+
* and an abort signal, resolves to the created folder once the mail server
|
|
101
|
+
* confirms it. The editor aborts the signal on unmount or cancel. When absent,
|
|
102
|
+
* the "New folder…" option is not offered — the editor stays data-agnostic, so
|
|
103
|
+
* stories and consumers without wiring render unchanged.
|
|
104
|
+
*/
|
|
105
|
+
onCreateFolder?: (
|
|
106
|
+
name: string,
|
|
107
|
+
signal?: AbortSignal,
|
|
108
|
+
) => Promise<FolderOption>;
|
|
109
|
+
onChangeLabel?: (labelId: string) => void;
|
|
110
|
+
/**
|
|
111
|
+
* Create a new label from within the editor (issue #26). Given a label
|
|
112
|
+
* name, resolves to the created label once the backend has saved it. When
|
|
113
|
+
* absent, the "New label…" option is not offered.
|
|
91
114
|
*/
|
|
92
|
-
|
|
115
|
+
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
93
116
|
onChangeScope?: (scope: RuleScope) => void;
|
|
94
117
|
onChangeName?: (name: string) => void;
|
|
95
118
|
onChangeUntil?: (date: string) => void;
|
|
@@ -109,6 +132,7 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
|
109
132
|
];
|
|
110
133
|
|
|
111
134
|
const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
135
|
+
const CREATE_LABEL_VALUE = "__filter_create_label__";
|
|
112
136
|
|
|
113
137
|
/**
|
|
114
138
|
* The move-to destination select, plus an inline "New folder…" affordance when
|
|
@@ -116,6 +140,13 @@ const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
|
116
140
|
* name field; on resolve the new folder is added to the local option set (so it
|
|
117
141
|
* is selectable even before the caller's folder list refetches) and picked as
|
|
118
142
|
* the destination. Without `onCreateFolder` this is the bare select.
|
|
143
|
+
*
|
|
144
|
+
* The destination is a dependent write: the filter this editor commits binds to
|
|
145
|
+
* the folder, so `onCreateFolder` resolves only once the folder is confirmed on
|
|
146
|
+
* the mail server, not when the create is merely queued. The pending state holds
|
|
147
|
+
* "Creating folder…" for that whole wait, and a create that fails or never
|
|
148
|
+
* confirms rejects with its own message here — the folder is never selected, so
|
|
149
|
+
* the caller cannot commit a filter against a folder that does not exist.
|
|
119
150
|
*/
|
|
120
151
|
function MoveDestinationField({
|
|
121
152
|
folders,
|
|
@@ -126,13 +157,21 @@ function MoveDestinationField({
|
|
|
126
157
|
folders: FolderOption[];
|
|
127
158
|
value: string;
|
|
128
159
|
onChangeMove?: (mailboxId: string) => void;
|
|
129
|
-
onCreateFolder?: (
|
|
160
|
+
onCreateFolder?: (
|
|
161
|
+
name: string,
|
|
162
|
+
signal?: AbortSignal,
|
|
163
|
+
) => Promise<FolderOption>;
|
|
130
164
|
}) {
|
|
131
165
|
const [creating, setCreating] = useState(false);
|
|
132
166
|
const [name, setName] = useState("");
|
|
133
167
|
const [pending, setPending] = useState(false);
|
|
134
168
|
const [error, setError] = useState<string>();
|
|
135
169
|
const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
|
|
170
|
+
// The create waits for the mail server to confirm the folder; abort it on
|
|
171
|
+
// unmount or cancel so a late confirmation never binds the destination after
|
|
172
|
+
// the editor is gone or the sub-form dismissed.
|
|
173
|
+
const createAbort = useRef<AbortController | null>(null);
|
|
174
|
+
useEffect(() => () => createAbort.current?.abort(), []);
|
|
136
175
|
|
|
137
176
|
const options = useMemo(() => {
|
|
138
177
|
const known = new Set(folders.map((folder) => folder.id));
|
|
@@ -157,7 +196,10 @@ function MoveDestinationField({
|
|
|
157
196
|
if (trimmed === "") return;
|
|
158
197
|
setPending(true);
|
|
159
198
|
setError(undefined);
|
|
160
|
-
|
|
199
|
+
createAbort.current?.abort();
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
createAbort.current = controller;
|
|
202
|
+
onCreateFolder(trimmed, controller.signal)
|
|
161
203
|
.then((folder) => {
|
|
162
204
|
setCreatedFolders((prev) =>
|
|
163
205
|
prev.some((entry) => entry.id === folder.id)
|
|
@@ -170,6 +212,7 @@ function MoveDestinationField({
|
|
|
170
212
|
setPending(false);
|
|
171
213
|
})
|
|
172
214
|
.catch((error: unknown) => {
|
|
215
|
+
if (isAbortError(error)) return;
|
|
173
216
|
setError(
|
|
174
217
|
error instanceof Error
|
|
175
218
|
? error.message
|
|
@@ -180,9 +223,11 @@ function MoveDestinationField({
|
|
|
180
223
|
};
|
|
181
224
|
|
|
182
225
|
const cancel = () => {
|
|
226
|
+
createAbort.current?.abort();
|
|
183
227
|
setCreating(false);
|
|
184
228
|
setName("");
|
|
185
229
|
setError(undefined);
|
|
230
|
+
setPending(false);
|
|
186
231
|
};
|
|
187
232
|
|
|
188
233
|
return (
|
|
@@ -234,7 +279,154 @@ function MoveDestinationField({
|
|
|
234
279
|
onClick={submit}
|
|
235
280
|
disabled={pending || name.trim() === ""}
|
|
236
281
|
>
|
|
237
|
-
{pending ? "Creating…" : "Create folder"}
|
|
282
|
+
{pending ? "Creating folder…" : "Create folder"}
|
|
283
|
+
</Button>
|
|
284
|
+
<Button
|
|
285
|
+
variant="ghost"
|
|
286
|
+
size="sm"
|
|
287
|
+
onClick={cancel}
|
|
288
|
+
disabled={pending}
|
|
289
|
+
>
|
|
290
|
+
Cancel
|
|
291
|
+
</Button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The apply-label select, plus an inline "New label…" affordance when the
|
|
301
|
+
* consumer wires `onCreateLabel` (issue #26). Mirrors `MoveDestinationField`:
|
|
302
|
+
* selecting the create option reveals a name field, and the created label is
|
|
303
|
+
* added to the local option set and picked immediately, before the caller's
|
|
304
|
+
* label list refetches.
|
|
305
|
+
*/
|
|
306
|
+
function LabelDestinationField({
|
|
307
|
+
labels,
|
|
308
|
+
value,
|
|
309
|
+
onChangeLabel,
|
|
310
|
+
onCreateLabel,
|
|
311
|
+
}: {
|
|
312
|
+
labels: LabelOption[];
|
|
313
|
+
value: string;
|
|
314
|
+
onChangeLabel?: (labelId: string) => void;
|
|
315
|
+
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
316
|
+
}) {
|
|
317
|
+
const [creating, setCreating] = useState(false);
|
|
318
|
+
const [name, setName] = useState("");
|
|
319
|
+
const [pending, setPending] = useState(false);
|
|
320
|
+
const [error, setError] = useState<string>();
|
|
321
|
+
const [createdLabels, setCreatedLabels] = useState<LabelOption[]>([]);
|
|
322
|
+
|
|
323
|
+
const options = useMemo(() => {
|
|
324
|
+
const known = new Set(labels.map((label) => label.id));
|
|
325
|
+
return [
|
|
326
|
+
...labels,
|
|
327
|
+
...createdLabels.filter((label) => !known.has(label.id)),
|
|
328
|
+
];
|
|
329
|
+
}, [labels, createdLabels]);
|
|
330
|
+
|
|
331
|
+
const selected = options.find((label) => label.id === value);
|
|
332
|
+
|
|
333
|
+
const handleSelectChange = (next: string) => {
|
|
334
|
+
if (next === CREATE_LABEL_VALUE) {
|
|
335
|
+
setError(undefined);
|
|
336
|
+
setCreating(true);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
onChangeLabel?.(next);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const submit = () => {
|
|
343
|
+
if (!onCreateLabel) return;
|
|
344
|
+
const trimmed = name.trim();
|
|
345
|
+
if (trimmed === "") return;
|
|
346
|
+
setPending(true);
|
|
347
|
+
setError(undefined);
|
|
348
|
+
onCreateLabel(trimmed)
|
|
349
|
+
.then((label) => {
|
|
350
|
+
setCreatedLabels((prev) =>
|
|
351
|
+
prev.some((entry) => entry.id === label.id) ? prev : [...prev, label],
|
|
352
|
+
);
|
|
353
|
+
onChangeLabel?.(label.id);
|
|
354
|
+
setCreating(false);
|
|
355
|
+
setName("");
|
|
356
|
+
setPending(false);
|
|
357
|
+
})
|
|
358
|
+
.catch((error: unknown) => {
|
|
359
|
+
setError(
|
|
360
|
+
error instanceof Error
|
|
361
|
+
? error.message
|
|
362
|
+
: "Couldn't create that label. Please try again.",
|
|
363
|
+
);
|
|
364
|
+
setPending(false);
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const cancel = () => {
|
|
369
|
+
setCreating(false);
|
|
370
|
+
setName("");
|
|
371
|
+
setError(undefined);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div className="space-y-2">
|
|
376
|
+
<div className="flex items-center gap-2">
|
|
377
|
+
<Select
|
|
378
|
+
aria-label="Label to apply"
|
|
379
|
+
value={value}
|
|
380
|
+
onChange={(event) => handleSelectChange(event.target.value)}
|
|
381
|
+
className="flex-1"
|
|
382
|
+
>
|
|
383
|
+
<option value="">No label</option>
|
|
384
|
+
{options.map((label) => (
|
|
385
|
+
<option key={label.id} value={label.id}>
|
|
386
|
+
{label.name}
|
|
387
|
+
</option>
|
|
388
|
+
))}
|
|
389
|
+
{onCreateLabel && (
|
|
390
|
+
<option value={CREATE_LABEL_VALUE}>+ New label…</option>
|
|
391
|
+
)}
|
|
392
|
+
</Select>
|
|
393
|
+
{selected && (
|
|
394
|
+
<LabelChip label={{ ...selected, labelId: selected.id }} />
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
{creating && (
|
|
398
|
+
<div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
|
|
399
|
+
<Input
|
|
400
|
+
value={name}
|
|
401
|
+
onChange={(event) => setName(event.target.value)}
|
|
402
|
+
placeholder="Label name"
|
|
403
|
+
aria-label="New label name"
|
|
404
|
+
disabled={pending}
|
|
405
|
+
autoFocus
|
|
406
|
+
onKeyDown={(event) => {
|
|
407
|
+
if (event.key === "Enter") {
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
submit();
|
|
410
|
+
}
|
|
411
|
+
if (event.key === "Escape") {
|
|
412
|
+
event.preventDefault();
|
|
413
|
+
cancel();
|
|
414
|
+
}
|
|
415
|
+
}}
|
|
416
|
+
/>
|
|
417
|
+
{error && (
|
|
418
|
+
<p className="text-2xs text-danger" role="alert">
|
|
419
|
+
{error}
|
|
420
|
+
</p>
|
|
421
|
+
)}
|
|
422
|
+
<div className="flex gap-2">
|
|
423
|
+
<Button
|
|
424
|
+
variant="primary"
|
|
425
|
+
size="sm"
|
|
426
|
+
onClick={submit}
|
|
427
|
+
disabled={pending || name.trim() === ""}
|
|
428
|
+
>
|
|
429
|
+
{pending ? "Creating…" : "Create label"}
|
|
238
430
|
</Button>
|
|
239
431
|
<Button
|
|
240
432
|
variant="ghost"
|
|
@@ -254,6 +446,7 @@ function MoveDestinationField({
|
|
|
254
446
|
export function FilterRuleEditor({
|
|
255
447
|
rule,
|
|
256
448
|
folders,
|
|
449
|
+
labels = [],
|
|
257
450
|
preview,
|
|
258
451
|
notice,
|
|
259
452
|
semanticAvailable = false,
|
|
@@ -272,6 +465,8 @@ export function FilterRuleEditor({
|
|
|
272
465
|
onChangeMatchOperator,
|
|
273
466
|
onChangeMove,
|
|
274
467
|
onCreateFolder,
|
|
468
|
+
onChangeLabel,
|
|
469
|
+
onCreateLabel,
|
|
275
470
|
onChangeScope,
|
|
276
471
|
onChangeName,
|
|
277
472
|
onChangeUntil,
|
|
@@ -386,15 +581,16 @@ export function FilterRuleEditor({
|
|
|
386
581
|
onChangeMove={onChangeMove}
|
|
387
582
|
onCreateFolder={onCreateFolder}
|
|
388
583
|
/>
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
584
|
+
</section>
|
|
585
|
+
|
|
586
|
+
<section className="space-y-2">
|
|
587
|
+
<p className="text-xs font-medium text-fg-muted">Apply a label</p>
|
|
588
|
+
<LabelDestinationField
|
|
589
|
+
labels={labels}
|
|
590
|
+
value={rule.labelId ?? ""}
|
|
591
|
+
onChangeLabel={onChangeLabel}
|
|
592
|
+
onCreateLabel={onCreateLabel}
|
|
593
|
+
/>
|
|
398
594
|
</section>
|
|
399
595
|
|
|
400
596
|
<section className="space-y-2">
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
demoVocabularyRule,
|
|
22
22
|
type FilterRule,
|
|
23
23
|
type FolderOption,
|
|
24
|
+
type LabelOption,
|
|
24
25
|
matchJoinWord,
|
|
25
26
|
matchOperatorLabel,
|
|
26
27
|
type PreviewCount,
|
|
@@ -45,6 +46,11 @@ const FOLDERS: FolderOption[] = [
|
|
|
45
46
|
{ id: "mbx-archive", label: "Archive" },
|
|
46
47
|
];
|
|
47
48
|
|
|
49
|
+
const LABELS: LabelOption[] = [
|
|
50
|
+
{ id: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
51
|
+
{ id: "lbl-travel", name: "Travel", color: "Green" },
|
|
52
|
+
];
|
|
53
|
+
|
|
48
54
|
const READY: PreviewCount = { status: "ready", count: 47 };
|
|
49
55
|
|
|
50
56
|
const editor = (overrides: Partial<FilterRuleEditorProps> = {}) =>
|
|
@@ -161,10 +167,20 @@ describe("commitBlockedReason", () => {
|
|
|
161
167
|
);
|
|
162
168
|
});
|
|
163
169
|
|
|
164
|
-
it("asks for a folder when
|
|
170
|
+
it("asks for a folder or a label when neither action is chosen", () => {
|
|
165
171
|
assert.match(
|
|
166
172
|
commitBlockedReason({ ...base, moveMailboxId: undefined }, fresh) ?? "",
|
|
167
|
-
/Pick a folder/,
|
|
173
|
+
/Pick a folder to move into, or a label to apply/,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("a label alone is a sufficient action, no folder needed", () => {
|
|
178
|
+
assert.equal(
|
|
179
|
+
commitBlockedReason(
|
|
180
|
+
{ ...base, moveMailboxId: undefined, labelId: "lbl-1" },
|
|
181
|
+
fresh,
|
|
182
|
+
),
|
|
183
|
+
undefined,
|
|
168
184
|
);
|
|
169
185
|
});
|
|
170
186
|
|
|
@@ -505,10 +521,33 @@ describe("FilterRuleEditor", () => {
|
|
|
505
521
|
assert.match(html, /from sender/);
|
|
506
522
|
});
|
|
507
523
|
|
|
508
|
-
it("
|
|
509
|
-
const html = editor();
|
|
510
|
-
assert.match(html, /label
|
|
511
|
-
assert.match(html, /
|
|
524
|
+
it("offers the account's labels as the apply-label action", () => {
|
|
525
|
+
const html = editor({ labels: LABELS });
|
|
526
|
+
assert.match(html, /aria-label="Label to apply"/);
|
|
527
|
+
assert.match(html, /Receipts/);
|
|
528
|
+
assert.match(html, /Travel/);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("renders a chip for the selected label", () => {
|
|
532
|
+
const html = editor({
|
|
533
|
+
labels: LABELS,
|
|
534
|
+
rule: { ...demoRule, labelId: "lbl-receipts" },
|
|
535
|
+
});
|
|
536
|
+
assert.match(html, /Receipts/);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("offers no create option without onCreateLabel", () => {
|
|
540
|
+
const html = editor({ labels: LABELS });
|
|
541
|
+
assert.doesNotMatch(html, /New label…/);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("offers the create option when onCreateLabel is wired", () => {
|
|
545
|
+
const html = editor({
|
|
546
|
+
labels: LABELS,
|
|
547
|
+
onCreateLabel: () =>
|
|
548
|
+
Promise.resolve({ id: "lbl-new", name: "New", color: "Default" }),
|
|
549
|
+
});
|
|
550
|
+
assert.match(html, /New label…/);
|
|
512
551
|
});
|
|
513
552
|
|
|
514
553
|
it("shows the scope toggle and names a standing rule", () => {
|
|
@@ -62,6 +62,11 @@ export interface FilterRule {
|
|
|
62
62
|
widen?: RuleWiden;
|
|
63
63
|
/** The move-to-folder action's destination. Absent leaves mail in place. */
|
|
64
64
|
moveMailboxId?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The apply-label action's target (issue #26) — additive, so it can be set
|
|
67
|
+
* alongside `moveMailboxId`. Absent applies no label.
|
|
68
|
+
*/
|
|
69
|
+
labelId?: string;
|
|
65
70
|
scope: RuleScope;
|
|
66
71
|
/** ISO 8601 civil date (`YYYY-MM-DD`) for the `until` scope. */
|
|
67
72
|
until?: string;
|
|
@@ -74,6 +79,12 @@ export interface FolderOption {
|
|
|
74
79
|
label: string;
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
export interface LabelOption {
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
color: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
/**
|
|
78
89
|
* The live match count (RFC 038 D1). `stale` marks a count the editor already
|
|
79
90
|
* moved past — a clause changed after it was counted, so the number on screen
|
|
@@ -171,13 +182,13 @@ export function commitLabel(scope: RuleScope): string {
|
|
|
171
182
|
|
|
172
183
|
/**
|
|
173
184
|
* Why the rule cannot be saved yet, or `undefined` when it is ready. A rule
|
|
174
|
-
* needs at least one live way to match,
|
|
175
|
-
*
|
|
176
|
-
* date. It also needs a settled preview:
|
|
177
|
-
* count on screen is the count that
|
|
178
|
-
* previewed-set-equals-applied-set
|
|
179
|
-
*
|
|
180
|
-
* saying why (ux.md).
|
|
185
|
+
* needs at least one live way to match, at least one action — move and/or
|
|
186
|
+
* label, either satisfies this (issue #26) — and, for the two persisted
|
|
187
|
+
* scopes, a name and, for `until`, a date. It also needs a settled preview:
|
|
188
|
+
* the rule is committable only when the count on screen is the count that
|
|
189
|
+
* will be applied. That makes RFC 038's previewed-set-equals-applied-set
|
|
190
|
+
* contract structural — a consumer cannot save a rule whose match count is
|
|
191
|
+
* still moving. Never disable a control without saying why (ux.md).
|
|
181
192
|
*/
|
|
182
193
|
export function commitBlockedReason(
|
|
183
194
|
rule: FilterRule,
|
|
@@ -187,8 +198,8 @@ export function commitBlockedReason(
|
|
|
187
198
|
rule.clauses.length > 0 ||
|
|
188
199
|
(rule.widen !== undefined && !rule.widen.inactive);
|
|
189
200
|
if (!hasMatch) return "Add a clause so the rule has something to match.";
|
|
190
|
-
if (!rule.moveMailboxId)
|
|
191
|
-
return "Pick a folder to move
|
|
201
|
+
if (!rule.moveMailboxId && !rule.labelId)
|
|
202
|
+
return "Pick a folder to move into, or a label to apply.";
|
|
192
203
|
if (
|
|
193
204
|
(rule.scope === "standing" || rule.scope === "until") &&
|
|
194
205
|
(rule.name ?? "").trim() === ""
|
|
@@ -211,6 +222,12 @@ export const demoFolders: FolderOption[] = [
|
|
|
211
222
|
{ id: "mbx-junk", label: "Junk" },
|
|
212
223
|
];
|
|
213
224
|
|
|
225
|
+
export const demoLabels: LabelOption[] = [
|
|
226
|
+
{ id: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
227
|
+
{ id: "lbl-travel", name: "Travel", color: "Green" },
|
|
228
|
+
{ id: "lbl-urgent", name: "Urgent", color: "Red" },
|
|
229
|
+
];
|
|
230
|
+
|
|
214
231
|
export const demoRule: FilterRule = {
|
|
215
232
|
clauses: [
|
|
216
233
|
{ id: "c1", field: "From", value: "notifications@github.com" },
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { LabelChip } from "./label-chip.js";
|
|
6
|
+
|
|
7
|
+
describe("LabelChip", () => {
|
|
8
|
+
it("renders the label's name and a dot in its color", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(LabelChip, {
|
|
11
|
+
label: { labelId: "l1", name: "Receipts", color: "Blue" },
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
assert.match(html, /Receipts/);
|
|
15
|
+
assert.match(html, /bg-blue-500/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to the neutral dot for an unrecognized color", () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
createElement(LabelChip, {
|
|
21
|
+
label: { labelId: "l1", name: "Mystery", color: "Chartreuse" },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
assert.match(html, /bg-fg-subtle/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders no remove affordance without onRemove", () => {
|
|
28
|
+
const html = renderToString(
|
|
29
|
+
createElement(LabelChip, {
|
|
30
|
+
label: { labelId: "l1", name: "Receipts", color: "Blue" },
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
assert.doesNotMatch(html, /Remove label/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders a remove button naming the label when onRemove is wired", () => {
|
|
37
|
+
const html = renderToString(
|
|
38
|
+
createElement(LabelChip, {
|
|
39
|
+
label: { labelId: "l1", name: "Receipts", color: "Blue" },
|
|
40
|
+
onRemove: () => undefined,
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
assert.match(html, /aria-label="Remove label Receipts"/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { isLabelColorValue, labelDotClass } from "../lib/label-color.js";
|
|
3
|
+
|
|
4
|
+
export interface LabelChipData {
|
|
5
|
+
labelId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
color: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LabelChipProps {
|
|
11
|
+
label: LabelChipData;
|
|
12
|
+
/** Renders a remove affordance — the manual "just these" unlabel action. */
|
|
13
|
+
onRemove?: (labelId: string) => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A label applied to a message (issue #26) — a colored dot plus its name, the
|
|
19
|
+
* same shape a category badge or the widen chip takes. `color` renders from
|
|
20
|
+
* the literal palette (never a semantic tone): a label's color is a user
|
|
21
|
+
* choice, not a status the design system assigns meaning to.
|
|
22
|
+
*/
|
|
23
|
+
export function LabelChip({ label, onRemove, className }: LabelChipProps) {
|
|
24
|
+
const dotClass = isLabelColorValue(label.color)
|
|
25
|
+
? labelDotClass[label.color]
|
|
26
|
+
: labelDotClass.Default;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
"inline-flex items-center gap-1 rounded-full bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted shrink-0",
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<span className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
|
|
36
|
+
<span className="truncate">{label.name}</span>
|
|
37
|
+
{onRemove && (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => onRemove(label.labelId)}
|
|
41
|
+
aria-label={`Remove label ${label.name}`}
|
|
42
|
+
className="ml-0.5 rounded-full text-fg-subtle hover:text-fg"
|
|
43
|
+
>
|
|
44
|
+
×
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -35,6 +35,30 @@ describe("ComfortableRow", () => {
|
|
|
35
35
|
assert.notEqual(unread, read);
|
|
36
36
|
assert.match(unread, /font-semibold/);
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
it("renders a chip for every label applied to the message (issue #26)", () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
createElement(ComfortableRow, {
|
|
42
|
+
thread: {
|
|
43
|
+
...base,
|
|
44
|
+
isRead: true,
|
|
45
|
+
labels: [
|
|
46
|
+
{ labelId: "l1", name: "Receipts", color: "Blue" },
|
|
47
|
+
{ labelId: "l2", name: "Travel", color: "Green" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
assert.match(html, /Receipts/);
|
|
53
|
+
assert.match(html, /Travel/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders no label chip when the message carries none", () => {
|
|
57
|
+
const html = renderToString(
|
|
58
|
+
createElement(ComfortableRow, { thread: { ...base, isRead: true } }),
|
|
59
|
+
);
|
|
60
|
+
assert.doesNotMatch(html, /Remove label/);
|
|
61
|
+
});
|
|
38
62
|
});
|
|
39
63
|
|
|
40
64
|
describe("ComfortableRow selection slot", () => {
|
|
@@ -5,6 +5,7 @@ import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
|
5
5
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
6
6
|
import { Avatar } from "./avatar.js";
|
|
7
7
|
import { Badge } from "./badge.js";
|
|
8
|
+
import { LabelChip } from "./label-chip.js";
|
|
8
9
|
|
|
9
10
|
/** Visible keyboard-focus ring for a row reached by the list's arrow-key cursor. */
|
|
10
11
|
const ROW_FOCUS_RING =
|
|
@@ -166,6 +167,9 @@ export function ComfortableRowTextContent({
|
|
|
166
167
|
{thread.category}
|
|
167
168
|
</Badge>
|
|
168
169
|
)}
|
|
170
|
+
{thread.labels?.map((label) => (
|
|
171
|
+
<LabelChip key={label.labelId} label={label} className="max-w-20" />
|
|
172
|
+
))}
|
|
169
173
|
{badge}
|
|
170
174
|
</span>
|
|
171
175
|
</span>
|
|
@@ -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/index.ts
CHANGED
|
@@ -144,11 +144,13 @@ export {
|
|
|
144
144
|
commitBlockedReason,
|
|
145
145
|
commitLabel,
|
|
146
146
|
demoFolders,
|
|
147
|
+
demoLabels,
|
|
147
148
|
demoRule,
|
|
148
149
|
demoSenderFallbackRule,
|
|
149
150
|
demoVocabularyRule,
|
|
150
151
|
type FilterRule,
|
|
151
152
|
type FolderOption,
|
|
153
|
+
type LabelOption,
|
|
152
154
|
type MatchOperator,
|
|
153
155
|
matchJoinWord,
|
|
154
156
|
matchOperatorLabel,
|
|
@@ -221,6 +223,11 @@ export {
|
|
|
221
223
|
KeyboardHintBar,
|
|
222
224
|
type KeyboardHintBarProps,
|
|
223
225
|
} from "./components/keyboard-hint-bar.js";
|
|
226
|
+
export {
|
|
227
|
+
LabelChip,
|
|
228
|
+
type LabelChipData,
|
|
229
|
+
type LabelChipProps,
|
|
230
|
+
} from "./components/label-chip.js";
|
|
224
231
|
export { ListItem, type ListItemProps } from "./components/list-item.js";
|
|
225
232
|
export {
|
|
226
233
|
type MailAction,
|
|
@@ -555,6 +562,12 @@ export {
|
|
|
555
562
|
sanitizeInlineStyle,
|
|
556
563
|
sanitizeStyleElementCss,
|
|
557
564
|
} from "./lib/email-sanitizer.js";
|
|
565
|
+
export {
|
|
566
|
+
isLabelColorValue,
|
|
567
|
+
type LabelColorValue,
|
|
568
|
+
labelColorOptions,
|
|
569
|
+
labelDotClass,
|
|
570
|
+
} from "./lib/label-color.js";
|
|
558
571
|
export {
|
|
559
572
|
LIST_ROW_ATTRIBUTE,
|
|
560
573
|
LIST_ROW_SELECTOR,
|
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";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
isLabelColorValue,
|
|
5
|
+
labelColorOptions,
|
|
6
|
+
labelDotClass,
|
|
7
|
+
} from "./label-color.js";
|
|
8
|
+
|
|
9
|
+
describe("label-color", () => {
|
|
10
|
+
it("every listed option resolves to a dot class", () => {
|
|
11
|
+
for (const color of labelColorOptions) {
|
|
12
|
+
assert.ok(labelDotClass[color]);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("recognizes every named color value", () => {
|
|
17
|
+
for (const color of labelColorOptions) {
|
|
18
|
+
assert.ok(isLabelColorValue(color));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects a value outside the named set", () => {
|
|
23
|
+
assert.equal(isLabelColorValue("Chartreuse"), false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-picked label color (RFC 030, issue #26). Unlike the app's status
|
|
3
|
+
* tones (positive/warning/danger/…), a label's color carries no meaning the
|
|
4
|
+
* design system assigns — the user picked it to tell their own labels apart —
|
|
5
|
+
* so it renders from the literal Tailwind palette rather than a semantic
|
|
6
|
+
* token.
|
|
7
|
+
*/
|
|
8
|
+
export type LabelColorValue =
|
|
9
|
+
| "Default"
|
|
10
|
+
| "Red"
|
|
11
|
+
| "Orange"
|
|
12
|
+
| "Yellow"
|
|
13
|
+
| "Green"
|
|
14
|
+
| "Teal"
|
|
15
|
+
| "Blue"
|
|
16
|
+
| "Purple"
|
|
17
|
+
| "Gray";
|
|
18
|
+
|
|
19
|
+
/** Solid dot color for a label swatch or chip. */
|
|
20
|
+
export const labelDotClass: Record<LabelColorValue, string> = {
|
|
21
|
+
Default: "bg-fg-subtle",
|
|
22
|
+
Red: "bg-red-500",
|
|
23
|
+
Orange: "bg-orange-500",
|
|
24
|
+
Yellow: "bg-yellow-500",
|
|
25
|
+
Green: "bg-green-500",
|
|
26
|
+
Teal: "bg-teal-500",
|
|
27
|
+
Blue: "bg-blue-500",
|
|
28
|
+
Purple: "bg-purple-500",
|
|
29
|
+
Gray: "bg-gray-500",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Every color a label can be assigned, in picker order. */
|
|
33
|
+
export const labelColorOptions: LabelColorValue[] = [
|
|
34
|
+
"Default",
|
|
35
|
+
"Red",
|
|
36
|
+
"Orange",
|
|
37
|
+
"Yellow",
|
|
38
|
+
"Green",
|
|
39
|
+
"Teal",
|
|
40
|
+
"Blue",
|
|
41
|
+
"Purple",
|
|
42
|
+
"Gray",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const isLabelColorValue = (value: string): value is LabelColorValue =>
|
|
46
|
+
Object.hasOwn(labelDotClass, value);
|