@remit/web-client 0.0.93 → 0.0.95

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BriefPane.selection.test.ts +0 -1
  3. package/src/components/mail/BriefPane.tsx +7 -17
  4. package/src/components/mail/DailyBrief.selection.test.ts +21 -3
  5. package/src/components/mail/DailyBrief.tsx +37 -39
  6. package/src/components/mail/FlaggedList.tsx +55 -8
  7. package/src/components/mail/FlaggedPane.tsx +9 -23
  8. package/src/components/mail/MailListHeader.tsx +41 -39
  9. package/src/components/mail/MailboxPane.tsx +24 -11
  10. package/src/components/mail/MessageList.selection.test.ts +44 -30
  11. package/src/components/mail/MessageList.tsx +117 -339
  12. package/src/components/mail/SelectionWizardHost.tsx +265 -48
  13. package/src/components/mail/ThreadListInteraction.test.ts +74 -4
  14. package/src/components/mail/ThreadListInteraction.tsx +58 -50
  15. package/src/components/mail/make-filter-entry.test.ts +63 -0
  16. package/src/components/mail/selection-verbs.test.ts +278 -0
  17. package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
  18. package/src/hooks/useEscalatedActions.ts +2 -2
  19. package/src/hooks/useMatchSample.ts +44 -3
  20. package/src/hooks/useRulePreview.ts +1 -2
  21. package/src/lib/bulk-action-copy.test.ts +0 -19
  22. package/src/lib/bulk-action-copy.ts +0 -8
  23. package/src/lib/bulk-actions.test.ts +0 -60
  24. package/src/lib/bulk-actions.ts +0 -26
  25. package/src/lib/format.test.ts +0 -27
  26. package/src/lib/format.ts +4 -14
  27. package/src/lib/list-header-chrome.ts +10 -0
  28. package/src/lib/organize/organize-model.ts +8 -2
  29. package/src/lib/organize/rule-model.ts +0 -4
  30. package/src/lib/wizard-history.test.ts +31 -0
  31. package/src/lib/wizard-history.ts +97 -9
  32. package/src/routes/mail.tsx +3 -1
  33. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  34. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  35. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  36. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
  37. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  38. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
@@ -1,182 +0,0 @@
1
- import type {
2
- ClauseEditState,
3
- ClauseField,
4
- FilterRule,
5
- MatchOperator,
6
- RuleScope,
7
- } from "@remit/ui";
8
- import { useRef, useState } from "react";
9
- import {
10
- normalizeClauseValue,
11
- SUPPORTED_CLAUSE_FIELDS,
12
- } from "@/lib/organize/rule-model";
13
-
14
- export interface RuleEditorState {
15
- rule: FilterRule;
16
- setRule: (updater: (current: FilterRule) => FilterRule) => void;
17
- /** The clause-editing and rule-mutation handlers a {@link FilterRuleEditor} binds. */
18
- handlers: {
19
- clauseEdit: ClauseEditState | undefined;
20
- onStartAddClause: () => void;
21
- onStartEditClause: (clauseId: string) => void;
22
- onRemoveClause: (clauseId: string) => void;
23
- onChangeDraftField: (field: ClauseField) => void;
24
- onChangeDraftValue: (value: string) => void;
25
- onSubmitClause: () => void;
26
- onCancelClause: () => void;
27
- onAddWiden: () => void;
28
- onRemoveWiden: () => void;
29
- onChangeMatchOperator: (matchOperator: MatchOperator) => void;
30
- onChangeMove: (mailboxId: string) => void;
31
- onChangeLabel: (labelId: string) => void;
32
- onChangeScope: (scope: RuleScope) => void;
33
- onChangeName: (name: string) => void;
34
- onChangeUntil: (until: string) => void;
35
- };
36
- }
37
-
38
- interface RuleEditorStateInput {
39
- /** The rule the editor opens on. */
40
- initialRule: FilterRule;
41
- /**
42
- * How many anchors a widen the user adds inline should carry. A search-seeded
43
- * rule has no message anchor, so its editor never offers the widen and this is
44
- * unused; the Organize editor passes its selection size.
45
- */
46
- widenAnchorCount?: number;
47
- }
48
-
49
- /**
50
- * The clause-editing and rule-mutation state a filter-rule editor owns, shared by
51
- * every entry point onto the chip editor (RFC 038 D1) so the Organize and
52
- * search-derived surfaces edit the rule identically. Preview and commit stay with
53
- * the caller — they differ by entry point (an anchored widen count and back-apply
54
- * job for Organize, a literal count for a search-derived rule).
55
- */
56
- export const useRuleEditorState = ({
57
- initialRule,
58
- widenAnchorCount = 1,
59
- }: RuleEditorStateInput): RuleEditorState => {
60
- const [rule, setRuleState] = useState<FilterRule>(initialRule);
61
- const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
62
- const nextClauseId = useRef(0);
63
-
64
- const setRule = (updater: (current: FilterRule) => FilterRule) =>
65
- setRuleState(updater);
66
-
67
- const startAddClause = () =>
68
- setClauseEdit({
69
- mode: "add",
70
- draft: { field: SUPPORTED_CLAUSE_FIELDS[0], value: "" },
71
- });
72
-
73
- const startEditClause = (clauseId: string) => {
74
- const clause = rule.clauses.find((entry) => entry.id === clauseId);
75
- if (!clause) return;
76
- setClauseEdit({
77
- mode: "edit",
78
- clauseId,
79
- draft: { field: clause.field, value: clause.value },
80
- });
81
- };
82
-
83
- const changeDraftField = (field: ClauseField) =>
84
- setClauseEdit((edit) =>
85
- edit ? { ...edit, draft: { ...edit.draft, field } } : edit,
86
- );
87
-
88
- const changeDraftValue = (value: string) =>
89
- setClauseEdit((edit) =>
90
- edit ? { ...edit, draft: { ...edit.draft, value } } : edit,
91
- );
92
-
93
- const submitClause = () => {
94
- if (!clauseEdit) return;
95
- const field = clauseEdit.draft.field;
96
- const value = normalizeClauseValue(field, clauseEdit.draft.value);
97
- if (value === "") return;
98
- setRuleState((current) => {
99
- if (clauseEdit.mode === "edit" && clauseEdit.clauseId) {
100
- return {
101
- ...current,
102
- clauses: current.clauses.map((clause) =>
103
- clause.id === clauseEdit.clauseId
104
- ? { id: clause.id, field, value }
105
- : clause,
106
- ),
107
- };
108
- }
109
- nextClauseId.current += 1;
110
- return {
111
- ...current,
112
- clauses: [
113
- ...current.clauses,
114
- { id: `clause-${nextClauseId.current}`, field, value },
115
- ],
116
- };
117
- });
118
- setClauseEdit(undefined);
119
- };
120
-
121
- const removeClause = (clauseId: string) =>
122
- setRuleState((current) => ({
123
- ...current,
124
- clauses: current.clauses.filter((clause) => clause.id !== clauseId),
125
- }));
126
-
127
- const addWiden = () =>
128
- setRuleState((current) => ({
129
- ...current,
130
- widen: { anchorCount: Math.max(widenAnchorCount, 1) },
131
- }));
132
-
133
- const removeWiden = () =>
134
- setRuleState((current) => ({ ...current, widen: undefined }));
135
-
136
- const changeMatchOperator = (matchOperator: MatchOperator) =>
137
- setRuleState((current) => ({ ...current, matchOperator }));
138
-
139
- const changeMove = (mailboxId: string) =>
140
- setRuleState((current) => ({
141
- ...current,
142
- moveMailboxId: mailboxId || undefined,
143
- }));
144
-
145
- const changeLabel = (labelId: string) =>
146
- setRuleState((current) => ({
147
- ...current,
148
- labelId: labelId || undefined,
149
- }));
150
-
151
- const changeScope = (scope: RuleScope) =>
152
- setRuleState((current) => ({ ...current, scope }));
153
-
154
- const changeName = (name: string) =>
155
- setRuleState((current) => ({ ...current, name }));
156
-
157
- const changeUntil = (until: string) =>
158
- setRuleState((current) => ({ ...current, until }));
159
-
160
- return {
161
- rule,
162
- setRule,
163
- handlers: {
164
- clauseEdit,
165
- onStartAddClause: startAddClause,
166
- onStartEditClause: startEditClause,
167
- onRemoveClause: removeClause,
168
- onChangeDraftField: changeDraftField,
169
- onChangeDraftValue: changeDraftValue,
170
- onSubmitClause: submitClause,
171
- onCancelClause: () => setClauseEdit(undefined),
172
- onAddWiden: addWiden,
173
- onRemoveWiden: removeWiden,
174
- onChangeMatchOperator: changeMatchOperator,
175
- onChangeMove: changeMove,
176
- onChangeLabel: changeLabel,
177
- onChangeScope: changeScope,
178
- onChangeName: changeName,
179
- onChangeUntil: changeUntil,
180
- },
181
- };
182
- };
@@ -1,118 +0,0 @@
1
- /**
2
- * Opening the filter-from-search editor: the seed count comes from the converted
3
- * literal predicate in one request, under the account the filter targets. No
4
- * capability probe — the deployment's semantic reach is read from the search's
5
- * own results on the surface, so a seed success is never blocked by a probe.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { afterEach, describe, it } from "node:test";
10
- import { createElement } from "react";
11
- import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
12
- import { createDomHarness, type DomHarness } from "../test-support/dom";
13
- import {
14
- type HttpCall,
15
- type HttpMock,
16
- httpError,
17
- mockFetch,
18
- } from "../test-support/http";
19
- import { useSearchFilterSeed } from "./useSearchFilterSeed";
20
-
21
- let harness: DomHarness | undefined;
22
- let http: HttpMock | undefined;
23
-
24
- afterEach(() => {
25
- harness?.close();
26
- harness = undefined;
27
- http?.restore();
28
- http = undefined;
29
- });
30
-
31
- const COUNTABLE: OrganizeMatchPredicate = {
32
- matchOperator: "And",
33
- literalClauses: [{ field: "From", value: "receipts@shop.example" }],
34
- };
35
-
36
- /**
37
- * A free-text search converts to a body-content clause, which the vector-free
38
- * matcher refuses. The seed must not ask.
39
- */
40
- const UNCOUNTABLE: OrganizeMatchPredicate = {
41
- matchOperator: "And",
42
- literalClauses: [{ field: "HasWords", value: "receipts" }],
43
- };
44
-
45
- function probeFor(predicate: OrganizeMatchPredicate) {
46
- return function Probe() {
47
- const seed = useSearchFilterSeed("acc-1", predicate);
48
- return createElement(
49
- "div",
50
- null,
51
- JSON.stringify({
52
- seedCount: seed.seedCount ?? null,
53
- isPending: seed.isPending,
54
- isError: seed.isError,
55
- uncountable: seed.uncountable,
56
- }),
57
- );
58
- };
59
- }
60
-
61
- const mount = (
62
- responder: (call: HttpCall) => unknown,
63
- predicate: OrganizeMatchPredicate = COUNTABLE,
64
- ): DomHarness => {
65
- http = mockFetch(responder);
66
- harness = createDomHarness();
67
- harness.renderApp(createElement(probeFor(predicate)));
68
- return harness;
69
- };
70
-
71
- const state = (dom: DomHarness) => JSON.parse(dom.text());
72
-
73
- describe("useSearchFilterSeed", () => {
74
- it("seeds the count from the literal predicate in a single preview", async () => {
75
- const dom = mount((call) =>
76
- call.path.endsWith("/organize/preview")
77
- ? { matchedCount: 12, messageIds: [] }
78
- : {},
79
- );
80
- await dom.flush();
81
- await dom.flush();
82
- assert.equal(state(dom).seedCount, 12);
83
- // One preview, carrying the literal clauses and no anchor.
84
- const previews = http?.to("/organize/preview") ?? [];
85
- assert.equal(previews.length, 1);
86
- assert.equal(previews[0].body?.anchorMessageId, undefined);
87
- assert.deepEqual(previews[0].body?.literalClauses, [
88
- { field: "From", value: "receipts@shop.example" },
89
- ]);
90
- });
91
-
92
- it("surfaces the seed error so the caller can offer a retry", async () => {
93
- const dom = mount(() => httpError(500));
94
- await dom.flush();
95
- await dom.flush();
96
- assert.equal(state(dom).isError, true);
97
- });
98
-
99
- it("never asks for a count the matcher cannot produce", async () => {
100
- const dom = mount(
101
- (call) =>
102
- call.path.endsWith("/organize/preview")
103
- ? { matchedCount: 12, messageIds: [] }
104
- : {},
105
- UNCOUNTABLE,
106
- );
107
- await dom.flush();
108
- await dom.flush();
109
- assert.equal(http?.to("/organize/preview").length, 0);
110
- // Not pending and not an error — the editor opens, it just has no number.
111
- assert.deepEqual(state(dom), {
112
- seedCount: null,
113
- isPending: false,
114
- isError: false,
115
- uncountable: true,
116
- });
117
- });
118
- });
@@ -1,79 +0,0 @@
1
- import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { useMutation } from "@tanstack/react-query";
3
- import { useCallback, useEffect } from "react";
4
- import { buildOrganizeInput } from "@/lib/organize/organize-model";
5
- import { isEvaluablePredicate } from "@/lib/organize/rule-model";
6
- import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
7
-
8
- interface SearchFilterSeed {
9
- /** The live count for the converted literal predicate, seeding the editor. */
10
- seedCount?: number;
11
- /**
12
- * The converted predicate is one the vector-free matcher refuses — a free-text
13
- * search kept as a `HasWords` clause. There is no count to seed and no request
14
- * to make.
15
- */
16
- uncountable: boolean;
17
- isPending: boolean;
18
- isError: boolean;
19
- error: unknown;
20
- retry: () => void;
21
- }
22
-
23
- /**
24
- * Open the filter-from-search editor: seed the live count from the converted
25
- * literal predicate. One `POST /organize/preview` under the account the filter
26
- * targets — the count on screen is the set a literal-only filter applies to.
27
- *
28
- * A search whose terms convert to a `HasWords` clause has no count to seed: the
29
- * vector-free matcher reads no message bodies and rejects the predicate outright
30
- * (`assertNoBodyContentClause`), so asking is a 500 and a 500 is not a count.
31
- * That search is still a legitimate standing filter — the index-time matcher
32
- * does read bodies — so the editor opens on the uncountable reason and holds only
33
- * the one-time apply, the same way {@link useRulePreview} handles the clause
34
- * being added by hand.
35
- *
36
- * The deployment's semantic reach is not probed here; it is read from the
37
- * search's own "Related" results on the surface that opens the editor (RFC 038
38
- * D5), a direct signal that needs no request and cannot hit the wrong account.
39
- */
40
- export const useSearchFilterSeed = (
41
- accountId: string | undefined,
42
- literalPredicate: OrganizeMatchPredicate,
43
- ): SearchFilterSeed => {
44
- const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
45
- const { mutate, reset } = seed;
46
- const countable = isEvaluablePredicate(literalPredicate);
47
-
48
- const run = useCallback(() => {
49
- if (!accountId || !countable) return;
50
- reset();
51
- mutate({
52
- path: { accountId },
53
- body: buildOrganizeInput({
54
- matchOperator: literalPredicate.matchOperator,
55
- literalClauses: literalPredicate.literalClauses,
56
- }),
57
- });
58
- }, [
59
- accountId,
60
- countable,
61
- literalPredicate.matchOperator,
62
- literalPredicate.literalClauses,
63
- mutate,
64
- reset,
65
- ]);
66
-
67
- useEffect(() => {
68
- run();
69
- }, [run]);
70
-
71
- return {
72
- seedCount: seed.data?.matchedCount,
73
- uncountable: !countable,
74
- isPending: countable && (seed.isPending || seed.data === undefined),
75
- isError: countable && seed.isError,
76
- error: seed.error,
77
- retry: run,
78
- };
79
- };