@remit/web-client 0.0.92 → 0.0.94

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  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
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -36,7 +36,6 @@ import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
36
36
  import { formatDeleteToTrashTitle } from "@/lib/format";
37
37
  import { tabStopId } from "@/lib/list-focus";
38
38
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
39
- import { useMailContext } from "@/lib/mail-context";
40
39
  import type { MessageListCommands } from "./MessageList";
41
40
  import type { MessageRowSelection } from "./MessageRow";
42
41
 
@@ -187,14 +186,6 @@ export function ThreadListInteraction({
187
186
  if (orderedIds.length > 0) toggleAll(orderedIds);
188
187
  }, [orderedIds, toggleAll]);
189
188
 
190
- // The selection wizard is mounted on the route, above this provider, and
191
- // acts on these rows — so it reads the count from here rather than keeping
192
- // one of its own (#477).
193
- const { onSelectedCountChange } = useMailContext();
194
- useEffect(() => {
195
- onSelectedCountChange(selectedCount);
196
- }, [selectedCount, onSelectedCountChange]);
197
-
198
189
  // A row that leaves the list — a chip filter, a collapsed section, a
199
190
  // completed delete — cannot stay selected. Survivors keep their selection.
200
191
  const { intersectWith } = selection;
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * "Make this a filter" is the wizard's second entry (#477 clause 1.8), and the
9
+ * step it pushes has to be answered on the view that offered it.
10
+ *
11
+ * The affordance is built once by `MailListHeader` and handed down as
12
+ * `makeFilterSlot`, so any surface can put it on screen — but the wizard is
13
+ * mounted per surface, and a surface that renders the row without one leaves
14
+ * the press on a URL nothing answers. That is the dead button clause 1.7 exists
15
+ * to prevent, and it is invisible in review: the row and the host are two files
16
+ * apart, and the affordance keeps working everywhere else.
17
+ *
18
+ * So the pairing is the assertion. A surface that renders the row names where
19
+ * its step is answered, and that file is checked to mount a host. A fourth
20
+ * surface fails here until it does the same.
21
+ */
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /** How the chrome's slot is read — `MailListHeader` writes it, everyone else reads it. */
26
+ const RENDERS_SLOT = /\.makeFilterSlot/;
27
+ const MOUNTS_HOST = /<SelectionWizardHost/;
28
+
29
+ /** Surface that renders the affordance → the file whose composition answers the step. */
30
+ const ANSWERED_BY: Record<string, string> = {
31
+ "DailyBrief.tsx": "DailyBrief.tsx",
32
+ "MessageList.tsx": "MessageList.tsx",
33
+ // The starred list's bar is shared with the brief's, so its host is mounted
34
+ // by the view rather than beside the bar.
35
+ "ThreadListInteraction.tsx": "FlaggedList.tsx",
36
+ };
37
+
38
+ const read = (file: string): string =>
39
+ readFileSync(resolve(here, file), "utf8");
40
+
41
+ const surfacesRenderingTheAffordance = (): string[] =>
42
+ readdirSync(here)
43
+ .filter((file) => file.endsWith(".tsx"))
44
+ .filter((file) => RENDERS_SLOT.test(read(file)));
45
+
46
+ describe("the make-filter entry", () => {
47
+ it("is rendered only by surfaces that say where its step is answered", () => {
48
+ assert.deepEqual(
49
+ surfacesRenderingTheAffordance().sort(),
50
+ Object.keys(ANSWERED_BY).sort(),
51
+ );
52
+ });
53
+
54
+ it("is answered by a mounted wizard on every one of them", () => {
55
+ for (const [surface, host] of Object.entries(ANSWERED_BY)) {
56
+ assert.match(
57
+ read(host),
58
+ MOUNTS_HOST,
59
+ `${surface} is answered by ${host}`,
60
+ );
61
+ }
62
+ });
63
+ });
@@ -74,7 +74,7 @@ export function FilterEditor({
74
74
  const [offerReapply, setOfferReapply] = useState(false);
75
75
  const nextClauseId = useRef(0);
76
76
 
77
- const preview = useRulePreview(accountId, rulePredicate(rule));
77
+ const { count: preview } = useRulePreview(accountId, rulePredicate(rule));
78
78
  const update = useUpdateFilter(accountId, filter.filterId);
79
79
  const organizeJob = useOrganizeJob(accountId);
80
80
  const { createFolder } = useCreateMailbox(accountId);
@@ -40,16 +40,23 @@ import { composeFolderPath, validateNewFolderName } from "@/lib/new-folder";
40
40
  * `mutation` is exposed for callers that drive their own form state and want the
41
41
  * optimistic, non-waiting create (the standalone settings create).
42
42
  */
43
- export function useCreateMailbox(accountId: string) {
43
+ export function useCreateMailbox(accountId: string | undefined) {
44
44
  const queryClient = useQueryClient();
45
45
 
46
- const { data } = useQuery(
47
- mailboxOperationsListMailboxesOptions({ path: { accountId } }),
48
- );
46
+ // Held by surfaces that are mounted before a selection has named an account —
47
+ // the selection wizard sits beside every list — so the folder list is only
48
+ // asked for once there is an account to ask about.
49
+ const { data } = useQuery({
50
+ ...mailboxOperationsListMailboxesOptions({
51
+ path: { accountId: accountId ?? "" },
52
+ }),
53
+ enabled: !!accountId,
54
+ });
49
55
 
50
56
  const mutation = useMutation({
51
57
  ...mailboxOperationsCreateMailboxMutation(),
52
58
  onSuccess: () => {
59
+ if (!accountId) return;
53
60
  queryClient.invalidateQueries({
54
61
  queryKey: mailboxOperationsListMailboxesQueryKey({
55
62
  path: { accountId },
@@ -64,6 +71,11 @@ export function useCreateMailbox(accountId: string) {
64
71
 
65
72
  const createFolder = useCallback(
66
73
  async (name: string, signal?: AbortSignal): Promise<FolderOption> => {
74
+ if (!accountId) {
75
+ throw new Error(
76
+ "No account to create the folder in. Pick messages from a single account first.",
77
+ );
78
+ }
67
79
  const fullPath = composeFolderPath(name);
68
80
  let mailboxId = pendingByPath.current.get(fullPath);
69
81
  if (!mailboxId) {
@@ -60,7 +60,7 @@ export const useCreateFilter = (accountId: string | undefined) => {
60
60
  });
61
61
  },
62
62
  });
63
- const { mutate } = mutation;
63
+ const { mutate, mutateAsync } = mutation;
64
64
 
65
65
  const createFilter = useCallback(
66
66
  (
@@ -77,8 +77,37 @@ export const useCreateFilter = (accountId: string | undefined) => {
77
77
  [accountId, mutate],
78
78
  );
79
79
 
80
+ /**
81
+ * The same create, resolved once the server has the filter, for work that has
82
+ * to follow it — the pass over the mail already in the mailbox. Chaining that
83
+ * to the request rather than to the surface is what keeps it running when the
84
+ * surface is closed while the create is still in flight.
85
+ *
86
+ * `false` means the create did not land. The failure itself is on `isError`,
87
+ * which is what the surface reports and retries from, so it is not raised a
88
+ * second time here.
89
+ */
90
+ const createFilterAsync = useCallback(
91
+ async (
92
+ draft: OrganizeDraft,
93
+ scope: Extract<OrganizeScope, "standing" | "temporary">,
94
+ name: string,
95
+ ): Promise<boolean> => {
96
+ if (!accountId) return false;
97
+ return mutateAsync({
98
+ path: { accountId },
99
+ body: buildCreateFilterInput(draft, scope, name),
100
+ }).then(
101
+ () => true,
102
+ () => false,
103
+ );
104
+ },
105
+ [accountId, mutateAsync],
106
+ );
107
+
80
108
  return {
81
109
  createFilter,
110
+ createFilterAsync,
82
111
  isPending: mutation.isPending,
83
112
  isSuccess: mutation.isSuccess,
84
113
  isError: mutation.isError,
@@ -0,0 +1,63 @@
1
+ import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapDescribeMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { senderLabel, type WizardMessage } from "@remit/ui";
4
+ import { useQueries } from "@tanstack/react-query";
5
+ import { formatEmailDate } from "@/lib/format";
6
+
7
+ /**
8
+ * The members of a match, read from the server that matched them.
9
+ *
10
+ * A widened match reaches mail the list never loaded, so these rows cannot come
11
+ * from the browser's own cache: an intersection with whatever happens to be
12
+ * loaded shows a handful of rows, or none, beside a server count of hundreds —
13
+ * which reads as "this matches nothing", the one conclusion #477 3.5 exists to
14
+ * prevent. The ids the count was counted over are the match, and the server
15
+ * describes each of them.
16
+ *
17
+ * Bounded to the first {@link SAMPLE_LIMIT}: the screen shows a sample in its
18
+ * own scrolling region and states the total beside it, so describing the whole
19
+ * match would be requests nobody reads.
20
+ */
21
+ export const SAMPLE_LIMIT = 12;
22
+
23
+ export interface MatchSample {
24
+ messages: WizardMessage[];
25
+ /** No rows yet because they are still arriving, which is not "no rows". */
26
+ isPending: boolean;
27
+ }
28
+
29
+ const toWizardMessage = (
30
+ described: RemitImapDescribeMessageResponse,
31
+ ): WizardMessage => {
32
+ const { envelope } = described;
33
+ const from = envelope.from[0];
34
+ return {
35
+ id: envelope.messageId,
36
+ sender: from
37
+ ? senderLabel({
38
+ normalizedEmail: from.normalizedEmail,
39
+ displayName: from.displayName,
40
+ })
41
+ : "Unknown",
42
+ subject: envelope.subject ?? "(No subject)",
43
+ date: formatEmailDate(envelope.date),
44
+ };
45
+ };
46
+
47
+ export const useMatchSample = (messageIds: readonly string[]): MatchSample =>
48
+ useQueries({
49
+ queries: messageIds.slice(0, SAMPLE_LIMIT).map((messageId) => ({
50
+ ...messageOperationsDescribeMessageOptions({ path: { messageId } }),
51
+ staleTime: Number.POSITIVE_INFINITY,
52
+ })),
53
+ combine: (results) => ({
54
+ messages: results
55
+ .map((result) => result.data)
56
+ .filter(
57
+ (data): data is RemitImapDescribeMessageResponse =>
58
+ data !== undefined,
59
+ )
60
+ .map(toWizardMessage),
61
+ isPending: results.some((result) => result.isPending),
62
+ }),
63
+ });
@@ -1,5 +1,5 @@
1
1
  import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { PreviewCount } from "@remit/ui";
2
+ import { type PreviewCount, UNCOUNTABLE_PREDICATE_REASON } from "@remit/ui";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
@@ -9,7 +9,6 @@ import {
9
9
  PREVIEW_DEBOUNCE_MS,
10
10
  type PreviewState,
11
11
  predicateSignature,
12
- UNCOUNTABLE_PREDICATE_REASON,
13
12
  } from "@/lib/organize/rule-model";
14
13
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
15
14
 
@@ -28,12 +27,22 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
28
27
  * `seedCount` is the opening count a widen probe already knows; omit it (as the
29
28
  * settings editor does, having no probe) to open on `loading` and preview the
30
29
  * loaded predicate immediately.
30
+ *
31
+ * `matchedIds` are the ids that count was counted over — the server's own
32
+ * answer, bounded to the same cap the apply pass uses. A caller applying an
33
+ * action the back-apply job cannot express (a delete, a read flag) acts on
34
+ * exactly these rather than paging the list in the browser (#477 5.3).
31
35
  */
36
+ export interface RulePreview {
37
+ count: PreviewCount;
38
+ matchedIds: readonly string[];
39
+ }
40
+
32
41
  export const useRulePreview = (
33
42
  accountId: string | undefined,
34
43
  predicate: OrganizeMatchPredicate,
35
44
  seedCount?: number,
36
- ): PreviewCount => {
45
+ ): RulePreview => {
37
46
  const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
38
47
  const { mutateAsync } = mutation;
39
48
  const currentSignature = predicateSignature(predicate);
@@ -68,6 +77,7 @@ export const useRulePreview = (
68
77
  if (latestRequested.current !== signature) return;
69
78
  setState({
70
79
  count: response.matchedCount,
80
+ matchedIds: response.messageIds,
71
81
  previewedSignature: signature,
72
82
  });
73
83
  })
@@ -95,7 +105,16 @@ export const useRulePreview = (
95
105
  ]);
96
106
 
97
107
  if (!countable) {
98
- return { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON };
108
+ return {
109
+ count: { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON },
110
+ matchedIds: [],
111
+ };
99
112
  }
100
- return derivePreview(state, currentSignature);
113
+ return {
114
+ count: derivePreview(state, currentSignature),
115
+ matchedIds:
116
+ state.previewedSignature === currentSignature
117
+ ? (state.matchedIds ?? [])
118
+ : [],
119
+ };
101
120
  };
@@ -1,3 +1,4 @@
1
+ import type { SearchConversion } from "@remit/ui";
1
2
  import { createContext, type ReactNode, useContext } from "react";
2
3
 
3
4
  /**
@@ -31,6 +32,15 @@ export interface ListHeaderChrome {
31
32
  searchResults: ReactNode;
32
33
  /** The search's make-this-a-filter row, up only while nothing is ticked. */
33
34
  makeFilterSlot: ReactNode;
35
+ /**
36
+ * The active query as clauses, which is what the wizard's search entry opens
37
+ * on (#484). It travels with the chrome for the same reason the rest does:
38
+ * the query belongs to `MailListHeader` and the wizard is mounted by the body
39
+ * below it, and one conversion computed in one place is what keeps the reason
40
+ * the affordance gives and the clauses the wizard seeds from disagreeing.
41
+ * Absent when no query is up.
42
+ */
43
+ searchConversion?: SearchConversion;
34
44
  }
35
45
 
36
46
  const NO_CHROME: ListHeaderChrome = {
@@ -54,13 +54,6 @@ export interface MailContextValue {
54
54
  onToggleIntelligence: () => void;
55
55
  /** Set the pane open/closed and persist the choice (desktop default-open). */
56
56
  onSetIntelligenceOpen: (open: boolean) => void;
57
- /**
58
- * How many rows the list has ticked. The selection is owned by the list, and
59
- * surfaces mounted beside it — the selection wizard (#477) — need the same
60
- * number the bar is counting rather than one of their own.
61
- */
62
- selectedCount: number;
63
- onSelectedCountChange: (count: number) => void;
64
57
  }
65
58
 
66
59
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -85,8 +78,6 @@ export const useMailContext = (): MailContextValue => {
85
78
  intelligenceOpen: false,
86
79
  onToggleIntelligence: () => {},
87
80
  onSetIntelligenceOpen: () => {},
88
- selectedCount: 0,
89
- onSelectedCountChange: () => {},
90
81
  }
91
82
  );
92
83
  };
@@ -1,11 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import type { RuleClause } from "@remit/ui";
3
4
  import {
4
5
  buildCreateFilterInput,
5
6
  buildOrganizeInput,
7
+ buildWizardDraft,
6
8
  hasCommittableAction,
7
9
  NO_ACTION,
8
10
  type OrganizeDraft,
11
+ organizeScopeFor,
9
12
  } from "./organize-model";
10
13
 
11
14
  const baseDraft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
@@ -130,3 +133,110 @@ describe("buildCreateFilterInput", () => {
130
133
  assert.equal(input.actionLabelId, "lbl-1");
131
134
  });
132
135
  });
136
+
137
+ describe("organizeScopeFor", () => {
138
+ it("reads the ticked list at scope once as just-these", () => {
139
+ assert.equal(organizeScopeFor({ mode: "selected" }), "just-these");
140
+ assert.equal(
141
+ organizeScopeFor({ mode: "selected", ruleScope: "once" }),
142
+ "just-these",
143
+ );
144
+ });
145
+
146
+ it("reads either widened door at scope once as all-like-these", () => {
147
+ assert.equal(organizeScopeFor({ mode: "similar" }), "all-like-these");
148
+ assert.equal(
149
+ organizeScopeFor({ mode: "properties", ruleScope: "once" }),
150
+ "all-like-these",
151
+ );
152
+ });
153
+
154
+ it("reads the two persisting scopes off the scope answer alone", () => {
155
+ for (const mode of ["selected", "similar", "properties"] as const) {
156
+ assert.equal(
157
+ organizeScopeFor({ mode, ruleScope: "standing" }),
158
+ "standing",
159
+ );
160
+ assert.equal(organizeScopeFor({ mode, ruleScope: "until" }), "temporary");
161
+ }
162
+ });
163
+ });
164
+
165
+ describe("buildWizardDraft", () => {
166
+ const clauses: RuleClause[] = [
167
+ { id: "c1", field: "From", value: "noreply@booking.com" },
168
+ ];
169
+
170
+ it("turns the until scope's civil date into a zoned expiresAt", () => {
171
+ const draft = buildWizardDraft({
172
+ mode: "properties",
173
+ ruleScope: "until",
174
+ clauses,
175
+ matchOperator: "any",
176
+ moveMailboxId: "mbx-2",
177
+ until: "2026-09-30",
178
+ });
179
+ assert.match(
180
+ draft.expiresAt ?? "",
181
+ /^2026-09-30T23:59:59[+-]\d{2}:\d{2}$/,
182
+ "the day the rule stops on becomes an instant with an offset",
183
+ );
184
+ });
185
+
186
+ it("carries no expiry for the scopes that never expire", () => {
187
+ for (const ruleScope of ["once", "standing"] as const) {
188
+ const draft = buildWizardDraft({
189
+ mode: "properties",
190
+ ruleScope,
191
+ clauses,
192
+ matchOperator: "all",
193
+ until: "2026-09-30",
194
+ });
195
+ assert.equal(draft.expiresAt, undefined);
196
+ }
197
+ });
198
+
199
+ it("anchors on the ticked messages only while the similar door is matching", () => {
200
+ const anchored = buildWizardDraft({
201
+ mode: "similar",
202
+ anchorMessageId: "msg-1",
203
+ clauses: [],
204
+ matchOperator: "all",
205
+ });
206
+ assert.equal(anchored.anchorMessageId, "msg-1");
207
+
208
+ const literal = buildWizardDraft({
209
+ mode: "properties",
210
+ anchorMessageId: "msg-1",
211
+ clauses,
212
+ matchOperator: "any",
213
+ });
214
+ assert.equal("anchorMessageId" in literal, false);
215
+ });
216
+
217
+ it("maps the wizard's operator onto the API's", () => {
218
+ assert.equal(
219
+ buildWizardDraft({ mode: "properties", clauses, matchOperator: "all" })
220
+ .matchOperator,
221
+ "And",
222
+ );
223
+ assert.equal(
224
+ buildWizardDraft({ mode: "properties", clauses, matchOperator: "any" })
225
+ .matchOperator,
226
+ "Or",
227
+ );
228
+ });
229
+
230
+ it("sends the clause field and value, and nothing the chip carries besides", () => {
231
+ const draft = buildWizardDraft({
232
+ mode: "properties",
233
+ clauses: [
234
+ { id: "c1", field: "Subject", value: "receipt", derived: true },
235
+ ],
236
+ matchOperator: "all",
237
+ });
238
+ assert.deepEqual(draft.literalClauses, [
239
+ { field: "Subject", value: "receipt" },
240
+ ]);
241
+ });
242
+ });
@@ -5,6 +5,13 @@ import type {
5
5
  RemitImapFilterScope,
6
6
  RemitImapOrganizeInput,
7
7
  } from "@remit/api-http-client/types.gen.ts";
8
+ import type {
9
+ MatchMode,
10
+ MatchOperator,
11
+ RuleClause,
12
+ RuleScope,
13
+ } from "@remit/ui";
14
+ import { pickedDateToExpiresAt } from "./filter-status";
8
15
 
9
16
  /**
10
17
  * The four commit scopes the smart-organize sentence offers (RFC 034 recap):
@@ -92,6 +99,68 @@ export const buildOrganizeInput = (
92
99
  actionMailboxId: draft.moveMailboxId ?? NO_ACTION,
93
100
  });
94
101
 
102
+ /**
103
+ * The two independent answers the wizard collects (#477 4.6). Neither is a
104
+ * scope: the door says what the action covers and the scope step says how long
105
+ * it holds, and the four `OrganizeScope` values fall out of the pair.
106
+ */
107
+ export interface WizardCommitAnswers {
108
+ mode: MatchMode;
109
+ /** Absent on the verbs that never reach the scope step — those act once. */
110
+ ruleScope?: RuleScope;
111
+ }
112
+
113
+ /**
114
+ * Which of the four commit scopes the wizard's answers add up to. Reconstructed
115
+ * here and nowhere else, so a driver cannot invent a fifth: the ticked list at
116
+ * scope once is `just-these`, a widened door at scope once is `all-like-these`,
117
+ * and the two persisting scopes are `standing` and `temporary`.
118
+ */
119
+ export const organizeScopeFor = ({
120
+ mode,
121
+ ruleScope,
122
+ }: WizardCommitAnswers): OrganizeScope => {
123
+ if (ruleScope === "standing") return "standing";
124
+ if (ruleScope === "until") return "temporary";
125
+ return mode === "selected" ? "just-these" : "all-like-these";
126
+ };
127
+
128
+ export interface WizardDraftInput extends WizardCommitAnswers {
129
+ /** The semantic anchor, present only while the similar door is the matcher. */
130
+ anchorMessageId?: string;
131
+ clauses: readonly RuleClause[];
132
+ matchOperator: MatchOperator;
133
+ moveMailboxId?: string;
134
+ /** ISO 8601 civil date (`YYYY-MM-DD`) the `until` scope stops on. */
135
+ until?: string;
136
+ }
137
+
138
+ /**
139
+ * The commit draft for a set of wizard answers. This is where the `until`
140
+ * scope's civil date becomes `expiresAt`, a zoned date-time (#477 5.4): the
141
+ * wizard collects the day the rule stops on, and the day only becomes an
142
+ * instant once there is a draft to carry it.
143
+ */
144
+ export const buildWizardDraft = ({
145
+ mode,
146
+ ruleScope,
147
+ anchorMessageId,
148
+ clauses,
149
+ matchOperator,
150
+ moveMailboxId,
151
+ until,
152
+ }: WizardDraftInput): OrganizeDraft => ({
153
+ ...(mode === "similar" && anchorMessageId ? { anchorMessageId } : {}),
154
+ matchOperator: matchOperator === "all" ? "And" : "Or",
155
+ literalClauses: clauses.map((clause) => ({
156
+ field: clause.field,
157
+ value: clause.value,
158
+ })),
159
+ moveMailboxId,
160
+ expiresAt:
161
+ ruleScope === "until" ? pickedDateToExpiresAt(until ?? "") : undefined,
162
+ });
163
+
95
164
  /**
96
165
  * Build the `createFilter` body for a standing or temporary filter. `ttl` is
97
166
  * derived server-side from `expiresAt`; `expiresAt` is sent only for the
@@ -207,10 +207,6 @@ export const isEvaluablePredicate = (
207
207
  predicate.anchorMessageId !== undefined ||
208
208
  !predicate.literalClauses.some((clause) => clause.field === "HasWords");
209
209
 
210
- /** What the count says when the predicate cannot be counted at all. */
211
- export const UNCOUNTABLE_PREDICATE_REASON =
212
- "Can't count matches — “has the words” reads message bodies, which only a saved rule does.";
213
-
214
210
  /**
215
211
  * A stable key for a predicate's match set. Two predicates with the same key
216
212
  * match the same messages; a change to the key is what marks the live count
@@ -254,6 +250,8 @@ export const ruleToDraft = (
254
250
  export interface PreviewState {
255
251
  /** The last count that came back, `undefined` before the first lands. */
256
252
  count?: number;
253
+ /** The ids that count was counted over, as the server bounded them. */
254
+ matchedIds?: readonly string[];
257
255
  /** The signature the {@link count} was counted for. */
258
256
  previewedSignature?: string;
259
257
  /** The signature the last error was raised for. */
@@ -15,6 +15,8 @@ import {
15
15
  } from "@remit/ui";
16
16
  import {
17
17
  ownedHistoryEntries,
18
+ wizardEntryFromParam,
19
+ wizardEntryValue,
18
20
  wizardStepFromParam,
19
21
  wizardStepValue,
20
22
  } from "./wizard-history.js";
@@ -72,6 +74,17 @@ describe("re-rooting a wizard that was loaded into", () => {
72
74
  );
73
75
  });
74
76
 
77
+ // The root is the wizard-closed state, so it carries neither the step nor
78
+ // the affordance that opened it; otherwise closing the wizard leaves the
79
+ // entry marker behind in the address bar (#484).
80
+ it("puts the wizard back on a root that names no entry", () => {
81
+ assert.match(source, /wizard: undefined,\s*\n\s*wizardFrom: undefined,/);
82
+ assert.match(
83
+ source,
84
+ /wizard: openingStep,\s*\n\s*wizardFrom: openingEntry,/,
85
+ );
86
+ });
87
+
75
88
  it("is never armed by a step the app pushed", () => {
76
89
  const goToStep = source.slice(source.indexOf("const goToStep"));
77
90
  assert.doesNotMatch(goToStep, /loadedHoldingStep/);
@@ -107,6 +120,24 @@ describe("the wizard step in the URL", () => {
107
120
  });
108
121
  });
109
122
 
123
+ describe("which affordance opened the wizard", () => {
124
+ it("reads the search entry, and everything else as the selection bar", () => {
125
+ assert.equal(wizardEntryFromParam("search"), "search");
126
+ for (const value of [undefined, "", "Search", "bar", 1, null, {}]) {
127
+ assert.equal(wizardEntryFromParam(value), undefined);
128
+ }
129
+ });
130
+
131
+ it("never fails validation, so a mistyped link still lands on the mail", () => {
132
+ for (const value of ["nope", "SEARCH", 7, [], null, undefined]) {
133
+ const parsed = wizardEntryValue.safeParse(value);
134
+ assert.ok(parsed.success);
135
+ assert.equal(parsed.data, undefined);
136
+ }
137
+ assert.equal(wizardEntryValue.parse("search"), "search");
138
+ });
139
+ });
140
+
110
141
  describe("the history entries the wizard owns", () => {
111
142
  it("is one per step reached, on every shape of the list", () => {
112
143
  for (const answers of answerSets()) {