@remit/ui 0.0.41 → 0.0.43
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 +223 -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 +311 -0
- package/src/components/filter-rule.render.test.ts +604 -0
- package/src/components/filter-rule.ts +247 -0
- package/src/index.ts +48 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { BottomSheet } from "./bottom-sheet.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { Dialog } from "./dialog.js";
|
|
5
|
+
import {
|
|
6
|
+
AddChipButton,
|
|
7
|
+
ClauseChip,
|
|
8
|
+
type ClauseDraft,
|
|
9
|
+
ClauseEditor,
|
|
10
|
+
WidenChip,
|
|
11
|
+
} from "./filter-clause-chip.js";
|
|
12
|
+
import { FilterPreviewCount } from "./filter-preview-count.js";
|
|
13
|
+
import {
|
|
14
|
+
type ClauseField,
|
|
15
|
+
commitBlockedReason,
|
|
16
|
+
commitLabel,
|
|
17
|
+
type FilterRule,
|
|
18
|
+
type FolderOption,
|
|
19
|
+
type MatchOperator,
|
|
20
|
+
matchJoinWord,
|
|
21
|
+
matchOperatorLabel,
|
|
22
|
+
type PreviewCount,
|
|
23
|
+
type RuleScope,
|
|
24
|
+
} from "./filter-rule.js";
|
|
25
|
+
import { Input } from "./input.js";
|
|
26
|
+
import { SegmentedControl } from "./segmented-control.js";
|
|
27
|
+
import { Select } from "./select.js";
|
|
28
|
+
|
|
29
|
+
export interface ClauseEditState {
|
|
30
|
+
mode: "add" | "edit";
|
|
31
|
+
/** Present when amending an existing clause; absent when adding a new one. */
|
|
32
|
+
clauseId?: string;
|
|
33
|
+
draft: ClauseDraft;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FilterRuleEditorProps {
|
|
37
|
+
rule: FilterRule;
|
|
38
|
+
folders: FolderOption[];
|
|
39
|
+
preview: PreviewCount;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the deployment can serve the semantic widen (RFC 038 D4). When
|
|
42
|
+
* false the "…and anything similar" chip is never offered — an already-present
|
|
43
|
+
* widen still renders, marked inactive.
|
|
44
|
+
*/
|
|
45
|
+
semanticAvailable?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* The clause fields the add/edit picker offers, in menu order. Defaults to the
|
|
48
|
+
* whole vocabulary; a consumer narrows it to the fields its deployment can
|
|
49
|
+
* match, so the editor never offers a clause the backend cannot evaluate.
|
|
50
|
+
*/
|
|
51
|
+
clauseFields?: ClauseField[];
|
|
52
|
+
/** The inline clause form, when adding or editing a clause. */
|
|
53
|
+
clauseEdit?: ClauseEditState;
|
|
54
|
+
onStartAddClause?: () => void;
|
|
55
|
+
onStartEditClause?: (clauseId: string) => void;
|
|
56
|
+
onRemoveClause?: (clauseId: string) => void;
|
|
57
|
+
onChangeDraftField?: (field: ClauseField) => void;
|
|
58
|
+
onChangeDraftValue?: (value: string) => void;
|
|
59
|
+
onSubmitClause?: () => void;
|
|
60
|
+
onCancelClause?: () => void;
|
|
61
|
+
onAddWiden?: () => void;
|
|
62
|
+
onRemoveWiden?: () => void;
|
|
63
|
+
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
64
|
+
onChangeMove?: (mailboxId: string) => void;
|
|
65
|
+
onChangeScope?: (scope: RuleScope) => void;
|
|
66
|
+
onChangeName?: (name: string) => void;
|
|
67
|
+
onChangeUntil?: (date: string) => void;
|
|
68
|
+
onCommit?: () => void;
|
|
69
|
+
onCancel?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const matchOptions: { value: MatchOperator; label: string }[] = [
|
|
73
|
+
{ value: "all", label: matchOperatorLabel("all") },
|
|
74
|
+
{ value: "any", label: matchOperatorLabel("any") },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
78
|
+
{ value: "once", label: "Just once" },
|
|
79
|
+
{ value: "standing", label: "Keep doing this" },
|
|
80
|
+
{ value: "until", label: "Until a date" },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export function FilterRuleEditor({
|
|
84
|
+
rule,
|
|
85
|
+
folders,
|
|
86
|
+
preview,
|
|
87
|
+
semanticAvailable = false,
|
|
88
|
+
clauseFields,
|
|
89
|
+
clauseEdit,
|
|
90
|
+
onStartAddClause,
|
|
91
|
+
onStartEditClause,
|
|
92
|
+
onRemoveClause,
|
|
93
|
+
onChangeDraftField,
|
|
94
|
+
onChangeDraftValue,
|
|
95
|
+
onSubmitClause,
|
|
96
|
+
onCancelClause,
|
|
97
|
+
onAddWiden,
|
|
98
|
+
onRemoveWiden,
|
|
99
|
+
onChangeMatchOperator,
|
|
100
|
+
onChangeMove,
|
|
101
|
+
onChangeScope,
|
|
102
|
+
onChangeName,
|
|
103
|
+
onChangeUntil,
|
|
104
|
+
onCommit,
|
|
105
|
+
onCancel,
|
|
106
|
+
}: FilterRuleEditorProps) {
|
|
107
|
+
const activeWiden = rule.widen && !rule.widen.inactive ? 1 : 0;
|
|
108
|
+
const matcherCount = rule.clauses.length + activeWiden;
|
|
109
|
+
const showOperator = matcherCount >= 2;
|
|
110
|
+
const join = matchJoinWord(rule.matchOperator);
|
|
111
|
+
const blockedReason = commitBlockedReason(rule, preview);
|
|
112
|
+
const needsName = rule.scope === "standing" || rule.scope === "until";
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex min-h-0 flex-col">
|
|
116
|
+
<div className="border-b border-line px-5 py-3">
|
|
117
|
+
<h2 className="text-sm font-semibold text-fg">Filter rule</h2>
|
|
118
|
+
<p className="mt-0.5 text-xs text-fg-muted">
|
|
119
|
+
These chips are the whole rule — what you see is what applies.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
|
124
|
+
<section className="space-y-3">
|
|
125
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
126
|
+
{rule.clauses.map((clause, index) => (
|
|
127
|
+
<Fragment key={clause.id}>
|
|
128
|
+
{index > 0 && (
|
|
129
|
+
<span className="text-2xs font-medium uppercase text-fg-subtle">
|
|
130
|
+
{join}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
<ClauseChip
|
|
134
|
+
clause={clause}
|
|
135
|
+
onEdit={() => onStartEditClause?.(clause.id)}
|
|
136
|
+
onRemove={() => onRemoveClause?.(clause.id)}
|
|
137
|
+
/>
|
|
138
|
+
</Fragment>
|
|
139
|
+
))}
|
|
140
|
+
|
|
141
|
+
{rule.widen && (
|
|
142
|
+
<>
|
|
143
|
+
{rule.clauses.length > 0 && !rule.widen.inactive && (
|
|
144
|
+
<span className="text-2xs font-medium uppercase text-fg-subtle">
|
|
145
|
+
{join}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
<WidenChip widen={rule.widen} onRemove={onRemoveWiden} />
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{!clauseEdit && (
|
|
153
|
+
<AddChipButton label="Add clause" onClick={onStartAddClause} />
|
|
154
|
+
)}
|
|
155
|
+
{!rule.widen && semanticAvailable && !clauseEdit && (
|
|
156
|
+
<AddChipButton label="…and similar" onClick={onAddWiden} />
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{clauseEdit && (
|
|
161
|
+
<ClauseEditor
|
|
162
|
+
draft={clauseEdit.draft}
|
|
163
|
+
mode={clauseEdit.mode}
|
|
164
|
+
fields={clauseFields}
|
|
165
|
+
onChangeField={onChangeDraftField}
|
|
166
|
+
onChangeValue={onChangeDraftValue}
|
|
167
|
+
onSubmit={onSubmitClause}
|
|
168
|
+
onCancel={onCancelClause}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{showOperator && (
|
|
173
|
+
<div className="flex items-center gap-2">
|
|
174
|
+
<span className="text-xs text-fg-muted">Match</span>
|
|
175
|
+
<SegmentedControl
|
|
176
|
+
name="match-operator"
|
|
177
|
+
size="sm"
|
|
178
|
+
aria-label="Match operator"
|
|
179
|
+
options={matchOptions}
|
|
180
|
+
value={rule.matchOperator}
|
|
181
|
+
onChange={(value) => onChangeMatchOperator?.(value)}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</section>
|
|
186
|
+
|
|
187
|
+
<section className="space-y-2">
|
|
188
|
+
<p className="text-xs font-medium text-fg-muted">Move matches to</p>
|
|
189
|
+
<Select
|
|
190
|
+
aria-label="Destination folder"
|
|
191
|
+
value={rule.moveMailboxId ?? ""}
|
|
192
|
+
onChange={(e) => onChangeMove?.(e.target.value)}
|
|
193
|
+
>
|
|
194
|
+
<option value="">Choose a folder…</option>
|
|
195
|
+
{folders.map((folder) => (
|
|
196
|
+
<option key={folder.id} value={folder.id}>
|
|
197
|
+
{folder.label}
|
|
198
|
+
</option>
|
|
199
|
+
))}
|
|
200
|
+
</Select>
|
|
201
|
+
<div className="flex items-center gap-2 pt-0.5">
|
|
202
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted">
|
|
203
|
+
label them…
|
|
204
|
+
</span>
|
|
205
|
+
<span className="text-2xs text-fg-subtle">
|
|
206
|
+
Labeling isn't available yet — arrives with mail-labeling (RFC
|
|
207
|
+
031).
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
211
|
+
|
|
212
|
+
<section className="space-y-2">
|
|
213
|
+
<p className="text-xs font-medium text-fg-muted">How long</p>
|
|
214
|
+
<SegmentedControl
|
|
215
|
+
name="rule-scope"
|
|
216
|
+
size="sm"
|
|
217
|
+
aria-label="Rule scope"
|
|
218
|
+
options={scopeOptions}
|
|
219
|
+
value={rule.scope}
|
|
220
|
+
onChange={(value) => onChangeScope?.(value)}
|
|
221
|
+
/>
|
|
222
|
+
{needsName && (
|
|
223
|
+
<Input
|
|
224
|
+
value={rule.name ?? ""}
|
|
225
|
+
onChange={(e) => onChangeName?.(e.target.value)}
|
|
226
|
+
placeholder="Name this rule (e.g. Receipts)"
|
|
227
|
+
aria-label="Rule name"
|
|
228
|
+
className="w-full"
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
{rule.scope === "until" && (
|
|
232
|
+
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
233
|
+
<span>Until</span>
|
|
234
|
+
<Input
|
|
235
|
+
type="date"
|
|
236
|
+
value={rule.until ?? ""}
|
|
237
|
+
onChange={(e) => onChangeUntil?.(e.target.value)}
|
|
238
|
+
aria-label="Expiry date"
|
|
239
|
+
className="flex-1"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</section>
|
|
244
|
+
|
|
245
|
+
<div className="border-t border-line pt-3">
|
|
246
|
+
<FilterPreviewCount preview={preview} />
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div className="space-y-2 border-t border-line px-5 py-3">
|
|
251
|
+
{blockedReason && (
|
|
252
|
+
<p className="text-xs text-fg-subtle" role="status">
|
|
253
|
+
{blockedReason}
|
|
254
|
+
</p>
|
|
255
|
+
)}
|
|
256
|
+
<Button
|
|
257
|
+
variant="primary"
|
|
258
|
+
onClick={onCommit}
|
|
259
|
+
disabled={blockedReason !== undefined}
|
|
260
|
+
className="w-full"
|
|
261
|
+
>
|
|
262
|
+
{commitLabel(rule.scope)}
|
|
263
|
+
</Button>
|
|
264
|
+
<Button variant="ghost" onClick={onCancel} className="w-full">
|
|
265
|
+
Not now
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface FilterRuleDialogProps extends FilterRuleEditorProps {
|
|
273
|
+
open: boolean;
|
|
274
|
+
onClose: () => void;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Desktop home for the rule editor — the centered modal (RFC 038 D1). */
|
|
278
|
+
export function FilterRuleDialog({
|
|
279
|
+
open,
|
|
280
|
+
onClose,
|
|
281
|
+
...editor
|
|
282
|
+
}: FilterRuleDialogProps) {
|
|
283
|
+
if (!open) return null;
|
|
284
|
+
return (
|
|
285
|
+
<Dialog open={open} onClose={onClose} title="Filter rule">
|
|
286
|
+
<FilterRuleEditor {...editor} onCancel={onClose} />
|
|
287
|
+
</Dialog>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface FilterRuleSheetProps extends FilterRuleEditorProps {
|
|
292
|
+
open: boolean;
|
|
293
|
+
onClose: () => void;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Mobile home for the rule editor — the bottom sheet (RFC 038 D1). */
|
|
297
|
+
export function FilterRuleSheet({
|
|
298
|
+
open,
|
|
299
|
+
onClose,
|
|
300
|
+
...editor
|
|
301
|
+
}: FilterRuleSheetProps): ReactNode {
|
|
302
|
+
return (
|
|
303
|
+
<BottomSheet
|
|
304
|
+
open={open}
|
|
305
|
+
onClose={onClose}
|
|
306
|
+
dismissLabel="Dismiss filter rule"
|
|
307
|
+
>
|
|
308
|
+
<FilterRuleEditor {...editor} onCancel={onClose} />
|
|
309
|
+
</BottomSheet>
|
|
310
|
+
);
|
|
311
|
+
}
|