@remit/web-client 0.0.91 → 0.0.93
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/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +47 -110
- package/src/components/mail/MessageList.selection.test.ts +18 -6
- package/src/components/mail/MessageList.tsx +83 -83
- package/src/components/mail/SelectionWizardHost.tsx +779 -0
- package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +23 -3
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +131 -0
- package/src/routes/mail.tsx +4 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
|
@@ -78,7 +78,11 @@ export function SearchFilterEditor({
|
|
|
78
78
|
[mailboxesData?.items],
|
|
79
79
|
);
|
|
80
80
|
|
|
81
|
-
const preview = useRulePreview(
|
|
81
|
+
const { count: preview } = useRulePreview(
|
|
82
|
+
accountId,
|
|
83
|
+
rulePredicate(rule),
|
|
84
|
+
seedCount,
|
|
85
|
+
);
|
|
82
86
|
|
|
83
87
|
const organizeJob = useOrganizeJob(accountId);
|
|
84
88
|
const createFilter = useCreateFilter(accountId);
|
|
@@ -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
|
-
|
|
47
|
-
|
|
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) {
|
package/src/hooks/useFilters.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -28,12 +28,22 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
|
28
28
|
* `seedCount` is the opening count a widen probe already knows; omit it (as the
|
|
29
29
|
* settings editor does, having no probe) to open on `loading` and preview the
|
|
30
30
|
* loaded predicate immediately.
|
|
31
|
+
*
|
|
32
|
+
* `matchedIds` are the ids that count was counted over — the server's own
|
|
33
|
+
* answer, bounded to the same cap the apply pass uses. A caller applying an
|
|
34
|
+
* action the back-apply job cannot express (a delete, a read flag) acts on
|
|
35
|
+
* exactly these rather than paging the list in the browser (#477 5.3).
|
|
31
36
|
*/
|
|
37
|
+
export interface RulePreview {
|
|
38
|
+
count: PreviewCount;
|
|
39
|
+
matchedIds: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export const useRulePreview = (
|
|
33
43
|
accountId: string | undefined,
|
|
34
44
|
predicate: OrganizeMatchPredicate,
|
|
35
45
|
seedCount?: number,
|
|
36
|
-
):
|
|
46
|
+
): RulePreview => {
|
|
37
47
|
const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
38
48
|
const { mutateAsync } = mutation;
|
|
39
49
|
const currentSignature = predicateSignature(predicate);
|
|
@@ -68,6 +78,7 @@ export const useRulePreview = (
|
|
|
68
78
|
if (latestRequested.current !== signature) return;
|
|
69
79
|
setState({
|
|
70
80
|
count: response.matchedCount,
|
|
81
|
+
matchedIds: response.messageIds,
|
|
71
82
|
previewedSignature: signature,
|
|
72
83
|
});
|
|
73
84
|
})
|
|
@@ -95,7 +106,16 @@ export const useRulePreview = (
|
|
|
95
106
|
]);
|
|
96
107
|
|
|
97
108
|
if (!countable) {
|
|
98
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
count: { status: "error", reason: UNCOUNTABLE_PREDICATE_REASON },
|
|
111
|
+
matchedIds: [],
|
|
112
|
+
};
|
|
99
113
|
}
|
|
100
|
-
return
|
|
114
|
+
return {
|
|
115
|
+
count: derivePreview(state, currentSignature),
|
|
116
|
+
matchedIds:
|
|
117
|
+
state.previewedSignature === currentSignature
|
|
118
|
+
? (state.matchedIds ?? [])
|
|
119
|
+
: [],
|
|
120
|
+
};
|
|
101
121
|
};
|
|
@@ -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
|
|
@@ -254,6 +254,8 @@ export const ruleToDraft = (
|
|
|
254
254
|
export interface PreviewState {
|
|
255
255
|
/** The last count that came back, `undefined` before the first lands. */
|
|
256
256
|
count?: number;
|
|
257
|
+
/** The ids that count was counted over, as the server bounded them. */
|
|
258
|
+
matchedIds?: readonly string[];
|
|
257
259
|
/** The signature the {@link count} was counted for. */
|
|
258
260
|
previewedSignature?: string;
|
|
259
261
|
/** The signature the last error was raised for. */
|
|
@@ -28,17 +28,31 @@ describe("deriveIsMultiSelectMode", () => {
|
|
|
28
28
|
|
|
29
29
|
describe("shouldExitSelectionOnNavigate", () => {
|
|
30
30
|
test("back while selecting exits selection instead of navigating", () => {
|
|
31
|
-
assert.equal(shouldExitSelectionOnNavigate("BACK", true), true);
|
|
31
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, undefined), true);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("back with nothing selected is left alone", () => {
|
|
35
|
-
assert.equal(
|
|
35
|
+
assert.equal(
|
|
36
|
+
shouldExitSelectionOnNavigate("BACK", false, undefined),
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("back inside the wizard pops a step instead of the selection", () => {
|
|
42
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, "match"), false);
|
|
43
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, "review"), false);
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
test("forward, push, replace and go are never blocked", () => {
|
|
39
|
-
assert.equal(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
assert.equal(
|
|
48
|
+
shouldExitSelectionOnNavigate("FORWARD", true, undefined),
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(shouldExitSelectionOnNavigate("PUSH", true, undefined), false);
|
|
52
|
+
assert.equal(
|
|
53
|
+
shouldExitSelectionOnNavigate("REPLACE", true, undefined),
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(shouldExitSelectionOnNavigate("GO", true, undefined), false);
|
|
43
57
|
});
|
|
44
58
|
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* and 10). Pure so "one source of truth, one exit" is testable without a DOM.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { StepId } from "@remit/ui";
|
|
7
|
+
|
|
6
8
|
/** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
|
|
7
9
|
export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
|
|
8
10
|
|
|
@@ -22,8 +24,13 @@ export const deriveIsMultiSelectMode = (
|
|
|
22
24
|
* Whether a history navigation should exit selection mode instead of leaving
|
|
23
25
|
* the route. Only the back gesture is intercepted, so a navigation the app
|
|
24
26
|
* itself starts (opening a message, switching mailboxes) is never blocked.
|
|
27
|
+
*
|
|
28
|
+
* While the selection wizard is open its own steps own the back gesture, and
|
|
29
|
+
* the selection is what the wizard is acting on — swallowing back there would
|
|
30
|
+
* clear the selection out from under the flow instead of popping a step.
|
|
25
31
|
*/
|
|
26
32
|
export const shouldExitSelectionOnNavigate = (
|
|
27
33
|
action: NavigationAction,
|
|
28
34
|
hasSelection: boolean,
|
|
29
|
-
|
|
35
|
+
wizardStep: StepId | undefined,
|
|
36
|
+
): boolean => action === "BACK" && hasSelection && wizardStep === undefined;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { 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
|
+
import {
|
|
7
|
+
backExits,
|
|
8
|
+
type MatchMode,
|
|
9
|
+
type RuleScope,
|
|
10
|
+
type StepId,
|
|
11
|
+
stepIndex,
|
|
12
|
+
stepsFor,
|
|
13
|
+
type Verb,
|
|
14
|
+
type WizardAnswers,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import {
|
|
17
|
+
ownedHistoryEntries,
|
|
18
|
+
wizardStepFromParam,
|
|
19
|
+
wizardStepValue,
|
|
20
|
+
} from "./wizard-history.js";
|
|
21
|
+
|
|
22
|
+
const VERBS: readonly Verb[] = [
|
|
23
|
+
"delete",
|
|
24
|
+
"move",
|
|
25
|
+
"junk",
|
|
26
|
+
"markRead",
|
|
27
|
+
"organize",
|
|
28
|
+
];
|
|
29
|
+
const MODES: readonly MatchMode[] = ["selected", "similar", "properties"];
|
|
30
|
+
const SCOPES: readonly (RuleScope | undefined)[] = [
|
|
31
|
+
undefined,
|
|
32
|
+
"once",
|
|
33
|
+
"standing",
|
|
34
|
+
"until",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const answerSets = (): WizardAnswers[] => {
|
|
38
|
+
const sets: WizardAnswers[] = [];
|
|
39
|
+
for (const verb of VERBS) {
|
|
40
|
+
for (const mode of MODES) {
|
|
41
|
+
for (const scope of SCOPES) {
|
|
42
|
+
for (const fromSearch of [false, true]) {
|
|
43
|
+
sets.push({ verb, mode, scope, fromSearch });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return sets;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const source = readFileSync(resolve(here, "wizard-history.ts"), "utf8");
|
|
53
|
+
|
|
54
|
+
/** The steps up to and including the one an answer is given on. */
|
|
55
|
+
const prefixThrough = (steps: readonly StepId[], step: StepId): StepId[] =>
|
|
56
|
+
steps.slice(0, steps.indexOf(step) + 1);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The re-root runs inside a router, so the rule is enforced by reading the
|
|
60
|
+
* source — as `../components/mail/MessageList.selection.test.ts` does for its
|
|
61
|
+
* own component-level rules. What it pins is where the decision comes from: a
|
|
62
|
+
* step the app itself pushed must never be re-rooted, or the push that opens
|
|
63
|
+
* the wizard from a verb (#483) duplicates the entry underneath it and the
|
|
64
|
+
* first back after Cancel appears to do nothing.
|
|
65
|
+
*/
|
|
66
|
+
describe("re-rooting a wizard that was loaded into", () => {
|
|
67
|
+
it("decides from what the first render held, not from a flag", () => {
|
|
68
|
+
assert.match(source, /useRef\(step !== undefined\)/);
|
|
69
|
+
assert.match(
|
|
70
|
+
source,
|
|
71
|
+
/if \(!loadedHoldingStep\.current\) return;\s*\n\s*loadedHoldingStep\.current = false;/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is never armed by a step the app pushed", () => {
|
|
76
|
+
const goToStep = source.slice(source.indexOf("const goToStep"));
|
|
77
|
+
assert.doesNotMatch(goToStep, /loadedHoldingStep/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not re-run when the step changes", () => {
|
|
81
|
+
assert.doesNotMatch(source, /\}, \[step, openingStep, navigate\]\)/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("the wizard step in the URL", () => {
|
|
86
|
+
it("round-trips every step the wizard can reach", () => {
|
|
87
|
+
for (const answers of answerSets()) {
|
|
88
|
+
for (const step of stepsFor(answers)) {
|
|
89
|
+
assert.equal(wizardStepFromParam(step), step);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reads a value the wizard cannot be on as no step", () => {
|
|
95
|
+
for (const value of [undefined, "", "Match", "step-1", 2, null, {}]) {
|
|
96
|
+
assert.equal(wizardStepFromParam(value), undefined);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("never fails validation, so a mistyped link still lands on the mail", () => {
|
|
101
|
+
for (const value of ["nope", "MATCH", "run ", 7, [], null, undefined]) {
|
|
102
|
+
const parsed = wizardStepValue.safeParse(value);
|
|
103
|
+
assert.ok(parsed.success);
|
|
104
|
+
assert.equal(parsed.data, undefined);
|
|
105
|
+
}
|
|
106
|
+
assert.equal(wizardStepValue.parse("review"), "review");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the history entries the wizard owns", () => {
|
|
111
|
+
it("is one per step reached, on every shape of the list", () => {
|
|
112
|
+
for (const answers of answerSets()) {
|
|
113
|
+
const steps = stepsFor(answers);
|
|
114
|
+
steps.forEach((step, position) => {
|
|
115
|
+
assert.equal(ownedHistoryEntries(steps, step), position + 1);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("cannot be moved by the match door, which is answered before it", () => {
|
|
121
|
+
for (const verb of VERBS) {
|
|
122
|
+
for (const scope of SCOPES) {
|
|
123
|
+
const prefixes = MODES.map((mode) =>
|
|
124
|
+
prefixThrough(stepsFor({ verb, mode, scope }), "match"),
|
|
125
|
+
);
|
|
126
|
+
for (const prefix of prefixes) {
|
|
127
|
+
assert.deepEqual(prefix, prefixes[0]);
|
|
128
|
+
for (const step of prefix) {
|
|
129
|
+
const counts = MODES.map((mode) =>
|
|
130
|
+
ownedHistoryEntries(stepsFor({ verb, mode, scope }), step),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(new Set(counts).size, 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cannot be moved by the scope, which is answered before it", () => {
|
|
140
|
+
for (const mode of MODES) {
|
|
141
|
+
for (const fromSearch of [false, true]) {
|
|
142
|
+
const listFor = (scope: RuleScope | undefined) =>
|
|
143
|
+
stepsFor({ verb: "organize", mode, scope, fromSearch });
|
|
144
|
+
const prefixes = SCOPES.map((scope) =>
|
|
145
|
+
prefixThrough(listFor(scope), "rule"),
|
|
146
|
+
);
|
|
147
|
+
for (const prefix of prefixes) {
|
|
148
|
+
assert.deepEqual(prefix, prefixes[0]);
|
|
149
|
+
for (const step of prefix) {
|
|
150
|
+
const counts = SCOPES.map((scope) =>
|
|
151
|
+
ownedHistoryEntries(listFor(scope), step),
|
|
152
|
+
);
|
|
153
|
+
assert.equal(new Set(counts).size, 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rewinds the whole flow from the steps Back leaves on", () => {
|
|
161
|
+
for (const answers of answerSets()) {
|
|
162
|
+
const steps = stepsFor(answers);
|
|
163
|
+
assert.ok(backExits(steps, steps[0]));
|
|
164
|
+
assert.equal(ownedHistoryEntries(steps, steps[0]), 1);
|
|
165
|
+
const run = steps[steps.length - 1];
|
|
166
|
+
assert.ok(backExits(steps, run));
|
|
167
|
+
assert.equal(ownedHistoryEntries(steps, run), steps.length);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("holds a step the answers dropped to the opening entry", () => {
|
|
172
|
+
const steps = stepsFor({ verb: "delete", mode: "selected" });
|
|
173
|
+
assert.equal(stepIndex(steps, "properties"), 0);
|
|
174
|
+
assert.equal(ownedHistoryEntries(steps, "properties"), 1);
|
|
175
|
+
});
|
|
176
|
+
});
|