@remit/ui 0.0.50 → 0.0.52
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 +87 -0
- package/src/components/filter-rule-editor.tsx +172 -9
- package/src/components/filter-rule.render.test.ts +45 -6
- package/src/components/filter-rule.ts +26 -9
- package/src/components/filter-sheet.stories.tsx +16 -0
- package/src/components/label-chip.render.test.ts +45 -0
- package/src/components/label-chip.tsx +49 -0
- package/src/components/message-list-pane.tsx +17 -1
- package/src/components/message-list-state.render.test.ts +168 -7
- package/src/components/message-list-state.stories.tsx +328 -0
- package/src/components/message-list-state.tsx +136 -12
- package/src/components/message-row.render.test.ts +67 -1
- package/src/components/message-row.tsx +4 -0
- package/src/filter-presets.test.ts +25 -0
- package/src/filter-presets.ts +21 -0
- package/src/index.ts +17 -0
- package/src/lib/label-color.test.ts +25 -0
- package/src/lib/label-color.ts +46 -0
- package/src/stories-viewport-globals.test.ts +24 -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,14 +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;
|
|
62
|
+
labels?: LabelOption[];
|
|
58
63
|
onCreateFolder?: (
|
|
59
64
|
name: string,
|
|
60
65
|
signal?: AbortSignal,
|
|
61
66
|
) => Promise<FolderOption>;
|
|
67
|
+
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
62
68
|
}) {
|
|
63
69
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
64
70
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
@@ -126,6 +132,8 @@ function LiveEditor({
|
|
|
126
132
|
},
|
|
127
133
|
onChangeMove: (moveMailboxId) =>
|
|
128
134
|
setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
|
|
135
|
+
onChangeLabel: (labelId) =>
|
|
136
|
+
setRule((r) => ({ ...r, labelId: labelId || undefined })),
|
|
129
137
|
onChangeScope: (scope) => setRule((r) => ({ ...r, scope })),
|
|
130
138
|
onChangeName: (name) => setRule((r) => ({ ...r, name })),
|
|
131
139
|
onChangeUntil: (until) => setRule((r) => ({ ...r, until })),
|
|
@@ -135,10 +143,12 @@ function LiveEditor({
|
|
|
135
143
|
<FilterRuleEditor
|
|
136
144
|
rule={rule}
|
|
137
145
|
folders={demoFolders}
|
|
146
|
+
labels={labels}
|
|
138
147
|
preview={preview}
|
|
139
148
|
semanticAvailable={semanticAvailable}
|
|
140
149
|
clauseEdit={clauseEdit}
|
|
141
150
|
onCreateFolder={onCreateFolder}
|
|
151
|
+
onCreateLabel={onCreateLabel}
|
|
142
152
|
onCommit={() => {}}
|
|
143
153
|
onCancel={() => {}}
|
|
144
154
|
{...handlers}
|
|
@@ -349,6 +359,83 @@ export const NewFolderCreateCancelledMidWait: Story = {
|
|
|
349
359
|
},
|
|
350
360
|
};
|
|
351
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
|
+
|
|
352
439
|
/** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
|
|
353
440
|
export const AnyOfTheseClauses: Story = {
|
|
354
441
|
render: () => <LiveEditor initialRule={demoVocabularyRule} />,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
commitLabel,
|
|
25
25
|
type FilterRule,
|
|
26
26
|
type FolderOption,
|
|
27
|
+
type LabelOption,
|
|
27
28
|
type MatchOperator,
|
|
28
29
|
matchJoinWord,
|
|
29
30
|
matchOperatorLabel,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type RuleScope,
|
|
32
33
|
} from "./filter-rule.js";
|
|
33
34
|
import { Input } from "./input.js";
|
|
35
|
+
import { LabelChip } from "./label-chip.js";
|
|
34
36
|
import { SegmentedControl } from "./segmented-control.js";
|
|
35
37
|
import { Select } from "./select.js";
|
|
36
38
|
|
|
@@ -44,6 +46,8 @@ export interface ClauseEditState {
|
|
|
44
46
|
export interface FilterRuleEditorProps {
|
|
45
47
|
rule: FilterRule;
|
|
46
48
|
folders: FolderOption[];
|
|
49
|
+
/** The account's labels the apply-label action can target (issue #26). */
|
|
50
|
+
labels?: LabelOption[];
|
|
47
51
|
preview: PreviewCount;
|
|
48
52
|
/**
|
|
49
53
|
* Content rendered above the clause chips — the filter-from-search conversion
|
|
@@ -102,6 +106,13 @@ export interface FilterRuleEditorProps {
|
|
|
102
106
|
name: string,
|
|
103
107
|
signal?: AbortSignal,
|
|
104
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.
|
|
114
|
+
*/
|
|
115
|
+
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
105
116
|
onChangeScope?: (scope: RuleScope) => void;
|
|
106
117
|
onChangeName?: (name: string) => void;
|
|
107
118
|
onChangeUntil?: (date: string) => void;
|
|
@@ -121,6 +132,7 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
|
121
132
|
];
|
|
122
133
|
|
|
123
134
|
const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
135
|
+
const CREATE_LABEL_VALUE = "__filter_create_label__";
|
|
124
136
|
|
|
125
137
|
/**
|
|
126
138
|
* The move-to destination select, plus an inline "New folder…" affordance when
|
|
@@ -284,9 +296,157 @@ function MoveDestinationField({
|
|
|
284
296
|
);
|
|
285
297
|
}
|
|
286
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"}
|
|
430
|
+
</Button>
|
|
431
|
+
<Button
|
|
432
|
+
variant="ghost"
|
|
433
|
+
size="sm"
|
|
434
|
+
onClick={cancel}
|
|
435
|
+
disabled={pending}
|
|
436
|
+
>
|
|
437
|
+
Cancel
|
|
438
|
+
</Button>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
287
446
|
export function FilterRuleEditor({
|
|
288
447
|
rule,
|
|
289
448
|
folders,
|
|
449
|
+
labels = [],
|
|
290
450
|
preview,
|
|
291
451
|
notice,
|
|
292
452
|
semanticAvailable = false,
|
|
@@ -305,6 +465,8 @@ export function FilterRuleEditor({
|
|
|
305
465
|
onChangeMatchOperator,
|
|
306
466
|
onChangeMove,
|
|
307
467
|
onCreateFolder,
|
|
468
|
+
onChangeLabel,
|
|
469
|
+
onCreateLabel,
|
|
308
470
|
onChangeScope,
|
|
309
471
|
onChangeName,
|
|
310
472
|
onChangeUntil,
|
|
@@ -419,15 +581,16 @@ export function FilterRuleEditor({
|
|
|
419
581
|
onChangeMove={onChangeMove}
|
|
420
582
|
onCreateFolder={onCreateFolder}
|
|
421
583
|
/>
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
+
/>
|
|
431
594
|
</section>
|
|
432
595
|
|
|
433
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" },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { UNCLASSIFIED_CATEGORY } from "../filter-presets.js";
|
|
3
4
|
import {
|
|
4
5
|
FilterSheet,
|
|
5
6
|
type FilterSheetCategory,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
const CATEGORIES: FilterSheetCategory[] = [
|
|
11
12
|
{ id: "all", label: "All", tone: "neutral" },
|
|
12
13
|
{ id: "personal", label: "Personal", tone: "positive" },
|
|
14
|
+
UNCLASSIFIED_CATEGORY,
|
|
13
15
|
{ id: "newsletters", label: "Newsletters", tone: "accent" },
|
|
14
16
|
{ id: "marketing", label: "Marketing", tone: "warning" },
|
|
15
17
|
{ id: "automated", label: "Automated", tone: "neutral" },
|
|
@@ -127,6 +129,20 @@ export const CollapsedWithActiveFilters: Story = {
|
|
|
127
129
|
),
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Unclassified selected. Mail the classifier has not reached is a filterable
|
|
134
|
+
* value of its own and never folds into Personal (issue #45), so the chip and
|
|
135
|
+
* its collapsed summary carry their own label and tone.
|
|
136
|
+
*/
|
|
137
|
+
export const CollapsedWithUnclassified: Story = {
|
|
138
|
+
render: () => (
|
|
139
|
+
<ControlledShell
|
|
140
|
+
initialExpanded={false}
|
|
141
|
+
initialCategory={UNCLASSIFIED_CATEGORY.id}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
|
|
130
146
|
export const CollapsedEmpty: Story = {
|
|
131
147
|
render: () => <ControlledShell initialExpanded={false} />,
|
|
132
148
|
};
|
|
@@ -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
|
+
}
|