@remit/ui 0.0.41 → 0.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/filter-clause-chip.tsx +211 -0
- package/src/components/filter-preview-count.tsx +57 -0
- package/src/components/filter-rule-editor.stories.tsx +336 -0
- package/src/components/filter-rule-editor.tsx +303 -0
- package/src/components/filter-rule.render.test.ts +547 -0
- package/src/components/filter-rule.ts +230 -0
- package/src/index.ts +47 -0
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Plus, Sparkles, X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import {
|
|
5
|
+
type ClauseField,
|
|
6
|
+
clauseFieldLabel,
|
|
7
|
+
clauseFieldOrder,
|
|
8
|
+
type RuleClause,
|
|
9
|
+
type RuleWiden,
|
|
10
|
+
widenChipLabel,
|
|
11
|
+
} from "./filter-rule.js";
|
|
12
|
+
import { Input } from "./input.js";
|
|
13
|
+
import { Select } from "./select.js";
|
|
14
|
+
|
|
15
|
+
const chipShell =
|
|
16
|
+
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs";
|
|
17
|
+
|
|
18
|
+
export interface ClauseChipProps {
|
|
19
|
+
clause: RuleClause;
|
|
20
|
+
onEdit?: () => void;
|
|
21
|
+
onRemove?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ClauseChip({ clause, onEdit, onRemove }: ClauseChipProps) {
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className={cn(
|
|
28
|
+
chipShell,
|
|
29
|
+
clause.derived
|
|
30
|
+
? "border-dashed border-line-strong bg-surface-sunken"
|
|
31
|
+
: "border-line bg-surface",
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onEdit}
|
|
37
|
+
aria-label={`Edit ${clauseFieldLabel(clause.field)} clause`}
|
|
38
|
+
className="flex items-center gap-1 text-left"
|
|
39
|
+
>
|
|
40
|
+
<span className="font-medium text-fg-muted">
|
|
41
|
+
{clauseFieldLabel(clause.field)}
|
|
42
|
+
</span>
|
|
43
|
+
<span className="text-fg">{clause.value}</span>
|
|
44
|
+
</button>
|
|
45
|
+
{clause.derived && (
|
|
46
|
+
<span className="text-2xs text-fg-subtle">from sender</span>
|
|
47
|
+
)}
|
|
48
|
+
{onRemove && (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onRemove}
|
|
52
|
+
aria-label={`Remove ${clauseFieldLabel(clause.field)} clause`}
|
|
53
|
+
className="flex size-4 items-center justify-center rounded-full text-fg-subtle hover:bg-surface-sunken hover:text-fg-muted"
|
|
54
|
+
>
|
|
55
|
+
<X className="size-3" />
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WidenChipProps {
|
|
63
|
+
widen: RuleWiden;
|
|
64
|
+
onRemove?: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function WidenRemoveButton({ onRemove }: { onRemove: () => void }) {
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={onRemove}
|
|
72
|
+
aria-label="Remove the similar-mail widen"
|
|
73
|
+
className="flex size-4 items-center justify-center rounded-full hover:bg-fg-subtle/20"
|
|
74
|
+
>
|
|
75
|
+
<X className="size-3" />
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The semantic widen as one chip (RFC 038 D3). Active it reads "…and anything
|
|
82
|
+
* similar" with the anchor count; inactive it says the deployment cannot
|
|
83
|
+
* evaluate it and the rule matches by its literal clauses only (D4). Either
|
|
84
|
+
* way it is removable like any other chip — a dead anchor is exactly what a
|
|
85
|
+
* user needs to be able to take off a degraded rule.
|
|
86
|
+
*/
|
|
87
|
+
export function WidenChip({ widen, onRemove }: WidenChipProps) {
|
|
88
|
+
if (widen.inactive) {
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
className={cn(
|
|
92
|
+
chipShell,
|
|
93
|
+
"border-dashed border-line-strong bg-surface-sunken text-fg-subtle",
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
<Sparkles className="size-3 shrink-0" aria-hidden="true" />
|
|
97
|
+
<span className="line-through">{widenChipLabel(widen)}</span>
|
|
98
|
+
<span className="text-2xs">not available here</span>
|
|
99
|
+
{onRemove && <WidenRemoveButton onRemove={onRemove} />}
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<span
|
|
106
|
+
className={cn(
|
|
107
|
+
chipShell,
|
|
108
|
+
"border-accent-2 bg-accent-2-soft text-accent-2",
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<Sparkles className="size-3 shrink-0" aria-hidden="true" />
|
|
112
|
+
<span className="font-medium">…and anything similar</span>
|
|
113
|
+
<span className="text-2xs opacity-80">{widenChipLabel(widen)}</span>
|
|
114
|
+
{onRemove && <WidenRemoveButton onRemove={onRemove} />}
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface AddChipButtonProps {
|
|
120
|
+
label: string;
|
|
121
|
+
onClick?: () => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function AddChipButton({ label, onClick }: AddChipButtonProps) {
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={onClick}
|
|
129
|
+
className={cn(
|
|
130
|
+
chipShell,
|
|
131
|
+
"border-dashed border-line-strong text-fg-muted hover:border-accent-2 hover:text-accent-2",
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<Plus className="size-3" aria-hidden="true" />
|
|
135
|
+
{label}
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ClauseDraft {
|
|
141
|
+
field: ClauseField;
|
|
142
|
+
value: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ClauseEditorProps {
|
|
146
|
+
draft: ClauseDraft;
|
|
147
|
+
/** `add` seeds a new clause; `edit` amends an existing one. */
|
|
148
|
+
mode: "add" | "edit";
|
|
149
|
+
onChangeField?: (field: ClauseField) => void;
|
|
150
|
+
onChangeValue?: (value: string) => void;
|
|
151
|
+
onSubmit?: () => void;
|
|
152
|
+
onCancel?: () => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Inline form for adding or editing one clause — field picker plus a value
|
|
157
|
+
* input, from the design-system primitives so it never drifts from the rest of
|
|
158
|
+
* the editor. The chip it produces renders through {@link ClauseChip}.
|
|
159
|
+
*/
|
|
160
|
+
export function ClauseEditor({
|
|
161
|
+
draft,
|
|
162
|
+
mode,
|
|
163
|
+
onChangeField,
|
|
164
|
+
onChangeValue,
|
|
165
|
+
onSubmit,
|
|
166
|
+
onCancel,
|
|
167
|
+
}: ClauseEditorProps) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent-2 bg-surface p-2">
|
|
170
|
+
<Select
|
|
171
|
+
aria-label="Clause field"
|
|
172
|
+
value={draft.field}
|
|
173
|
+
onChange={(e) => onChangeField?.(e.target.value as ClauseField)}
|
|
174
|
+
className="h-8 w-32 shrink-0"
|
|
175
|
+
>
|
|
176
|
+
{clauseFieldOrder.map((field) => (
|
|
177
|
+
<option key={field} value={field}>
|
|
178
|
+
{clauseFieldLabel(field)}
|
|
179
|
+
</option>
|
|
180
|
+
))}
|
|
181
|
+
</Select>
|
|
182
|
+
<Input
|
|
183
|
+
aria-label="Clause value"
|
|
184
|
+
value={draft.value}
|
|
185
|
+
placeholder="value…"
|
|
186
|
+
onChange={(e) => onChangeValue?.(e.target.value)}
|
|
187
|
+
onKeyDown={(e) => {
|
|
188
|
+
if (e.key === "Enter") onSubmit?.();
|
|
189
|
+
}}
|
|
190
|
+
className="h-8 min-w-40 flex-1"
|
|
191
|
+
/>
|
|
192
|
+
<Button
|
|
193
|
+
variant="primary"
|
|
194
|
+
size="sm"
|
|
195
|
+
onClick={onSubmit}
|
|
196
|
+
disabled={draft.value.trim() === ""}
|
|
197
|
+
className="shrink-0"
|
|
198
|
+
>
|
|
199
|
+
{mode === "add" ? "Add" : "Save"}
|
|
200
|
+
</Button>
|
|
201
|
+
<Button
|
|
202
|
+
variant="ghost"
|
|
203
|
+
size="sm"
|
|
204
|
+
onClick={onCancel}
|
|
205
|
+
aria-label="Cancel clause edit"
|
|
206
|
+
icon={<X className="size-4" />}
|
|
207
|
+
className="shrink-0 px-2"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { type PreviewCount, previewCountSummary } from "./filter-rule.js";
|
|
4
|
+
|
|
5
|
+
export interface FilterPreviewCountProps {
|
|
6
|
+
preview: PreviewCount;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The live match count (RFC 038 D1). It never goes blank between counts: a
|
|
11
|
+
* clause change flips the last count to `stale` and says it is recounting
|
|
12
|
+
* rather than dropping to zero and reading as "nothing matches".
|
|
13
|
+
*/
|
|
14
|
+
export function FilterPreviewCount({ preview }: FilterPreviewCountProps) {
|
|
15
|
+
const summary = previewCountSummary(preview);
|
|
16
|
+
|
|
17
|
+
if (preview.status === "loading") {
|
|
18
|
+
return (
|
|
19
|
+
<p
|
|
20
|
+
className="flex items-center gap-2 text-xs text-fg-muted"
|
|
21
|
+
aria-live="polite"
|
|
22
|
+
>
|
|
23
|
+
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
|
24
|
+
{summary}
|
|
25
|
+
</p>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (preview.status === "error") {
|
|
30
|
+
return (
|
|
31
|
+
<p className="flex items-center gap-2 text-xs text-danger" role="alert">
|
|
32
|
+
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
33
|
+
{summary}
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const empty = preview.count === 0;
|
|
39
|
+
return (
|
|
40
|
+
<p
|
|
41
|
+
className={cn(
|
|
42
|
+
"flex items-center gap-2 text-xs",
|
|
43
|
+
preview.stale
|
|
44
|
+
? "text-fg-subtle"
|
|
45
|
+
: empty
|
|
46
|
+
? "text-fg-muted"
|
|
47
|
+
: "font-medium text-fg",
|
|
48
|
+
)}
|
|
49
|
+
aria-live="polite"
|
|
50
|
+
>
|
|
51
|
+
{preview.stale && (
|
|
52
|
+
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
|
|
53
|
+
)}
|
|
54
|
+
{summary}
|
|
55
|
+
</p>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { ClauseChip, ClauseEditor, WidenChip } from "./filter-clause-chip.js";
|
|
4
|
+
import { FilterPreviewCount } from "./filter-preview-count.js";
|
|
5
|
+
import {
|
|
6
|
+
type ClauseField,
|
|
7
|
+
clauseFieldLabel,
|
|
8
|
+
demoFolders,
|
|
9
|
+
demoRule,
|
|
10
|
+
demoSenderFallbackRule,
|
|
11
|
+
demoVocabularyRule,
|
|
12
|
+
type FilterRule,
|
|
13
|
+
type PreviewCount,
|
|
14
|
+
type RuleClause,
|
|
15
|
+
} from "./filter-rule.js";
|
|
16
|
+
import {
|
|
17
|
+
type ClauseEditState,
|
|
18
|
+
FilterRuleEditor,
|
|
19
|
+
type FilterRuleEditorProps,
|
|
20
|
+
} from "./filter-rule-editor.js";
|
|
21
|
+
|
|
22
|
+
const meta: Meta<typeof FilterRuleEditor> = {
|
|
23
|
+
title: "FilterRuleEditor",
|
|
24
|
+
component: FilterRuleEditor,
|
|
25
|
+
parameters: { layout: "padded" },
|
|
26
|
+
decorators: [
|
|
27
|
+
(Story) => (
|
|
28
|
+
<div className="mx-auto max-w-md rounded-xl border border-line bg-surface">
|
|
29
|
+
<Story />
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
export default meta;
|
|
35
|
+
|
|
36
|
+
type Story = StoryObj<typeof FilterRuleEditor>;
|
|
37
|
+
|
|
38
|
+
const READY = (count: number, stale?: boolean): PreviewCount => ({
|
|
39
|
+
status: "ready",
|
|
40
|
+
count,
|
|
41
|
+
stale,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A fully wired editor so every affordance is live in the story — add, edit and
|
|
46
|
+
* remove clauses, toggle the operator and scope, watch the count follow. The
|
|
47
|
+
* count settles instantly here so the commit stays usable; the loading and
|
|
48
|
+
* recounting states have their own stories. State is local; no app, no network.
|
|
49
|
+
*/
|
|
50
|
+
function LiveEditor({
|
|
51
|
+
initialRule,
|
|
52
|
+
semanticAvailable = true,
|
|
53
|
+
}: {
|
|
54
|
+
initialRule: FilterRule;
|
|
55
|
+
semanticAvailable?: boolean;
|
|
56
|
+
}) {
|
|
57
|
+
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
58
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
59
|
+
|
|
60
|
+
const preview = useMemo<PreviewCount>(
|
|
61
|
+
() => READY(rule.clauses.length * 23 + (rule.widen ? 40 : 0)),
|
|
62
|
+
[rule],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const handlers: Partial<FilterRuleEditorProps> = {
|
|
66
|
+
onStartAddClause: () =>
|
|
67
|
+
setClauseEdit({ mode: "add", draft: { field: "From", value: "" } }),
|
|
68
|
+
onStartEditClause: (clauseId) => {
|
|
69
|
+
const clause = rule.clauses.find((c) => c.id === clauseId);
|
|
70
|
+
if (!clause) return;
|
|
71
|
+
setClauseEdit({
|
|
72
|
+
mode: "edit",
|
|
73
|
+
clauseId,
|
|
74
|
+
draft: { field: clause.field, value: clause.value },
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
onRemoveClause: (clauseId) => {
|
|
78
|
+
setRule((r) => ({
|
|
79
|
+
...r,
|
|
80
|
+
clauses: r.clauses.filter((c) => c.id !== clauseId),
|
|
81
|
+
}));
|
|
82
|
+
},
|
|
83
|
+
onChangeDraftField: (field) =>
|
|
84
|
+
setClauseEdit((e) => (e ? { ...e, draft: { ...e.draft, field } } : e)),
|
|
85
|
+
onChangeDraftValue: (value) =>
|
|
86
|
+
setClauseEdit((e) => (e ? { ...e, draft: { ...e.draft, value } } : e)),
|
|
87
|
+
onSubmitClause: () => {
|
|
88
|
+
setClauseEdit((edit) => {
|
|
89
|
+
if (!edit) return undefined;
|
|
90
|
+
setRule((r) => {
|
|
91
|
+
if (edit.mode === "add") {
|
|
92
|
+
const clause: RuleClause = {
|
|
93
|
+
id: `c-${Date.now()}`,
|
|
94
|
+
field: edit.draft.field,
|
|
95
|
+
value: edit.draft.value,
|
|
96
|
+
};
|
|
97
|
+
return { ...r, clauses: [...r.clauses, clause] };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...r,
|
|
101
|
+
clauses: r.clauses.map((c) =>
|
|
102
|
+
c.id === edit.clauseId
|
|
103
|
+
? { ...c, field: edit.draft.field, value: edit.draft.value }
|
|
104
|
+
: c,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
return undefined;
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
onCancelClause: () => setClauseEdit(undefined),
|
|
112
|
+
onAddWiden: () => {
|
|
113
|
+
setRule((r) => ({ ...r, widen: { anchorCount: 2 } }));
|
|
114
|
+
},
|
|
115
|
+
onRemoveWiden: () => {
|
|
116
|
+
setRule((r) => ({ ...r, widen: undefined }));
|
|
117
|
+
},
|
|
118
|
+
onChangeMatchOperator: (matchOperator) => {
|
|
119
|
+
setRule((r) => ({ ...r, matchOperator }));
|
|
120
|
+
},
|
|
121
|
+
onChangeMove: (moveMailboxId) =>
|
|
122
|
+
setRule((r) => ({ ...r, moveMailboxId: moveMailboxId || undefined })),
|
|
123
|
+
onChangeScope: (scope) => setRule((r) => ({ ...r, scope })),
|
|
124
|
+
onChangeName: (name) => setRule((r) => ({ ...r, name })),
|
|
125
|
+
onChangeUntil: (until) => setRule((r) => ({ ...r, until })),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<FilterRuleEditor
|
|
130
|
+
rule={rule}
|
|
131
|
+
folders={demoFolders}
|
|
132
|
+
preview={preview}
|
|
133
|
+
semanticAvailable={semanticAvailable}
|
|
134
|
+
clauseEdit={clauseEdit}
|
|
135
|
+
onCommit={() => {}}
|
|
136
|
+
onCancel={() => {}}
|
|
137
|
+
{...handlers}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The full editor, interactive — the shape ticket B and the app consume. */
|
|
143
|
+
export const Interactive: Story = {
|
|
144
|
+
render: () => <LiveEditor initialRule={demoRule} />,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
|
|
148
|
+
export const AnyOfTheseClauses: Story = {
|
|
149
|
+
render: () => <LiveEditor initialRule={demoVocabularyRule} />,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** A one-time rule — no name, no widen, the commit reads "Apply now". */
|
|
153
|
+
export const OneTimeMove: Story = {
|
|
154
|
+
args: {
|
|
155
|
+
rule: {
|
|
156
|
+
clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
|
|
157
|
+
matchOperator: "all",
|
|
158
|
+
moveMailboxId: "mbx-receipts",
|
|
159
|
+
scope: "once",
|
|
160
|
+
},
|
|
161
|
+
folders: demoFolders,
|
|
162
|
+
preview: READY(12),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Standing scope with a widen — the "keep doing this" rule. */
|
|
167
|
+
export const StandingWithWiden: Story = {
|
|
168
|
+
args: {
|
|
169
|
+
rule: demoRule,
|
|
170
|
+
folders: demoFolders,
|
|
171
|
+
preview: READY(47),
|
|
172
|
+
semanticAvailable: true,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Timed scope — the name and a date the rule stops on. */
|
|
177
|
+
export const UntilADate: Story = {
|
|
178
|
+
args: {
|
|
179
|
+
rule: {
|
|
180
|
+
...demoRule,
|
|
181
|
+
scope: "until",
|
|
182
|
+
until: "2026-09-01",
|
|
183
|
+
name: "Conference",
|
|
184
|
+
},
|
|
185
|
+
folders: demoFolders,
|
|
186
|
+
preview: READY(31),
|
|
187
|
+
semanticAvailable: true,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The sender-fallback (#251) as chips: the derived From clauses are ordinary
|
|
193
|
+
* visible, editable chips, not an invisible substitution.
|
|
194
|
+
*/
|
|
195
|
+
export const SenderFallbackChips: Story = {
|
|
196
|
+
render: () => (
|
|
197
|
+
<LiveEditor
|
|
198
|
+
initialRule={demoSenderFallbackRule}
|
|
199
|
+
semanticAvailable={false}
|
|
200
|
+
/>
|
|
201
|
+
),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The deployment cannot serve the widen (RFC 038 D4): the "…and similar" add is
|
|
206
|
+
* not offered and the rule matches by its literal clauses only.
|
|
207
|
+
*/
|
|
208
|
+
export const SemanticUnavailable: Story = {
|
|
209
|
+
args: {
|
|
210
|
+
rule: { ...demoRule, widen: undefined },
|
|
211
|
+
folders: demoFolders,
|
|
212
|
+
preview: READY(24),
|
|
213
|
+
semanticAvailable: false,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A standing rule that carries an anchor this deployment cannot evaluate — the
|
|
219
|
+
* widen chip lists as inactive and nothing claims similarity is running.
|
|
220
|
+
*/
|
|
221
|
+
export const DegradedStandingWiden: Story = {
|
|
222
|
+
args: {
|
|
223
|
+
rule: { ...demoRule, widen: { anchorCount: 2, inactive: true } },
|
|
224
|
+
folders: demoFolders,
|
|
225
|
+
preview: READY(19),
|
|
226
|
+
semanticAvailable: false,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Nothing to match yet — the commit says why it is blocked. */
|
|
231
|
+
export const BlockedEmpty: Story = {
|
|
232
|
+
args: {
|
|
233
|
+
rule: {
|
|
234
|
+
clauses: [],
|
|
235
|
+
matchOperator: "all",
|
|
236
|
+
scope: "once",
|
|
237
|
+
},
|
|
238
|
+
folders: demoFolders,
|
|
239
|
+
preview: READY(0),
|
|
240
|
+
semanticAvailable: true,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The live count while it recomputes — the commit waits for it to settle. */
|
|
245
|
+
export const PreviewLoading: Story = {
|
|
246
|
+
args: {
|
|
247
|
+
rule: demoRule,
|
|
248
|
+
folders: demoFolders,
|
|
249
|
+
preview: { status: "loading" },
|
|
250
|
+
semanticAvailable: true,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The previewed set changed under the rule — recounting, never blank, and the
|
|
256
|
+
* "Save rule" button is held disabled until the count that will be applied is
|
|
257
|
+
* the count on screen (RFC 038's previewed-set-equals-applied-set contract).
|
|
258
|
+
*/
|
|
259
|
+
export const PreviewStale: Story = {
|
|
260
|
+
args: {
|
|
261
|
+
rule: demoRule,
|
|
262
|
+
folders: demoFolders,
|
|
263
|
+
preview: READY(47, true),
|
|
264
|
+
semanticAvailable: true,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** The preview failed — the count region raises it, the editor stays usable. */
|
|
269
|
+
export const PreviewError: Story = {
|
|
270
|
+
args: {
|
|
271
|
+
rule: demoRule,
|
|
272
|
+
folders: demoFolders,
|
|
273
|
+
preview: { status: "error", reason: "Couldn't reach the server to count." },
|
|
274
|
+
semanticAvailable: true,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type ChipStory = StoryObj;
|
|
279
|
+
|
|
280
|
+
/** Every clause and widen chip in isolation, including edit and inactive states. */
|
|
281
|
+
export const ChipGallery: ChipStory = {
|
|
282
|
+
render: () => {
|
|
283
|
+
const fields: ClauseField[] = [
|
|
284
|
+
"From",
|
|
285
|
+
"Subject",
|
|
286
|
+
"HasWords",
|
|
287
|
+
"ListId",
|
|
288
|
+
"FromDomain",
|
|
289
|
+
];
|
|
290
|
+
return (
|
|
291
|
+
<div className="space-y-4 p-4">
|
|
292
|
+
<div className="flex flex-wrap gap-2">
|
|
293
|
+
{fields.map((field) => (
|
|
294
|
+
<ClauseChip
|
|
295
|
+
key={field}
|
|
296
|
+
clause={{ id: field, field, value: clauseFieldLabel(field) }}
|
|
297
|
+
onEdit={() => {}}
|
|
298
|
+
onRemove={() => {}}
|
|
299
|
+
/>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
<div className="flex flex-wrap gap-2">
|
|
303
|
+
<ClauseChip
|
|
304
|
+
clause={{
|
|
305
|
+
id: "d",
|
|
306
|
+
field: "From",
|
|
307
|
+
value: "receipts@stripe.com",
|
|
308
|
+
derived: true,
|
|
309
|
+
}}
|
|
310
|
+
onEdit={() => {}}
|
|
311
|
+
onRemove={() => {}}
|
|
312
|
+
/>
|
|
313
|
+
<WidenChip widen={{ anchorCount: 2 }} onRemove={() => {}} />
|
|
314
|
+
<WidenChip widen={{ anchorCount: 2, inactive: true }} />
|
|
315
|
+
</div>
|
|
316
|
+
<ClauseEditor
|
|
317
|
+
draft={{ field: "ListId", value: "python-dev.python.org" }}
|
|
318
|
+
mode="edit"
|
|
319
|
+
onChangeField={() => {}}
|
|
320
|
+
onChangeValue={() => {}}
|
|
321
|
+
onSubmit={() => {}}
|
|
322
|
+
onCancel={() => {}}
|
|
323
|
+
/>
|
|
324
|
+
<div className="space-y-2">
|
|
325
|
+
<FilterPreviewCount preview={{ status: "loading" }} />
|
|
326
|
+
<FilterPreviewCount preview={READY(0)} />
|
|
327
|
+
<FilterPreviewCount preview={READY(412)} />
|
|
328
|
+
<FilterPreviewCount preview={READY(47, true)} />
|
|
329
|
+
<FilterPreviewCount
|
|
330
|
+
preview={{ status: "error", reason: "Couldn't count." }}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
};
|