@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.
- package/package.json +1 -1
- package/src/components/mail/BriefPane.selection.test.ts +0 -1
- package/src/components/mail/BriefPane.tsx +7 -17
- package/src/components/mail/DailyBrief.selection.test.ts +21 -3
- package/src/components/mail/DailyBrief.tsx +37 -39
- package/src/components/mail/FlaggedList.tsx +55 -8
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MailboxPane.tsx +24 -11
- package/src/components/mail/MessageList.selection.test.ts +44 -30
- package/src/components/mail/MessageList.tsx +117 -339
- package/src/components/mail/SelectionWizardHost.tsx +265 -48
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/mail/selection-verbs.test.ts +278 -0
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/hooks/useMatchSample.ts +44 -3
- package/src/hooks/useRulePreview.ts +1 -2
- package/src/lib/bulk-action-copy.test.ts +0 -19
- package/src/lib/bulk-action-copy.ts +0 -8
- package/src/lib/bulk-actions.test.ts +0 -60
- package/src/lib/bulk-actions.ts +0 -26
- package/src/lib/format.test.ts +0 -27
- package/src/lib/format.ts +4 -14
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +97 -9
- package/src/routes/mail.tsx +3 -1
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
bulkActionCompletionText,
|
|
6
6
|
bulkActionFailureDetail,
|
|
7
7
|
bulkActionFailureTitle,
|
|
8
|
-
bulkActionPartialText,
|
|
9
8
|
bulkActionProgressLabel,
|
|
10
9
|
bulkActionProgressTone,
|
|
11
10
|
} from "./bulk-action-copy.js";
|
|
@@ -46,23 +45,6 @@ describe("bulkActionCompletionText", () => {
|
|
|
46
45
|
});
|
|
47
46
|
});
|
|
48
47
|
|
|
49
|
-
describe("bulkActionPartialText", () => {
|
|
50
|
-
test("splits what landed from what is still selected", () => {
|
|
51
|
-
assert.equal(
|
|
52
|
-
bulkActionPartialText("delete", 3072, 340),
|
|
53
|
-
"3,072 moved to Trash. 340 couldn't be deleted.",
|
|
54
|
-
);
|
|
55
|
-
assert.equal(
|
|
56
|
-
bulkActionPartialText("move", 3072, 340),
|
|
57
|
-
"3,072 moved. 340 couldn't be moved.",
|
|
58
|
-
);
|
|
59
|
-
assert.equal(
|
|
60
|
-
bulkActionPartialText("markRead", 3072, 340),
|
|
61
|
-
"3,072 marked as read. 340 couldn't be marked as read.",
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
48
|
describe("bulkActionFailureTitle", () => {
|
|
67
49
|
test("reports where a partly-done run stopped", () => {
|
|
68
50
|
assert.equal(
|
|
@@ -87,7 +69,6 @@ describe("every action carries its own wording", () => {
|
|
|
87
69
|
test("no two actions share a sentence", () => {
|
|
88
70
|
const sentences: Array<(kind: BulkActionKind) => string> = [
|
|
89
71
|
(kind) => bulkActionCompletionText(kind, 5),
|
|
90
|
-
(kind) => bulkActionPartialText(kind, 5, 2),
|
|
91
72
|
(kind) => bulkActionFailureTitle(kind, 0),
|
|
92
73
|
(kind) => bulkActionFailureTitle(kind, 5),
|
|
93
74
|
bulkActionFailureDetail,
|
|
@@ -59,14 +59,6 @@ export const bulkActionCompletionText = (
|
|
|
59
59
|
): string =>
|
|
60
60
|
`${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
|
|
61
61
|
|
|
62
|
-
/** Shown when part of a run landed and the rest is still selected for Retry. */
|
|
63
|
-
export const bulkActionPartialText = (
|
|
64
|
-
kind: BulkActionKind,
|
|
65
|
-
succeeded: number,
|
|
66
|
-
remaining: number,
|
|
67
|
-
): string =>
|
|
68
|
-
`${formatNumber(succeeded)} ${pastTense[kind]}. ${formatNumber(remaining)} ${negated[kind]}.`;
|
|
69
|
-
|
|
70
62
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
71
63
|
export const bulkActionFailureTitle = (
|
|
72
64
|
kind: BulkActionKind,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
countMatches,
|
|
7
7
|
type FetchIdsPageResult,
|
|
8
8
|
honestProgress,
|
|
9
|
-
resolveSelectionAfterRun,
|
|
10
9
|
runChunkedAction,
|
|
11
10
|
runPredicateAction,
|
|
12
11
|
} from "./bulk-actions.js";
|
|
@@ -355,65 +354,6 @@ describe("countMatches", () => {
|
|
|
355
354
|
});
|
|
356
355
|
});
|
|
357
356
|
|
|
358
|
-
describe("resolveSelectionAfterRun", () => {
|
|
359
|
-
test("a clean run with nothing failed exits selection mode", () => {
|
|
360
|
-
assert.deepEqual(
|
|
361
|
-
resolveSelectionAfterRun({
|
|
362
|
-
done: 3412,
|
|
363
|
-
failedIds: [],
|
|
364
|
-
cancelled: false,
|
|
365
|
-
}),
|
|
366
|
-
{ exit: true, retryIds: [] },
|
|
367
|
-
);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
|
|
371
|
-
assert.deepEqual(
|
|
372
|
-
resolveSelectionAfterRun({
|
|
373
|
-
done: 3072,
|
|
374
|
-
failedIds: ["a", "b"],
|
|
375
|
-
cancelled: false,
|
|
376
|
-
}),
|
|
377
|
-
{ exit: false, retryIds: ["a", "b"] },
|
|
378
|
-
);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("a clean cancel with nothing yet confirmed failed leaves nothing to retry, but does not exit", () => {
|
|
382
|
-
assert.deepEqual(
|
|
383
|
-
resolveSelectionAfterRun({
|
|
384
|
-
done: 100,
|
|
385
|
-
failedIds: [],
|
|
386
|
-
cancelled: true,
|
|
387
|
-
}),
|
|
388
|
-
{ exit: false, retryIds: [] },
|
|
389
|
-
);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test("an infra failure with nothing left unreached still keeps selection mode open", () => {
|
|
393
|
-
assert.deepEqual(
|
|
394
|
-
resolveSelectionAfterRun({
|
|
395
|
-
done: 0,
|
|
396
|
-
failedIds: [],
|
|
397
|
-
cancelled: false,
|
|
398
|
-
error: new Error("network blip"),
|
|
399
|
-
}),
|
|
400
|
-
{ exit: false, retryIds: [] },
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("failedIds wins over cancelled/error when both are present", () => {
|
|
405
|
-
assert.deepEqual(
|
|
406
|
-
resolveSelectionAfterRun({
|
|
407
|
-
done: 10,
|
|
408
|
-
failedIds: ["x"],
|
|
409
|
-
cancelled: true,
|
|
410
|
-
error: new Error("boom"),
|
|
411
|
-
}),
|
|
412
|
-
{ exit: false, retryIds: ["x"] },
|
|
413
|
-
);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
357
|
describe("honestProgress", () => {
|
|
418
358
|
// Regression for #109: `countMatches` and `runPredicateAction` page the
|
|
419
359
|
// same predicate independently, so the delete can outrun the frozen
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -275,29 +275,3 @@ export interface BulkRunOutcome {
|
|
|
275
275
|
cancelled: boolean;
|
|
276
276
|
error?: unknown;
|
|
277
277
|
}
|
|
278
|
-
|
|
279
|
-
export interface SelectionAfterRun {
|
|
280
|
-
/** The action reached everything targeted — selection mode should exit. */
|
|
281
|
-
exit: boolean;
|
|
282
|
-
/** The bounded selection to leave in place, empty when exiting. */
|
|
283
|
-
retryIds: string[];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* What a caller does with selection once a run ends, for any reason. Every id
|
|
288
|
-
* the action never reached — a chunk the bounded run skipped because it was
|
|
289
|
-
* stopped or errored — belongs in `retryIds`: it is exactly what Retry should
|
|
290
|
-
* resend, and it is what stays selected so the count on screen never claims
|
|
291
|
-
* more was done than actually was (#92 requirement 8).
|
|
292
|
-
*/
|
|
293
|
-
export const resolveSelectionAfterRun = (
|
|
294
|
-
outcome: BulkRunOutcome,
|
|
295
|
-
): SelectionAfterRun => {
|
|
296
|
-
if (outcome.failedIds.length > 0) {
|
|
297
|
-
return { exit: false, retryIds: outcome.failedIds };
|
|
298
|
-
}
|
|
299
|
-
if (outcome.cancelled || outcome.error) {
|
|
300
|
-
return { exit: false, retryIds: [] };
|
|
301
|
-
}
|
|
302
|
-
return { exit: true, retryIds: [] };
|
|
303
|
-
};
|
package/src/lib/format.test.ts
CHANGED
|
@@ -119,31 +119,4 @@ describe("formatDeleteToTrashTitle", () => {
|
|
|
119
119
|
"Move 3,412 messages to Trash?",
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
|
-
|
|
123
|
-
// #109: an escalated-predicate count is paged once by `countMatches` and
|
|
124
|
-
// re-paged independently by the delete itself — never provably the number
|
|
125
|
-
// that gets deleted. `isEstimate` says so instead of stating an exact
|
|
126
|
-
// number the run may not honour.
|
|
127
|
-
describe("isEstimate (#109 — an escalated-predicate count, not a materialized selection)", () => {
|
|
128
|
-
test("prefixes 'about' for a plural estimate", () => {
|
|
129
|
-
assert.strictEqual(
|
|
130
|
-
formatDeleteToTrashTitle(3412, true),
|
|
131
|
-
"Move about 3,412 messages to Trash?",
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("prefixes 'about' for a singular estimate", () => {
|
|
136
|
-
assert.strictEqual(
|
|
137
|
-
formatDeleteToTrashTitle(1, true),
|
|
138
|
-
"Move about 1 message to Trash?",
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("defaults to false — a bounded selection's count stays exact", () => {
|
|
143
|
-
assert.strictEqual(
|
|
144
|
-
formatDeleteToTrashTitle(3412),
|
|
145
|
-
formatDeleteToTrashTitle(3412, false),
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
122
|
});
|
package/src/lib/format.ts
CHANGED
|
@@ -152,21 +152,11 @@ export const formatEmailDate = (date: Date | string | number): string => {
|
|
|
152
152
|
/**
|
|
153
153
|
* Confirmation title for the move-to-Trash delete flow. Reflects that delete
|
|
154
154
|
* moves messages to Trash (not a permanent delete) and pluralizes on count.
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* `isEstimate` marks an escalated-predicate count (#109): it was paged to a
|
|
159
|
-
* total once, and the delete itself re-pages the same predicate independently
|
|
160
|
-
* — mail arriving or leaving between the two can make them differ. "about"
|
|
161
|
-
* says so up front instead of stating a number the run may not honour; a
|
|
162
|
-
* materialized (bounded) selection's count is exact and never passes it.
|
|
155
|
+
* The count is always a concrete list of ids — every predicate delete ends on
|
|
156
|
+
* the wizard's review screen instead, which states what the predicate covers.
|
|
163
157
|
*/
|
|
164
|
-
export const formatDeleteToTrashTitle = (
|
|
165
|
-
count: number,
|
|
166
|
-
isEstimate = false,
|
|
167
|
-
): string => {
|
|
158
|
+
export const formatDeleteToTrashTitle = (count: number): string => {
|
|
168
159
|
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
169
160
|
const noun = count === 1 ? "message" : "messages";
|
|
170
|
-
|
|
171
|
-
return `Move ${prefix}${quantity} ${noun} to Trash?`;
|
|
161
|
+
return `Move ${quantity} ${noun} to Trash?`;
|
|
172
162
|
};
|
|
@@ -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 = {
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapOrganizeInput,
|
|
7
7
|
} from "@remit/api-http-client/types.gen.ts";
|
|
8
8
|
import type {
|
|
9
|
-
|
|
9
|
+
MatchDoor,
|
|
10
10
|
MatchOperator,
|
|
11
11
|
RuleClause,
|
|
12
12
|
RuleScope,
|
|
@@ -105,7 +105,13 @@ export const buildOrganizeInput = (
|
|
|
105
105
|
* it holds, and the four `OrganizeScope` values fall out of the pair.
|
|
106
106
|
*/
|
|
107
107
|
export interface WizardCommitAnswers {
|
|
108
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The door the match came through. An escalated predicate is not one: it has
|
|
110
|
+
* no clauses to build a rule from and no anchor to widen, so a scope
|
|
111
|
+
* reconstructed for it would be a rule matching everything. Typed to the
|
|
112
|
+
* three doors so that cannot be written rather than merely not written.
|
|
113
|
+
*/
|
|
114
|
+
mode: MatchDoor;
|
|
109
115
|
/** Absent on the verbs that never reach the scope step — those act once. */
|
|
110
116
|
ruleScope?: RuleScope;
|
|
111
117
|
}
|
|
@@ -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
|
|
@@ -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()) {
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* rewinds every entry the wizard owns.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { type StepId, stepIndex } from "@remit/ui";
|
|
20
|
+
import { type StepId, stepIndex, type Verb } from "@remit/ui";
|
|
21
21
|
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
|
22
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
|
|
25
25
|
const stepId = z.enum([
|
|
@@ -37,6 +37,33 @@ export const wizardStepFromParam = (value: unknown): StepId | undefined => {
|
|
|
37
37
|
return parsed.success ? parsed.data : undefined;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Which affordance opened the wizard. Absent is the selection bar; `search` is
|
|
42
|
+
* the make-filter affordance in the search results header, whose clauses come
|
|
43
|
+
* from the query rather than from ticked rows (#477 1.8).
|
|
44
|
+
*
|
|
45
|
+
* It rides the URL beside the step for the same reason the step does: a
|
|
46
|
+
* reloaded document has to know which walk it is in the middle of, and the
|
|
47
|
+
* query it was seeded from is in the URL already. It decides two things and no
|
|
48
|
+
* more — which step the wizard opens on, and whether the query seeds the
|
|
49
|
+
* clauses (#477 3.4). One host answers either way.
|
|
50
|
+
*/
|
|
51
|
+
const wizardEntry = z.literal("search");
|
|
52
|
+
|
|
53
|
+
export type WizardEntry = z.infer<typeof wizardEntry>;
|
|
54
|
+
|
|
55
|
+
export const wizardEntryFromParam = (
|
|
56
|
+
value: unknown,
|
|
57
|
+
): WizardEntry | undefined => {
|
|
58
|
+
const parsed = wizardEntry.safeParse(value);
|
|
59
|
+
return parsed.success ? parsed.data : undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const wizardEntryValue = z.unknown().transform(wizardEntryFromParam);
|
|
63
|
+
|
|
64
|
+
export const useWizardEntryValue = (): WizardEntry | undefined =>
|
|
65
|
+
useSearch({ from: "/mail", select: (search) => search.wizardFrom });
|
|
66
|
+
|
|
40
67
|
/**
|
|
41
68
|
* The route's `wizard` field. A value the wizard cannot be on reads as no step
|
|
42
69
|
* rather than as a validation failure, so a truncated or hand-typed link lands
|
|
@@ -54,19 +81,66 @@ export const useWizardStepValue = (): StepId | undefined =>
|
|
|
54
81
|
|
|
55
82
|
/**
|
|
56
83
|
* Opens the wizard on a step, from a surface that does not drive it — a verb on
|
|
57
|
-
* the selection bar
|
|
58
|
-
* that leaves it lands on the list with
|
|
84
|
+
* the selection bar, or the make-filter affordance on a search. The push is the
|
|
85
|
+
* wizard's first owned entry, so the back that leaves it lands on the list with
|
|
86
|
+
* the selection still ticked.
|
|
59
87
|
*/
|
|
60
|
-
export const useOpenWizard = (): ((
|
|
88
|
+
export const useOpenWizard = (): ((
|
|
89
|
+
step: StepId,
|
|
90
|
+
entry?: WizardEntry,
|
|
91
|
+
) => void) => {
|
|
61
92
|
const navigate = useNavigate();
|
|
62
93
|
return useCallback(
|
|
63
|
-
(step: StepId) => {
|
|
64
|
-
navigate({
|
|
94
|
+
(step: StepId, entry?: WizardEntry) => {
|
|
95
|
+
navigate({
|
|
96
|
+
to: ".",
|
|
97
|
+
search: (prev) => ({ ...prev, wizard: step, wizardFrom: entry }),
|
|
98
|
+
});
|
|
65
99
|
},
|
|
66
100
|
[navigate],
|
|
67
101
|
);
|
|
68
102
|
};
|
|
69
103
|
|
|
104
|
+
export interface SelectionWizardControl {
|
|
105
|
+
/** The verb the wizard was opened for, for the host that renders it. */
|
|
106
|
+
verb: Verb;
|
|
107
|
+
/** Opens the wizard on a verb, from the match step — every bar verb, and
|
|
108
|
+
* every keyboard verb aimed at a selection, comes through here. */
|
|
109
|
+
start: (verb: Verb) => void;
|
|
110
|
+
/** Opens it on the search entry instead: the property step, with the query
|
|
111
|
+
* converted (#477 1.8). The route for a verb whose match is a predicate
|
|
112
|
+
* rather than a set of clauses. */
|
|
113
|
+
startFromSearch: () => void;
|
|
114
|
+
/**
|
|
115
|
+
* A step is held, so the wizard owns the screen. Every selection surface
|
|
116
|
+
* suspends its keyboard layer on this: a shortcut acting behind the screen
|
|
117
|
+
* already asking about an action is a second flow nobody asked for.
|
|
118
|
+
*/
|
|
119
|
+
isOpen: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The wizard as a selection surface drives it. The step lives in the URL and
|
|
124
|
+
* the verb beside it in state, in one place rather than one copy per surface —
|
|
125
|
+
* three of them had drifted apart on which verbs they even offered.
|
|
126
|
+
*/
|
|
127
|
+
export const useSelectionWizard = (): SelectionWizardControl => {
|
|
128
|
+
const step = useWizardStepValue();
|
|
129
|
+
const openWizard = useOpenWizard();
|
|
130
|
+
const [verb, setVerb] = useState<Verb>("organize");
|
|
131
|
+
const start = useCallback(
|
|
132
|
+
(next: Verb) => {
|
|
133
|
+
setVerb(next);
|
|
134
|
+
openWizard("match");
|
|
135
|
+
},
|
|
136
|
+
[openWizard],
|
|
137
|
+
);
|
|
138
|
+
const startFromSearch = useCallback(() => {
|
|
139
|
+
openWizard("properties", "search");
|
|
140
|
+
}, [openWizard]);
|
|
141
|
+
return { verb, start, startFromSearch, isOpen: step !== undefined };
|
|
142
|
+
};
|
|
143
|
+
|
|
70
144
|
export interface WizardStepNavigation {
|
|
71
145
|
step: StepId | undefined;
|
|
72
146
|
goToStep: (step: StepId) => void;
|
|
@@ -78,10 +152,15 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
|
|
|
78
152
|
const router = useRouter();
|
|
79
153
|
const navigate = useNavigate();
|
|
80
154
|
const step = useWizardStepValue();
|
|
155
|
+
const entry = useWizardEntryValue();
|
|
81
156
|
// Whether this document loaded already holding a step, which is the one
|
|
82
157
|
// entrance that leaves the wizard unrooted. A step the app itself pushed
|
|
83
158
|
// arrives rooted, so re-rooting it would duplicate the entry underneath it.
|
|
84
159
|
const loadedHoldingStep = useRef(step !== undefined);
|
|
160
|
+
// The entry that step was reached by, so the root the wizard is put back on
|
|
161
|
+
// carries neither the step nor the affordance that opened it, and the entry
|
|
162
|
+
// pushed over it carries both.
|
|
163
|
+
const loadedEntry = useRef(entry);
|
|
85
164
|
const pushedTo = useRef<StepId | undefined>(undefined);
|
|
86
165
|
|
|
87
166
|
// Two taps on Continue land before the URL settles, and both would push the
|
|
@@ -94,15 +173,24 @@ export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
|
|
|
94
173
|
useEffect(() => {
|
|
95
174
|
if (!loadedHoldingStep.current) return;
|
|
96
175
|
loadedHoldingStep.current = false;
|
|
176
|
+
const openingEntry = loadedEntry.current;
|
|
97
177
|
void (async () => {
|
|
98
178
|
await navigate({
|
|
99
179
|
to: ".",
|
|
100
|
-
search: (prev) => ({
|
|
180
|
+
search: (prev) => ({
|
|
181
|
+
...prev,
|
|
182
|
+
wizard: undefined,
|
|
183
|
+
wizardFrom: undefined,
|
|
184
|
+
}),
|
|
101
185
|
replace: true,
|
|
102
186
|
});
|
|
103
187
|
await navigate({
|
|
104
188
|
to: ".",
|
|
105
|
-
search: (prev) => ({
|
|
189
|
+
search: (prev) => ({
|
|
190
|
+
...prev,
|
|
191
|
+
wizard: openingStep,
|
|
192
|
+
wizardFrom: openingEntry,
|
|
193
|
+
}),
|
|
106
194
|
});
|
|
107
195
|
})();
|
|
108
196
|
}, [openingStep, navigate]);
|
package/src/routes/mail.tsx
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
searchInputForView,
|
|
44
44
|
shouldMirrorQuery,
|
|
45
45
|
} from "@/lib/search-view";
|
|
46
|
-
import { wizardStepValue } from "@/lib/wizard-history";
|
|
46
|
+
import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
|
|
47
47
|
import "@/lib/client";
|
|
48
48
|
|
|
49
49
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -57,6 +57,8 @@ const mailSearchSchema = z.object({
|
|
|
57
57
|
// The selection wizard's step (#477 clause 1.6). The router owns history, so
|
|
58
58
|
// the step is a validated search param rather than a raw pushState entry.
|
|
59
59
|
wizard: wizardStepValue,
|
|
60
|
+
// Which affordance opened it, so a reload lands back on the walk it was in.
|
|
61
|
+
wizardFrom: wizardEntryValue,
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
export const Route = createFileRoute("/mail")({
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
-
import React, { createElement } from "react";
|
|
5
|
-
import { renderToString } from "react-dom/server";
|
|
6
|
-
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
7
|
-
import { SearchFilterDialog } from "./SearchFilterDialog";
|
|
8
|
-
|
|
9
|
-
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
10
|
-
// runtime, which references a global `React`.
|
|
11
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
-
|
|
13
|
-
const render = (open: boolean, query = "from:receipts@stripe.com") =>
|
|
14
|
-
renderToString(
|
|
15
|
-
createElement(
|
|
16
|
-
QueryClientProvider,
|
|
17
|
-
{ client: new QueryClient() },
|
|
18
|
-
createElement(SearchFilterDialog, {
|
|
19
|
-
open,
|
|
20
|
-
accountId: "acc-1",
|
|
21
|
-
parsed: parseSearchTokens(query, {}),
|
|
22
|
-
searchHadSemanticReach: true,
|
|
23
|
-
onClose: () => undefined,
|
|
24
|
-
}),
|
|
25
|
-
) as never,
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
describe("SearchFilterDialog", () => {
|
|
29
|
-
it("renders nothing when closed", () => {
|
|
30
|
-
assert.equal(render(false), "");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("shows the conversion step while the seed preview is in flight", () => {
|
|
34
|
-
assert.match(render(true), /Turning your search into a filter/);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("opens the editor for a free-text search instead of failing to count it", () => {
|
|
38
|
-
// The free text converts to a `HasWords` clause, which the vector-free
|
|
39
|
-
// matcher refuses outright. Asking for a count is a 500, so nothing is
|
|
40
|
-
// asked: the editor opens and the count region carries the reason.
|
|
41
|
-
const html = render(true, "receipts");
|
|
42
|
-
assert.doesNotMatch(html, /Turning your search into a filter/);
|
|
43
|
-
assert.doesNotMatch(html, /Couldn't build the filter/);
|
|
44
|
-
assert.match(html, /These chips are the whole rule/);
|
|
45
|
-
assert.match(html, /reads message bodies/);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { Button, buildSearchRule, Dialog } from "@remit/ui";
|
|
2
|
-
import { Loader2 } from "lucide-react";
|
|
3
|
-
import { useMemo } from "react";
|
|
4
|
-
import { useSearchFilterSeed } from "@/hooks/useSearchFilterSeed";
|
|
5
|
-
import { rulePredicate } from "@/lib/organize/rule-model";
|
|
6
|
-
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
7
|
-
import type { ParsedSearchQuery } from "@/lib/search-tokens";
|
|
8
|
-
import { SearchFilterEditor } from "./SearchFilterEditor";
|
|
9
|
-
|
|
10
|
-
interface SearchFilterDialogProps {
|
|
11
|
-
open: boolean;
|
|
12
|
-
/** The account the filter is created for (an `account:` facet, else the active account). */
|
|
13
|
-
accountId: string;
|
|
14
|
-
/** The current search, already split into free text and facets. */
|
|
15
|
-
parsed: ParsedSearchQuery;
|
|
16
|
-
/**
|
|
17
|
-
* The search surfaced semantically-similar mail (a non-empty "Related"
|
|
18
|
-
* section). The literal filter cannot reproduce that reach, so the conversion
|
|
19
|
-
* states it — read from the search's own results, never probed (RFC 038 D5).
|
|
20
|
-
*/
|
|
21
|
-
searchHadSemanticReach: boolean;
|
|
22
|
-
onClose: () => void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* "Make this a filter" (RFC 038 D5). Converts the current search to clauses and
|
|
27
|
-
* hands off to the shared chip editor pre-filled. No new endpoint: the seed
|
|
28
|
-
* count rides `POST /organize/preview` and the commit drives the existing filter
|
|
29
|
-
* CRUD.
|
|
30
|
-
*/
|
|
31
|
-
export function SearchFilterDialog({
|
|
32
|
-
open,
|
|
33
|
-
accountId,
|
|
34
|
-
parsed,
|
|
35
|
-
searchHadSemanticReach,
|
|
36
|
-
onClose,
|
|
37
|
-
}: SearchFilterDialogProps) {
|
|
38
|
-
const conversion = useMemo(
|
|
39
|
-
() => convertSearchToRule(parsed, { searchHadSemanticReach }),
|
|
40
|
-
[parsed, searchHadSemanticReach],
|
|
41
|
-
);
|
|
42
|
-
const literalPredicate = useMemo(
|
|
43
|
-
() => rulePredicate(buildSearchRule(conversion)),
|
|
44
|
-
[conversion],
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
const { seedCount, isPending, isError, retry } = useSearchFilterSeed(
|
|
48
|
-
open ? accountId : undefined,
|
|
49
|
-
literalPredicate,
|
|
50
|
-
);
|
|
51
|
-
// A search kept as a `HasWords` clause has no seed count and never will; the
|
|
52
|
-
// editor opens on the uncountable reason rather than a dead end.
|
|
53
|
-
|
|
54
|
-
if (!open) return null;
|
|
55
|
-
|
|
56
|
-
return (
|
|
57
|
-
<Dialog open={open} onClose={onClose} title="Filter rule">
|
|
58
|
-
{isError ? (
|
|
59
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
60
|
-
<p className="text-sm font-medium text-danger">
|
|
61
|
-
Couldn't build the filter
|
|
62
|
-
</p>
|
|
63
|
-
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
64
|
-
<div className="mt-2 flex gap-2">
|
|
65
|
-
<Button variant="primary" onClick={retry}>
|
|
66
|
-
Try again
|
|
67
|
-
</Button>
|
|
68
|
-
<Button variant="ghost" onClick={onClose}>
|
|
69
|
-
Not now
|
|
70
|
-
</Button>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
) : isPending ? (
|
|
74
|
-
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
75
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
76
|
-
<p className="text-sm font-medium text-fg">
|
|
77
|
-
Turning your search into a filter…
|
|
78
|
-
</p>
|
|
79
|
-
</div>
|
|
80
|
-
) : (
|
|
81
|
-
<SearchFilterEditor
|
|
82
|
-
accountId={accountId}
|
|
83
|
-
conversion={conversion}
|
|
84
|
-
seedCount={seedCount}
|
|
85
|
-
onClose={onClose}
|
|
86
|
-
/>
|
|
87
|
-
)}
|
|
88
|
-
</Dialog>
|
|
89
|
-
);
|
|
90
|
-
}
|