@remit/ui 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/app-shell-types.ts +8 -1
- package/src/components/auto-moved-badge.stories.tsx +3 -21
- package/src/components/auto-moved-badge.tsx +8 -17
- package/src/components/brief-empty.stories.tsx +51 -0
- package/src/components/brief-empty.tsx +60 -0
- package/src/components/dialog.tsx +10 -5
- package/src/components/filter-clause-chip.tsx +39 -1
- package/src/components/filter-rule-editor.stories.tsx +163 -1
- package/src/components/filter-rule-editor.tsx +47 -0
- package/src/components/filter-rule.render.test.ts +33 -0
- package/src/components/filter-rule.ts +141 -0
- package/src/components/input.tsx +7 -0
- package/src/components/mail-header.tsx +10 -0
- package/src/components/message-row.tsx +41 -3
- package/src/components/mobile-search-view.render.test.ts +31 -0
- package/src/components/mobile-search-view.stories.tsx +115 -6
- package/src/components/mobile-search-view.tsx +34 -8
- package/src/components/password-input.render.test.ts +149 -0
- package/src/components/password-input.tsx +45 -0
- package/src/components/primitives.stories.tsx +28 -0
- package/src/components/search-bar.tsx +9 -0
- package/src/components/search-chip-input.tsx +85 -3
- package/src/components/search-result-row.tsx +68 -82
- package/src/components/search-results.stories.tsx +12 -0
- package/src/components/search-results.tsx +20 -8
- package/src/components/suggest-list.render.test.ts +59 -0
- package/src/components/suggest-list.tsx +96 -0
- package/src/index.ts +55 -0
- package/src/lib/property-prefill.test.ts +173 -0
- package/src/lib/property-prefill.ts +151 -0
- package/src/lib/search-rule.test.ts +73 -0
- package/src/lib/search-rule.ts +96 -0
- package/src/lib/sender-derivation.test.ts +106 -0
- package/src/lib/sender-derivation.ts +85 -0
- package/src/lib/suggest-keys.test.ts +101 -0
- package/src/lib/suggest-keys.ts +63 -0
- package/src/lib/use-long-press.ts +70 -37
- package/src/lib/use-suggest-list.test.ts +169 -0
- package/src/lib/use-suggest-list.ts +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"react-resizable-panels": "^2.1.9",
|
|
27
27
|
"react-simple-pull-to-refresh": "^1.3.4",
|
|
28
28
|
"tailwind-merge": "^2",
|
|
29
|
+
"tldts": "^7.4.9",
|
|
29
30
|
"@types/dompurify": "*"
|
|
30
31
|
},
|
|
31
32
|
"peerDependencies": {
|
|
@@ -199,7 +199,14 @@ export interface ThreadRowLabel {
|
|
|
199
199
|
|
|
200
200
|
export interface ThreadRowData {
|
|
201
201
|
id: string;
|
|
202
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Owning IMAP account — the `accountId` path parameter of the account API,
|
|
204
|
+
* never the caller's `accountConfigId`. Optional because rows from
|
|
205
|
+
* per-mailbox listings do not carry it; a required field forced producers to
|
|
206
|
+
* substitute the always-present `accountConfigId`, which 404s every
|
|
207
|
+
* `/accounts/{accountId}/…` call made with it.
|
|
208
|
+
*/
|
|
209
|
+
accountId?: string;
|
|
203
210
|
/** Owning mailbox — used by the `in:` search-token filter. */
|
|
204
211
|
mailboxId?: string;
|
|
205
212
|
fromName: string;
|
|
@@ -10,14 +10,9 @@ export default meta;
|
|
|
10
10
|
|
|
11
11
|
type Story = StoryObj<typeof AutoMovedBadge>;
|
|
12
12
|
|
|
13
|
-
export const
|
|
14
|
-
args: { label: "Moved from Junk by Remit", size: "sm" },
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export const ReadingViewWithUndo: Story = {
|
|
13
|
+
export const WithUndo: Story = {
|
|
18
14
|
args: {
|
|
19
15
|
label: "Moved from Junk by Remit",
|
|
20
|
-
size: "md",
|
|
21
16
|
onUndo: () => alert("Undo"),
|
|
22
17
|
},
|
|
23
18
|
};
|
|
@@ -25,45 +20,32 @@ export const ReadingViewWithUndo: Story = {
|
|
|
25
20
|
export const MovedToJunk: Story = {
|
|
26
21
|
args: {
|
|
27
22
|
label: "Moved from Inbox by Remit",
|
|
28
|
-
size: "md",
|
|
29
23
|
onUndo: () => alert("Undo"),
|
|
30
24
|
},
|
|
31
25
|
};
|
|
32
26
|
|
|
33
27
|
export const WithoutUndoAction: Story = {
|
|
34
|
-
args: { label: "Moved from Junk by Remit"
|
|
28
|
+
args: { label: "Moved from Junk by Remit" },
|
|
35
29
|
};
|
|
36
30
|
|
|
37
31
|
export const FilterMoveWithManageLink: Story = {
|
|
38
32
|
args: {
|
|
39
33
|
label: "Moved from Inbox by Remit",
|
|
40
|
-
size: "md",
|
|
41
34
|
onUndo: () => alert("Undo"),
|
|
42
35
|
filtersHref: "/settings/filters",
|
|
43
36
|
},
|
|
44
37
|
};
|
|
45
38
|
|
|
46
|
-
export const FilterMoveListRow: Story = {
|
|
47
|
-
// The list row is icon + label only — the app gates the Manage link (and
|
|
48
|
-
// Undo) to the reading-view `md` size, so no `filtersHref` here.
|
|
49
|
-
args: {
|
|
50
|
-
label: "Moved from Inbox by Remit",
|
|
51
|
-
size: "sm",
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
|
|
55
39
|
export const SideBySide: Story = {
|
|
56
40
|
render: () => (
|
|
57
41
|
<div className="flex flex-col items-start gap-3">
|
|
58
|
-
<AutoMovedBadge label="Moved from Junk by Remit"
|
|
42
|
+
<AutoMovedBadge label="Moved from Junk by Remit" />
|
|
59
43
|
<AutoMovedBadge
|
|
60
44
|
label="Moved from Junk by Remit"
|
|
61
|
-
size="md"
|
|
62
45
|
onUndo={() => undefined}
|
|
63
46
|
/>
|
|
64
47
|
<AutoMovedBadge
|
|
65
48
|
label="Moved from Inbox by Remit"
|
|
66
|
-
size="md"
|
|
67
49
|
onUndo={() => undefined}
|
|
68
50
|
filtersHref="/settings/filters"
|
|
69
51
|
/>
|
|
@@ -5,8 +5,6 @@ import { Badge } from "./badge.js";
|
|
|
5
5
|
export interface AutoMovedBadgeProps {
|
|
6
6
|
/** Plain-language description, e.g. "Moved from Junk by Remit". */
|
|
7
7
|
label: string;
|
|
8
|
-
/** `md` adds an inline Undo action for the open-message header; `sm` (list row) is icon + label only. */
|
|
9
|
-
size?: "sm" | "md";
|
|
10
8
|
/**
|
|
11
9
|
* Present only when undo is available for this message. Omit to render the
|
|
12
10
|
* indicator without an action (e.g. the target folder can't be resolved).
|
|
@@ -24,16 +22,15 @@ export interface AutoMovedBadgeProps {
|
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* still in effect (so the badge should
|
|
31
|
-
* link applies are the caller's
|
|
32
|
-
* placement/mailbox state.
|
|
25
|
+
* Indicator that Remit auto-moved this message, with an optional inline
|
|
26
|
+
* one-click undo and, for a standing-filter move, a link to the filter in
|
|
27
|
+
* Settings. Sized for a message header, not a list row. Purely presentational —
|
|
28
|
+
* the label text, whether the move is still in effect (so the badge should
|
|
29
|
+
* render at all), and whether a filter link applies are the caller's
|
|
30
|
+
* responsibility; this component has no notion of placement/mailbox state.
|
|
33
31
|
*/
|
|
34
32
|
export function AutoMovedBadge({
|
|
35
33
|
label,
|
|
36
|
-
size = "sm",
|
|
37
34
|
onUndo,
|
|
38
35
|
undoLabel = "Undo",
|
|
39
36
|
filtersHref,
|
|
@@ -41,14 +38,8 @@ export function AutoMovedBadge({
|
|
|
41
38
|
className,
|
|
42
39
|
}: AutoMovedBadgeProps) {
|
|
43
40
|
return (
|
|
44
|
-
<Badge
|
|
45
|
-
|
|
46
|
-
className={cn(size === "md" && "py-1 text-xs", className)}
|
|
47
|
-
>
|
|
48
|
-
<Undo2
|
|
49
|
-
className={cn(size === "sm" ? "size-3" : "size-3.5", "shrink-0")}
|
|
50
|
-
aria-hidden
|
|
51
|
-
/>
|
|
41
|
+
<Badge tone="accent" className={cn("py-1 text-xs", className)}>
|
|
42
|
+
<Undo2 className="size-3.5 shrink-0" aria-hidden />
|
|
52
43
|
<span>{label}</span>
|
|
53
44
|
{onUndo && (
|
|
54
45
|
<button
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { BriefEmpty } from "./brief-empty.js";
|
|
3
|
+
|
|
4
|
+
/** A list pane's width, so the copy wraps the way it does in the app. */
|
|
5
|
+
const paneFrame: Decorator = (Story) => (
|
|
6
|
+
<div className="h-screen w-full bg-surface">
|
|
7
|
+
<div className="mx-auto h-full max-w-md border-x border-line">
|
|
8
|
+
<Story />
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof BriefEmpty> = {
|
|
14
|
+
title: "Screens/Kit/BriefEmpty",
|
|
15
|
+
component: BriefEmpty,
|
|
16
|
+
parameters: { layout: "fullscreen" },
|
|
17
|
+
decorators: [paneFrame],
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof BriefEmpty>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Nothing needs attention, and the app has everything it is going to get.
|
|
25
|
+
* The only state allowed to make that claim.
|
|
26
|
+
*/
|
|
27
|
+
export const CaughtUp: Story = { args: {} };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The first sync is still running (#452). The list is empty because the mail
|
|
31
|
+
* has not arrived yet, so the brief says that instead of congratulating the
|
|
32
|
+
* user on an inbox it has never seen.
|
|
33
|
+
*/
|
|
34
|
+
export const Syncing: Story = {
|
|
35
|
+
args: { sync: { synced: 812, total: 4210 } },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The first seconds of a sync: the server has not counted the mailbox yet, so
|
|
40
|
+
* there is no fraction to show. The state still reads as in-progress rather
|
|
41
|
+
* than falling back to "caught up".
|
|
42
|
+
*/
|
|
43
|
+
export const SyncingCountUnknown: Story = {
|
|
44
|
+
args: { sync: { synced: 0, total: 0 } },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Phone width (411 px) — the copy is the whole state, so it must not clip. */
|
|
48
|
+
export const SyncingPhone: Story = {
|
|
49
|
+
globals: { viewport: { value: "mobile" } },
|
|
50
|
+
args: { sync: { synced: 1204, total: 18960 } },
|
|
51
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Loader2, Sparkles } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How far the mail sync has got, for the accounts feeding the brief. `total` is
|
|
5
|
+
* 0 while the server has not counted the mailbox yet, which is a real state
|
|
6
|
+
* during the first seconds of a sync — not a missing number.
|
|
7
|
+
*/
|
|
8
|
+
export interface BriefSyncProgress {
|
|
9
|
+
synced: number;
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BriefEmptyProps {
|
|
14
|
+
/**
|
|
15
|
+
* Present only while at least one account reports an in-progress sync phase.
|
|
16
|
+
* Its presence is what separates "we have everything and there is nothing"
|
|
17
|
+
* from "we do not have it yet" — an empty brief looks identical either way,
|
|
18
|
+
* and saying "caught up" during the first sync is a false statement about
|
|
19
|
+
* the user's mail (#452).
|
|
20
|
+
*/
|
|
21
|
+
sync?: BriefSyncProgress;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The brief with no sections. Two states, because an empty list has two
|
|
26
|
+
* causes and only one of them is good news.
|
|
27
|
+
*/
|
|
28
|
+
export function BriefEmpty({ sync }: BriefEmptyProps) {
|
|
29
|
+
if (sync) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4"
|
|
33
|
+
role="status"
|
|
34
|
+
aria-live="polite"
|
|
35
|
+
>
|
|
36
|
+
<Loader2
|
|
37
|
+
className="size-8 animate-spin text-fg-subtle"
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
/>
|
|
40
|
+
<p className="text-sm font-medium text-fg">Still syncing your mail</p>
|
|
41
|
+
<p className="text-xs text-fg-subtle">{syncCopy(sync)}</p>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4">
|
|
48
|
+
<Sparkles className="size-8 text-fg-subtle" aria-hidden="true" />
|
|
49
|
+
<p className="text-sm font-medium text-fg">You're caught up</p>
|
|
50
|
+
<p className="text-xs text-fg-subtle">Nothing needs attention.</p>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function syncCopy({ synced, total }: BriefSyncProgress): string {
|
|
56
|
+
if (total > 0) {
|
|
57
|
+
return `${synced.toLocaleString()} of ${total.toLocaleString()} messages downloaded. This list fills in as they arrive.`;
|
|
58
|
+
}
|
|
59
|
+
return "Downloading your messages. This list fills in as they arrive.";
|
|
60
|
+
}
|
|
@@ -31,11 +31,16 @@ export function Dialog({
|
|
|
31
31
|
|
|
32
32
|
const handleKeyDown = useCallback(
|
|
33
33
|
(e: KeyboardEvent) => {
|
|
34
|
-
if (e.key
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
if (e.key !== "Escape") return;
|
|
35
|
+
// A control inside the dialog can own Escape while it has something of
|
|
36
|
+
// its own to close — an open suggestion list. Escape closes that first;
|
|
37
|
+
// the next Escape closes the dialog.
|
|
38
|
+
const focused = document.activeElement;
|
|
39
|
+
if (focused instanceof Element && focused.closest("[data-escape-owner]"))
|
|
40
|
+
return;
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
e.stopImmediatePropagation();
|
|
43
|
+
onClose();
|
|
39
44
|
},
|
|
40
45
|
[onClose],
|
|
41
46
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Plus, Sparkles, X } from "lucide-react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { useSuggestList } from "../lib/use-suggest-list.js";
|
|
3
4
|
import { Button } from "./button.js";
|
|
4
5
|
import {
|
|
5
6
|
type ClauseField,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "./filter-rule.js";
|
|
13
14
|
import { Input } from "./input.js";
|
|
14
15
|
import { Select } from "./select.js";
|
|
16
|
+
import { type Suggestion, SuggestList } from "./suggest-list.js";
|
|
15
17
|
|
|
16
18
|
const chipShell =
|
|
17
19
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs";
|
|
@@ -153,6 +155,13 @@ export interface ClauseEditorProps {
|
|
|
153
155
|
* match, so the editor never offers a clause the backend cannot evaluate.
|
|
154
156
|
*/
|
|
155
157
|
fields?: ClauseField[];
|
|
158
|
+
/**
|
|
159
|
+
* Values worth offering for the field being edited — known senders for a
|
|
160
|
+
* `From` clause, their domains for a `FromDomain` one. A shortcut and never a
|
|
161
|
+
* constraint: whatever is typed stands, matches or not, and a field with
|
|
162
|
+
* nothing to offer is a plain text box.
|
|
163
|
+
*/
|
|
164
|
+
suggestions?: readonly Suggestion[];
|
|
156
165
|
onChangeField?: (field: ClauseField) => void;
|
|
157
166
|
onChangeValue?: (value: string) => void;
|
|
158
167
|
onSubmit?: () => void;
|
|
@@ -163,17 +172,26 @@ export interface ClauseEditorProps {
|
|
|
163
172
|
* Inline form for adding or editing one clause — field picker plus a value
|
|
164
173
|
* input, from the design-system primitives so it never drifts from the rest of
|
|
165
174
|
* the editor. The chip it produces renders through {@link ClauseChip}.
|
|
175
|
+
*
|
|
176
|
+
* The suggestion list sits under the row in normal flow rather than floating
|
|
177
|
+
* over it, so on a phone a soft keyboard can never hide the field the list
|
|
178
|
+
* belongs to.
|
|
166
179
|
*/
|
|
167
180
|
export function ClauseEditor({
|
|
168
181
|
draft,
|
|
169
182
|
mode,
|
|
170
183
|
fields = clauseFieldOrder,
|
|
184
|
+
suggestions = [],
|
|
171
185
|
onChangeField,
|
|
172
186
|
onChangeValue,
|
|
173
187
|
onSubmit,
|
|
174
188
|
onCancel,
|
|
175
189
|
}: ClauseEditorProps) {
|
|
176
190
|
const hint = clauseFieldHint(draft.field);
|
|
191
|
+
const suggest = useSuggestList({
|
|
192
|
+
count: suggestions.length,
|
|
193
|
+
onAccept: (index) => onChangeValue?.(suggestions[index].value),
|
|
194
|
+
});
|
|
177
195
|
return (
|
|
178
196
|
<div className="space-y-1.5 rounded-lg border border-accent-2 bg-surface p-2">
|
|
179
197
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -193,11 +211,17 @@ export function ClauseEditor({
|
|
|
193
211
|
aria-label="Clause value"
|
|
194
212
|
value={draft.value}
|
|
195
213
|
placeholder="value…"
|
|
196
|
-
|
|
214
|
+
autoComplete="off"
|
|
215
|
+
onChange={(e) => {
|
|
216
|
+
suggest.reopen();
|
|
217
|
+
onChangeValue?.(e.target.value);
|
|
218
|
+
}}
|
|
197
219
|
onKeyDown={(e) => {
|
|
220
|
+
if (suggest.handleKeyDown(e)) return;
|
|
198
221
|
if (e.key === "Enter") onSubmit?.();
|
|
199
222
|
}}
|
|
200
223
|
className="h-8 min-w-40 flex-1"
|
|
224
|
+
{...suggest.comboboxProps}
|
|
201
225
|
/>
|
|
202
226
|
<Button
|
|
203
227
|
variant="primary"
|
|
@@ -217,6 +241,20 @@ export function ClauseEditor({
|
|
|
217
241
|
className="shrink-0 px-2"
|
|
218
242
|
/>
|
|
219
243
|
</div>
|
|
244
|
+
{suggest.open && (
|
|
245
|
+
<SuggestList
|
|
246
|
+
id={suggest.listId}
|
|
247
|
+
suggestions={suggestions}
|
|
248
|
+
activeIndex={suggest.activeIndex}
|
|
249
|
+
optionId={suggest.optionId}
|
|
250
|
+
onPick={(suggestion) => {
|
|
251
|
+
onChangeValue?.(suggestion.value);
|
|
252
|
+
suggest.dismiss();
|
|
253
|
+
}}
|
|
254
|
+
onHighlight={suggest.setActiveIndex}
|
|
255
|
+
label={`${clauseFieldLabel(draft.field)} suggestions`}
|
|
256
|
+
/>
|
|
257
|
+
)}
|
|
220
258
|
{hint && <p className="px-0.5 text-2xs text-fg-subtle">{hint}</p>}
|
|
221
259
|
</div>
|
|
222
260
|
);
|
|
@@ -5,16 +5,19 @@ import { FilterPreviewCount } from "./filter-preview-count.js";
|
|
|
5
5
|
import {
|
|
6
6
|
type ClauseField,
|
|
7
7
|
clauseFieldLabel,
|
|
8
|
+
demoClauseSuggestions,
|
|
8
9
|
demoFolders,
|
|
9
10
|
demoLabels,
|
|
10
11
|
demoRule,
|
|
11
12
|
demoSenderFallbackRule,
|
|
13
|
+
demoSubjectPrefillRule,
|
|
12
14
|
demoVocabularyRule,
|
|
13
15
|
type FilterRule,
|
|
14
16
|
type FolderOption,
|
|
15
17
|
type LabelOption,
|
|
16
18
|
type PreviewCount,
|
|
17
19
|
type RuleClause,
|
|
20
|
+
type RuleMatchMode,
|
|
18
21
|
} from "./filter-rule.js";
|
|
19
22
|
import {
|
|
20
23
|
type ClauseEditState,
|
|
@@ -53,12 +56,22 @@ const READY = (count: number, stale?: boolean): PreviewCount => ({
|
|
|
53
56
|
function LiveEditor({
|
|
54
57
|
initialRule,
|
|
55
58
|
semanticAvailable = true,
|
|
59
|
+
initialMatchMode,
|
|
60
|
+
propertyRule,
|
|
56
61
|
labels = demoLabels,
|
|
62
|
+
initialClauseEdit,
|
|
57
63
|
onCreateFolder,
|
|
58
64
|
onCreateLabel,
|
|
59
65
|
}: {
|
|
60
66
|
initialRule: FilterRule;
|
|
61
67
|
semanticAvailable?: boolean;
|
|
68
|
+
/** Offers the match-mode control; omit to render the editor without one. */
|
|
69
|
+
initialMatchMode?: RuleMatchMode;
|
|
70
|
+
/** Opens on the clause form, for the stories about editing a clause. */
|
|
71
|
+
initialClauseEdit?: ClauseEditState;
|
|
72
|
+
/** What the properties mode prefills — the app derives this from the
|
|
73
|
+
* selection's senders and subjects. */
|
|
74
|
+
propertyRule?: FilterRule;
|
|
62
75
|
labels?: LabelOption[];
|
|
63
76
|
onCreateFolder?: (
|
|
64
77
|
name: string,
|
|
@@ -67,7 +80,10 @@ function LiveEditor({
|
|
|
67
80
|
onCreateLabel?: (name: string) => Promise<LabelOption>;
|
|
68
81
|
}) {
|
|
69
82
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
70
|
-
const [
|
|
83
|
+
const [matchMode, setMatchMode] = useState(initialMatchMode);
|
|
84
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
85
|
+
initialClauseEdit,
|
|
86
|
+
);
|
|
71
87
|
|
|
72
88
|
const preview = useMemo<PreviewCount>(
|
|
73
89
|
() => READY(rule.clauses.length * 23 + (rule.widen ? 40 : 0)),
|
|
@@ -130,6 +146,26 @@ function LiveEditor({
|
|
|
130
146
|
onChangeMatchOperator: (matchOperator) => {
|
|
131
147
|
setRule((r) => ({ ...r, matchOperator }));
|
|
132
148
|
},
|
|
149
|
+
onChangeMatchMode: (mode) => {
|
|
150
|
+
setMatchMode(mode);
|
|
151
|
+
// The app rebuilds only the matchers, keeping the action and scope; the
|
|
152
|
+
// story does the same with its two fixture rules.
|
|
153
|
+
setRule((r) =>
|
|
154
|
+
mode === "properties"
|
|
155
|
+
? {
|
|
156
|
+
...r,
|
|
157
|
+
clauses: (propertyRule ?? demoSenderFallbackRule).clauses,
|
|
158
|
+
matchOperator: "any",
|
|
159
|
+
widen: undefined,
|
|
160
|
+
}
|
|
161
|
+
: {
|
|
162
|
+
...r,
|
|
163
|
+
clauses: initialRule.clauses,
|
|
164
|
+
matchOperator: initialRule.matchOperator,
|
|
165
|
+
widen: initialRule.widen ?? { anchorCount: 2 },
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
},
|
|
133
169
|
onChangeMove: (moveMailboxId) =>
|
|
134
170
|
setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
|
|
135
171
|
onChangeLabel: (labelId) =>
|
|
@@ -146,7 +182,16 @@ function LiveEditor({
|
|
|
146
182
|
labels={labels}
|
|
147
183
|
preview={preview}
|
|
148
184
|
semanticAvailable={semanticAvailable}
|
|
185
|
+
matchMode={matchMode}
|
|
149
186
|
clauseEdit={clauseEdit}
|
|
187
|
+
clauseSuggestions={
|
|
188
|
+
clauseEdit
|
|
189
|
+
? demoClauseSuggestions(
|
|
190
|
+
clauseEdit.draft.field,
|
|
191
|
+
clauseEdit.draft.value,
|
|
192
|
+
)
|
|
193
|
+
: undefined
|
|
194
|
+
}
|
|
150
195
|
onCreateFolder={onCreateFolder}
|
|
151
196
|
onCreateLabel={onCreateLabel}
|
|
152
197
|
onCommit={() => {}}
|
|
@@ -161,6 +206,123 @@ export const Interactive: Story = {
|
|
|
161
206
|
render: () => <LiveEditor initialRule={demoRule} />,
|
|
162
207
|
};
|
|
163
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The Organize surface, where the rule can be matched either way. It opens on
|
|
211
|
+
* "Anything similar" — semantic is still the default — and switching to
|
|
212
|
+
* "Its properties" drops the widen for the clauses derived from the messages
|
|
213
|
+
* that were selected. Switch back and the widen returns.
|
|
214
|
+
*/
|
|
215
|
+
export const MatchModeSemanticDefault: Story = {
|
|
216
|
+
render: () => (
|
|
217
|
+
<LiveEditor
|
|
218
|
+
initialRule={{ ...demoRule, clauses: [] }}
|
|
219
|
+
initialMatchMode="similar"
|
|
220
|
+
propertyRule={demoSenderFallbackRule}
|
|
221
|
+
/>
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Properties matching with one sender behind the whole selection — a single
|
|
227
|
+
* `From` chip, no semantics involved. Editable like any other chip.
|
|
228
|
+
*/
|
|
229
|
+
export const MatchOnSenderProperty: Story = {
|
|
230
|
+
render: () => (
|
|
231
|
+
<LiveEditor
|
|
232
|
+
initialRule={{
|
|
233
|
+
...demoSenderFallbackRule,
|
|
234
|
+
clauses: demoSenderFallbackRule.clauses.slice(0, 1),
|
|
235
|
+
scope: "once",
|
|
236
|
+
name: "",
|
|
237
|
+
}}
|
|
238
|
+
initialMatchMode="properties"
|
|
239
|
+
propertyRule={demoSenderFallbackRule}
|
|
240
|
+
/>
|
|
241
|
+
),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Properties matching when the senders differ: the prefill falls back to the
|
|
246
|
+
* part the selected subjects share, so the rule still starts somewhere useful.
|
|
247
|
+
*/
|
|
248
|
+
export const MatchOnSubjectProperty: Story = {
|
|
249
|
+
render: () => (
|
|
250
|
+
<LiveEditor
|
|
251
|
+
initialRule={demoSubjectPrefillRule}
|
|
252
|
+
initialMatchMode="properties"
|
|
253
|
+
propertyRule={demoSubjectPrefillRule}
|
|
254
|
+
/>
|
|
255
|
+
),
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Adding a `From` clause, with the value field offering what it knows: the
|
|
260
|
+
* senders of the messages that were selected lead the list — marked "selected",
|
|
261
|
+
* and there before a key is pressed — followed by other known addresses. Typing
|
|
262
|
+
* narrows it; anything typed still stands, match or no match.
|
|
263
|
+
*/
|
|
264
|
+
export const ClauseValueSuggestions: Story = {
|
|
265
|
+
render: () => (
|
|
266
|
+
<LiveEditor
|
|
267
|
+
initialRule={{ ...demoRule, widen: undefined }}
|
|
268
|
+
semanticAvailable={false}
|
|
269
|
+
initialClauseEdit={{ mode: "add", draft: { field: "From", value: "" } }}
|
|
270
|
+
/>
|
|
271
|
+
),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The same field on a `Domain` clause: the addresses collapse to registrable
|
|
276
|
+
* domains, so the value offered is exactly the string the matcher compares.
|
|
277
|
+
*/
|
|
278
|
+
export const DomainClauseSuggestions: Story = {
|
|
279
|
+
render: () => (
|
|
280
|
+
<LiveEditor
|
|
281
|
+
initialRule={{ ...demoRule, widen: undefined }}
|
|
282
|
+
semanticAvailable={false}
|
|
283
|
+
initialClauseEdit={{
|
|
284
|
+
mode: "add",
|
|
285
|
+
draft: { field: "FromDomain", value: "" },
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Typing something no known sender matches. The list simply goes away — no
|
|
293
|
+
* empty panel, no warning, no block on the value. A clause for an address that
|
|
294
|
+
* has not written yet is a legitimate rule.
|
|
295
|
+
*/
|
|
296
|
+
export const ClauseValueNoMatches: Story = {
|
|
297
|
+
render: () => (
|
|
298
|
+
<LiveEditor
|
|
299
|
+
initialRule={{ ...demoRule, widen: undefined }}
|
|
300
|
+
semanticAvailable={false}
|
|
301
|
+
initialClauseEdit={{
|
|
302
|
+
mode: "add",
|
|
303
|
+
draft: { field: "From", value: "someone@nowhere.test" },
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A free-text field is left alone: `Subject` has nothing to draw suggestions
|
|
311
|
+
* from, so it stays the plain box it was.
|
|
312
|
+
*/
|
|
313
|
+
export const SubjectClauseStaysFreeText: Story = {
|
|
314
|
+
render: () => (
|
|
315
|
+
<LiveEditor
|
|
316
|
+
initialRule={{ ...demoRule, widen: undefined }}
|
|
317
|
+
semanticAvailable={false}
|
|
318
|
+
initialClauseEdit={{
|
|
319
|
+
mode: "add",
|
|
320
|
+
draft: { field: "Subject", value: "receipt" },
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
),
|
|
324
|
+
};
|
|
325
|
+
|
|
164
326
|
let newFolderSeq = 0;
|
|
165
327
|
const mockCreateFolder = (name: string): Promise<FolderOption> =>
|
|
166
328
|
new Promise((resolve) => {
|