@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.
- package/package.json +1 -1
- package/src/components/mail/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +57 -112
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MessageList.selection.test.ts +10 -5
- package/src/components/mail/MessageList.tsx +78 -82
- package/src/components/mail/SelectionWizardHost.tsx +721 -64
- package/src/components/mail/ThreadListInteraction.tsx +0 -9
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- 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 +24 -5
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/mail-context.ts +0 -9
- 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 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +65 -2
- package/src/routes/mail.tsx +3 -8
- 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/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 -136
- 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/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
|
@@ -1,59 +1,429 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
1
2
|
import {
|
|
2
3
|
type ClauseDraft,
|
|
3
4
|
type ClauseEditState,
|
|
5
|
+
crossAccountDestinationReason,
|
|
6
|
+
crossAccountRuleReason,
|
|
7
|
+
derivePropertyClauses,
|
|
8
|
+
deriveSenderClauses,
|
|
9
|
+
dominantSender,
|
|
4
10
|
type MatchCount,
|
|
5
11
|
type MatchMode,
|
|
12
|
+
type MoveMailboxOption,
|
|
13
|
+
type RuleClause,
|
|
6
14
|
type RunState,
|
|
15
|
+
type SearchConversion,
|
|
7
16
|
SelectionWizard,
|
|
8
17
|
type StepId,
|
|
18
|
+
searchConversionNotice,
|
|
19
|
+
senderLabel,
|
|
9
20
|
stepBlockedReason,
|
|
10
21
|
stepIndex,
|
|
11
22
|
stepsFor,
|
|
23
|
+
suggestRuleName,
|
|
12
24
|
type Verb,
|
|
13
25
|
type WizardDraft,
|
|
14
26
|
type WizardMessage,
|
|
15
27
|
} from "@remit/ui";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
28
|
+
import { useQuery } from "@tanstack/react-query";
|
|
29
|
+
import { useBlocker } from "@tanstack/react-router";
|
|
30
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import {
|
|
32
|
+
useFolderAppointments,
|
|
33
|
+
useJunkMailbox,
|
|
34
|
+
} from "@/hooks/useArchiveMailbox";
|
|
35
|
+
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
36
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
37
|
+
import {
|
|
38
|
+
type EscalatedAction,
|
|
39
|
+
useEscalatedActions,
|
|
40
|
+
} from "@/hooks/useEscalatedActions";
|
|
41
|
+
import { useCreateFilter } from "@/hooks/useFilters";
|
|
42
|
+
import { useMatchSample } from "@/hooks/useMatchSample";
|
|
43
|
+
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
44
|
+
import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
45
|
+
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
46
|
+
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
47
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
48
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
49
|
+
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
50
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
51
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
52
|
+
import {
|
|
53
|
+
buildWizardDraft,
|
|
54
|
+
canBackApplyDraft,
|
|
55
|
+
type OrganizeDraft,
|
|
56
|
+
type OrganizeScope,
|
|
57
|
+
organizeScopeFor,
|
|
58
|
+
} from "@/lib/organize/organize-model";
|
|
59
|
+
import {
|
|
60
|
+
normalizeClauseValue,
|
|
61
|
+
SUPPORTED_CLAUSE_FIELDS,
|
|
62
|
+
} from "@/lib/organize/rule-model";
|
|
63
|
+
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
64
|
+
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
65
|
+
|
|
66
|
+
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
67
|
+
|
|
68
|
+
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
69
|
+
export interface WizardSelectionMessage extends WizardMessage {
|
|
70
|
+
/** Sender address — the widen's literal fallback and the prefill match on it. */
|
|
71
|
+
email: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
75
|
+
/** The step the URL holds. The session is mounted only while there is one. */
|
|
76
|
+
step: StepId;
|
|
77
|
+
goToStep: (step: StepId) => void;
|
|
78
|
+
goBack: () => void;
|
|
79
|
+
closeWizard: (steps: readonly StepId[], step: StepId) => void;
|
|
80
|
+
/**
|
|
81
|
+
* The wizard was entered by converting a search rather than by ticking rows
|
|
82
|
+
* (#477 1.8). The conversion seeds the clauses on the properties step and the
|
|
83
|
+
* notice above them, and nothing else: every step after that is the one a
|
|
84
|
+
* selection from the inbox walks (#477 3.4). Absent for the selection bar's
|
|
85
|
+
* entry.
|
|
86
|
+
*/
|
|
87
|
+
searchConversion?: SearchConversion;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SelectionWizardHostProps {
|
|
91
|
+
/** What the bar was pressed for. Every verb walks the same wizard. */
|
|
92
|
+
verb: Verb;
|
|
93
|
+
/** The account the ticked rows belong to, absent when they span several. */
|
|
94
|
+
accountId?: string;
|
|
95
|
+
/** Where the selection was made, so a run invalidates the listing it changed. */
|
|
96
|
+
mailboxId?: string;
|
|
97
|
+
selection: readonly WizardSelectionMessage[];
|
|
98
|
+
/** The ticked rows span more than one account, so no rule can be created. */
|
|
99
|
+
crossAccount?: boolean;
|
|
100
|
+
/** The wizard is done with the selection, and the list drops it. */
|
|
101
|
+
onFinished: () => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A search entry with no query behind it — a link typed by hand, or one whose
|
|
106
|
+
* query is gone. It opens the same properties step with nothing seeded, which
|
|
107
|
+
* the step already states, rather than a wizard that renders nothing.
|
|
108
|
+
*/
|
|
109
|
+
const NO_CONVERSION: SearchConversion = {
|
|
110
|
+
clauses: [],
|
|
111
|
+
matchOperator: "all",
|
|
112
|
+
droppedFacets: [],
|
|
113
|
+
keptTerms: false,
|
|
114
|
+
droppedSemantic: false,
|
|
115
|
+
};
|
|
18
116
|
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
|
|
117
|
+
/** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
|
|
118
|
+
const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
|
|
22
119
|
|
|
23
|
-
/** How
|
|
24
|
-
|
|
120
|
+
/** How far a commit has got, whichever of the three ways it took. */
|
|
121
|
+
interface RunSnapshot {
|
|
25
122
|
state: RunState;
|
|
26
123
|
matched: number;
|
|
27
124
|
applied: number;
|
|
125
|
+
failed: number;
|
|
28
126
|
failures: readonly WizardMessage[];
|
|
29
127
|
}
|
|
30
128
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
state: "commitFailed",
|
|
129
|
+
const NOT_STARTED: RunSnapshot = {
|
|
130
|
+
state: "saving",
|
|
34
131
|
matched: 0,
|
|
35
132
|
applied: 0,
|
|
133
|
+
failed: 0,
|
|
36
134
|
failures: [],
|
|
37
135
|
};
|
|
38
136
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
137
|
+
const withIds = (
|
|
138
|
+
clauses: readonly Omit<RuleClause, "id">[],
|
|
139
|
+
prefix: string,
|
|
140
|
+
): RuleClause[] =>
|
|
141
|
+
clauses.map((clause, index) => ({ ...clause, id: `${prefix}-${index}` }));
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The bulk call a verb makes over a concrete list of ids. Junk is a move: the
|
|
145
|
+
* message-flags API has no `$Junk` field, so the Junk verb files into the
|
|
146
|
+
* account's appointed Junk mailbox, and Move and Organize into the folder the
|
|
147
|
+
* folder step chose.
|
|
148
|
+
*/
|
|
149
|
+
const bulkActionFor = (
|
|
150
|
+
verb: Verb,
|
|
151
|
+
moveMailboxId: string | undefined,
|
|
152
|
+
junkMailboxId: string | undefined,
|
|
153
|
+
): EscalatedAction | undefined => {
|
|
154
|
+
if (verb === "delete") return { kind: "delete" };
|
|
155
|
+
if (verb === "markRead") return { kind: "markRead" };
|
|
156
|
+
const destinationMailboxId = verb === "junk" ? junkMailboxId : moveMailboxId;
|
|
157
|
+
return destinationMailboxId
|
|
158
|
+
? { kind: "move", destinationMailboxId }
|
|
159
|
+
: undefined;
|
|
160
|
+
};
|
|
44
161
|
|
|
45
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Whether a widened match is applied by the server's own pass rather than over
|
|
164
|
+
* the ids it matched. The back-apply job carries a move and nothing else, so
|
|
165
|
+
* Move, Junk and Organize hand it the whole match; Delete and Mark read have no
|
|
166
|
+
* server-side pass and act on the ids the preview matched.
|
|
167
|
+
*/
|
|
168
|
+
const widenedRunsAsJob = (verb: Verb): boolean =>
|
|
169
|
+
verb === "move" || verb === "junk" || verb === "organize";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where the wizard meets the app (#483). The steps and their bodies belong to
|
|
173
|
+
* `@remit/ui`; everything that talks to a server is here: the widen, the live
|
|
174
|
+
* count, the folder create, the bulk call, the back-apply job and the filter.
|
|
175
|
+
*
|
|
176
|
+
* Every commit routes through `organize-model.ts`, so the four `OrganizeScope`
|
|
177
|
+
* values are reconstructed in one place from the two answers the wizard asks
|
|
178
|
+
* for — what the action covers, and how long it holds.
|
|
179
|
+
*
|
|
180
|
+
* One walk of the wizard, and only that. Everything the walk answers — the door,
|
|
181
|
+
* the draft, whether a commit has been sent — lives here rather than in the host
|
|
182
|
+
* that mounts it, so closing the wizard takes all of it with the screen. Held a
|
|
183
|
+
* level up, on a component the list mounts for as long as it is on screen, the
|
|
184
|
+
* first commit's guard would still be up when the next selection reached Review,
|
|
185
|
+
* and the second one would press a control that does nothing at all.
|
|
186
|
+
*/
|
|
187
|
+
function SelectionWizardSession({
|
|
46
188
|
verb,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
189
|
+
accountId,
|
|
190
|
+
mailboxId,
|
|
191
|
+
selection,
|
|
192
|
+
crossAccount = false,
|
|
193
|
+
searchConversion,
|
|
194
|
+
onFinished,
|
|
195
|
+
step,
|
|
196
|
+
goToStep,
|
|
197
|
+
goBack,
|
|
198
|
+
closeWizard,
|
|
199
|
+
}: SelectionWizardSessionProps) {
|
|
200
|
+
// The query as it read when the wizard opened. Held for the walk, so a search
|
|
201
|
+
// still settling underneath cannot rewrite the notice a user is reading while
|
|
202
|
+
// the clauses beside it stay as they were seeded.
|
|
203
|
+
const [conversion] = useState(() => searchConversion);
|
|
204
|
+
const fromSearch = conversion !== undefined;
|
|
205
|
+
const [mode, setMode] = useState<MatchMode>(
|
|
206
|
+
fromSearch ? "properties" : "selected",
|
|
207
|
+
);
|
|
208
|
+
const [draft, setDraft] = useState<WizardDraft>(() =>
|
|
209
|
+
conversion
|
|
210
|
+
? {
|
|
211
|
+
clauses: withIds(conversion.clauses, "search"),
|
|
212
|
+
matchOperator: conversion.matchOperator,
|
|
213
|
+
}
|
|
214
|
+
: EMPTY_DRAFT,
|
|
215
|
+
);
|
|
53
216
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
54
217
|
undefined,
|
|
55
218
|
);
|
|
56
219
|
const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
|
|
220
|
+
const [semanticFallbackTaken, setSemanticFallbackTaken] = useState(false);
|
|
221
|
+
const [committedScope, setCommittedScope] = useState<
|
|
222
|
+
OrganizeScope | undefined
|
|
223
|
+
>(undefined);
|
|
224
|
+
const [bulkRun, setBulkRun] = useState<
|
|
225
|
+
{ matched: number; outcome?: BulkRunOutcome } | undefined
|
|
226
|
+
>(undefined);
|
|
227
|
+
// The predicate the create chains its pass to. Undefined when the rule cannot
|
|
228
|
+
// be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
|
|
229
|
+
// in which case the filter still saves and applies to incoming mail. Kept, not
|
|
230
|
+
// cleared, so a failed start can be retried.
|
|
231
|
+
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
232
|
+
const commitSent = useRef(false);
|
|
233
|
+
|
|
234
|
+
const messageIds = useMemo(
|
|
235
|
+
() => selection.map((message) => message.id),
|
|
236
|
+
[selection],
|
|
237
|
+
);
|
|
238
|
+
const senders = useMemo(
|
|
239
|
+
() => selection.map((message) => message.email).filter(Boolean),
|
|
240
|
+
[selection],
|
|
241
|
+
);
|
|
242
|
+
const anchorMessageId = messageIds[0];
|
|
243
|
+
const subjects = useSelectedSubjects(messageIds);
|
|
244
|
+
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
245
|
+
|
|
246
|
+
const widen = useOrganizeWiden(accountId, anchorMessageId, senders);
|
|
247
|
+
const { preview: probeWiden } = widen;
|
|
248
|
+
// The similar door has to know before it is pressed whether it can run, so
|
|
249
|
+
// the probe fires with the wizard rather than with the door (#477 3.6).
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
probeWiden();
|
|
252
|
+
}, [probeWiden]);
|
|
253
|
+
|
|
254
|
+
const widened = mode !== "selected";
|
|
255
|
+
const literalPredicate: OrganizeMatchPredicate = useMemo(
|
|
256
|
+
() => ({
|
|
257
|
+
matchOperator: draft.matchOperator === "all" ? "And" : "Or",
|
|
258
|
+
literalClauses: draft.clauses.map((clause) => ({
|
|
259
|
+
field: clause.field,
|
|
260
|
+
value: clause.value,
|
|
261
|
+
})),
|
|
262
|
+
}),
|
|
263
|
+
[draft.clauses, draft.matchOperator],
|
|
264
|
+
);
|
|
265
|
+
const predicate =
|
|
266
|
+
mode === "similar" ? widen.matchPredicate : literalPredicate;
|
|
267
|
+
// The count and the ids both come from the server, never from paging the list
|
|
268
|
+
// in the browser (#477 5.3). The ticked list is its own count, so no request
|
|
269
|
+
// is made for it.
|
|
270
|
+
const { count: previewCount, matchedIds } = useRulePreview(
|
|
271
|
+
widened ? accountId : undefined,
|
|
272
|
+
predicate,
|
|
273
|
+
);
|
|
274
|
+
const matchSample = useMatchSample(widened ? matchedIds : []);
|
|
275
|
+
|
|
276
|
+
const count: MatchCount = widened
|
|
277
|
+
? previewCount
|
|
278
|
+
: { status: "ready", count: selection.length };
|
|
279
|
+
|
|
280
|
+
const { data: mailboxesData } = useQuery({
|
|
281
|
+
...mailboxOperationsListMailboxesOptions({
|
|
282
|
+
path: { accountId: accountId ?? "" },
|
|
283
|
+
}),
|
|
284
|
+
enabled: !!accountId,
|
|
285
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
286
|
+
});
|
|
287
|
+
const folderAppointments = useFolderAppointments(accountId);
|
|
288
|
+
const mailboxes = useMemo<MoveMailboxOption[]>(
|
|
289
|
+
() =>
|
|
290
|
+
buildMoveTargets(mailboxesData?.items ?? [], folderAppointments).map(
|
|
291
|
+
(mailbox) => ({
|
|
292
|
+
id: mailbox.mailboxId,
|
|
293
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
294
|
+
searchValue: mailbox.fullPath,
|
|
295
|
+
isCurrent: mailbox.mailboxId === mailboxId,
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
[mailboxesData?.items, folderAppointments, mailboxId],
|
|
299
|
+
);
|
|
300
|
+
const { createFolder } = useCreateMailbox(accountId);
|
|
301
|
+
|
|
302
|
+
const folderLabel = mailboxes.find(
|
|
303
|
+
(mailbox) => mailbox.id === draft.moveMailboxId,
|
|
304
|
+
)?.label;
|
|
305
|
+
const leadSender = dominantSender(
|
|
306
|
+
selection.map((message) => ({
|
|
307
|
+
normalizedEmail: message.email,
|
|
308
|
+
displayName: message.sender,
|
|
309
|
+
})),
|
|
310
|
+
);
|
|
311
|
+
const suggestedName = suggestRuleName({
|
|
312
|
+
match:
|
|
313
|
+
mode === "properties"
|
|
314
|
+
? draft.clauses[0]?.value.trim() || undefined
|
|
315
|
+
: undefined,
|
|
316
|
+
sender: leadSender ? senderLabel(leadSender) : undefined,
|
|
317
|
+
folder: folderLabel,
|
|
318
|
+
});
|
|
319
|
+
// The name the rule commits under: what the user typed, or the suggestion the
|
|
320
|
+
// field is showing them. The two must be the same string, or Continue blocks
|
|
321
|
+
// on a name that is on screen.
|
|
322
|
+
const named = useMemo<WizardDraft>(
|
|
323
|
+
() => ({ ...draft, name: draft.name ?? suggestedName }),
|
|
324
|
+
[draft, suggestedName],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const clauseSuggestions = useClauseSuggestions(
|
|
328
|
+
clauseEdit?.draft.field,
|
|
329
|
+
clauseEdit?.draft.value ?? "",
|
|
330
|
+
senders,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const organizeJob = useOrganizeJob(accountId);
|
|
334
|
+
const createFilter = useCreateFilter(accountId);
|
|
335
|
+
const bulk = useEscalatedActions({
|
|
336
|
+
mailboxId: mailboxId ?? "",
|
|
337
|
+
accountId,
|
|
338
|
+
enabled: false,
|
|
339
|
+
predicateKey: "selection-wizard",
|
|
340
|
+
searchQuery: {},
|
|
341
|
+
});
|
|
342
|
+
const { runAction } = bulk;
|
|
343
|
+
|
|
344
|
+
const steps = stepsFor({ verb, mode, scope: draft.scope, fromSearch });
|
|
345
|
+
// The step the screens are on, which is the held one only while the answers
|
|
346
|
+
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
347
|
+
// is how a footer comes to name a screen nobody is looking at.
|
|
348
|
+
const current = steps[stepIndex(steps, step)];
|
|
349
|
+
|
|
350
|
+
// A filter and a folder both belong to one account, so a selection spanning
|
|
351
|
+
// accounts reaches neither and is told so on the step that asks (#477 5.5).
|
|
352
|
+
// The one-off scope acts on the messages themselves and is unaffected.
|
|
353
|
+
const accountScoped = !crossAccount && !!accountId;
|
|
354
|
+
const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
|
|
355
|
+
const folderRestriction = accountScoped
|
|
356
|
+
? undefined
|
|
357
|
+
: crossAccountDestinationReason;
|
|
358
|
+
const restrictionFor = (step: StepId): string | undefined => {
|
|
359
|
+
if (step === "folder") return folderRestriction;
|
|
360
|
+
if (step !== "rule") return undefined;
|
|
361
|
+
return named.scope === "standing" || named.scope === "until"
|
|
362
|
+
? ruleRestriction
|
|
363
|
+
: undefined;
|
|
364
|
+
};
|
|
365
|
+
const blockedReason =
|
|
366
|
+
restrictionFor(current) ?? stepBlockedReason(current, named, count);
|
|
367
|
+
|
|
368
|
+
const seedPropertyClauses = useCallback(
|
|
369
|
+
() => withIds(derivePropertyClauses(senders, subjects), "seed"),
|
|
370
|
+
[senders, subjects],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const changeMode = useCallback(
|
|
374
|
+
(next: MatchMode) => {
|
|
375
|
+
setMode(next);
|
|
376
|
+
setDraft((held) => ({
|
|
377
|
+
...held,
|
|
378
|
+
widen:
|
|
379
|
+
next === "similar" && anchorMessageId
|
|
380
|
+
? { anchorCount: Math.max(selection.length, 1) }
|
|
381
|
+
: undefined,
|
|
382
|
+
...(next === "properties" && held.clauses.length === 0
|
|
383
|
+
? { clauses: seedPropertyClauses() }
|
|
384
|
+
: {}),
|
|
385
|
+
}));
|
|
386
|
+
},
|
|
387
|
+
[anchorMessageId, selection.length, seedPropertyClauses],
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// The dimmed similar door, pressed. The senders the widen would have fallen
|
|
391
|
+
// back to are filled in on the property step instead of standing in for the
|
|
392
|
+
// semantic match without saying so (#477 3.6).
|
|
393
|
+
const takeSemanticFallback = useCallback(() => {
|
|
394
|
+
setSemanticFallbackTaken(true);
|
|
395
|
+
setMode("properties");
|
|
396
|
+
setDraft((held) => ({
|
|
397
|
+
...held,
|
|
398
|
+
widen: undefined,
|
|
399
|
+
clauses: withIds(deriveSenderClauses(senders), "sender"),
|
|
400
|
+
matchOperator: "any",
|
|
401
|
+
}));
|
|
402
|
+
goToStep("properties");
|
|
403
|
+
}, [senders, goToStep]);
|
|
404
|
+
|
|
405
|
+
// The probe lands after the door is on screen, so it can report the widen
|
|
406
|
+
// unavailable while the user is already holding it. The fallback is taken then
|
|
407
|
+
// rather than left as a door that counts nothing: the property step says, in
|
|
408
|
+
// so many words, that these are the senders it substituted (#477 3.6).
|
|
409
|
+
const semanticUnavailable = widen.semanticUnavailable || widen.isError;
|
|
410
|
+
// Only while the door is the screen: the probe re-fires when the anchor
|
|
411
|
+
// changes under a background refetch, and a late answer that moved the step
|
|
412
|
+
// from Review would push an entry the wizard does not own and leave the count
|
|
413
|
+
// it rewinds by wrong.
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (current !== "match") return;
|
|
416
|
+
if (mode !== "similar" || !semanticUnavailable || semanticFallbackTaken) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
takeSemanticFallback();
|
|
420
|
+
}, [
|
|
421
|
+
current,
|
|
422
|
+
mode,
|
|
423
|
+
semanticUnavailable,
|
|
424
|
+
semanticFallbackTaken,
|
|
425
|
+
takeSemanticFallback,
|
|
426
|
+
]);
|
|
57
427
|
|
|
58
428
|
const startAddClause = useCallback(() => {
|
|
59
429
|
setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
|
|
@@ -86,41 +456,263 @@ export function SelectionWizardHost({
|
|
|
86
456
|
const submitClause = useCallback(() => {
|
|
87
457
|
setClauseEdit((edit) => {
|
|
88
458
|
if (!edit) return undefined;
|
|
459
|
+
const value = normalizeClauseValue(edit.draft.field, edit.draft.value);
|
|
460
|
+
const next = { ...edit.draft, value };
|
|
89
461
|
setDraft((held) => ({
|
|
90
462
|
...held,
|
|
91
463
|
clauses: edit.clauseId
|
|
92
464
|
? held.clauses.map((clause) =>
|
|
93
|
-
clause.id === edit.clauseId
|
|
94
|
-
? { ...clause, ...edit.draft }
|
|
95
|
-
: clause,
|
|
465
|
+
clause.id === edit.clauseId ? { ...clause, ...next } : clause,
|
|
96
466
|
)
|
|
97
|
-
: [...held.clauses, { id: crypto.randomUUID(), ...
|
|
467
|
+
: [...held.clauses, { id: crypto.randomUUID(), ...next }],
|
|
98
468
|
}));
|
|
99
469
|
return undefined;
|
|
100
470
|
});
|
|
101
471
|
}, []);
|
|
102
472
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const steps = stepsFor({ verb, mode, scope: draft.scope });
|
|
106
|
-
// The step the screens are on, which is the held one only while the answers
|
|
107
|
-
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
108
|
-
// is how a footer comes to name a screen nobody is looking at.
|
|
109
|
-
const current = steps[stepIndex(steps, step)];
|
|
110
|
-
const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
|
|
111
|
-
const sample = {
|
|
112
|
-
messages: [],
|
|
113
|
-
count: UNCOUNTED,
|
|
114
|
-
label: "Messages this covers",
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const advance = () => {
|
|
473
|
+
const advance = useCallback(() => {
|
|
118
474
|
if (blockedReason) {
|
|
119
475
|
setNudgedStep(current);
|
|
120
476
|
return;
|
|
121
477
|
}
|
|
478
|
+
setNudgedStep(undefined);
|
|
122
479
|
const next = steps[stepIndex(steps, current) + 1];
|
|
123
480
|
if (next) goToStep(next);
|
|
481
|
+
}, [blockedReason, current, steps, goToStep]);
|
|
482
|
+
|
|
483
|
+
const runBulk = useCallback(
|
|
484
|
+
async (ids: readonly string[]) => {
|
|
485
|
+
const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
|
|
486
|
+
if (!action) {
|
|
487
|
+
setBulkRun({
|
|
488
|
+
matched: ids.length,
|
|
489
|
+
outcome: {
|
|
490
|
+
done: 0,
|
|
491
|
+
failedIds: [...ids],
|
|
492
|
+
cancelled: false,
|
|
493
|
+
error: new Error(
|
|
494
|
+
verb === "junk"
|
|
495
|
+
? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
|
|
496
|
+
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
setBulkRun({ matched: ids.length });
|
|
503
|
+
const outcome = await runAction(action, [...ids]);
|
|
504
|
+
setBulkRun({ matched: ids.length, outcome });
|
|
505
|
+
},
|
|
506
|
+
[verb, named.moveMailboxId, junkMailboxId, runAction],
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const { start: startJob } = organizeJob;
|
|
510
|
+
const { createFilterAsync } = createFilter;
|
|
511
|
+
|
|
512
|
+
const sendCommit = useCallback(() => {
|
|
513
|
+
const scope = organizeScopeFor({ mode, ruleScope: named.scope });
|
|
514
|
+
const organizeDraft = buildWizardDraft({
|
|
515
|
+
mode,
|
|
516
|
+
ruleScope: named.scope,
|
|
517
|
+
anchorMessageId,
|
|
518
|
+
clauses: named.clauses,
|
|
519
|
+
matchOperator: named.matchOperator,
|
|
520
|
+
moveMailboxId: named.moveMailboxId,
|
|
521
|
+
until: named.until,
|
|
522
|
+
});
|
|
523
|
+
setCommittedScope(scope);
|
|
524
|
+
|
|
525
|
+
if (scope === "standing" || scope === "temporary") {
|
|
526
|
+
// Creating a filter also moves the mail that already matches, not only
|
|
527
|
+
// the mail that arrives next. The pass is chained to the create's own
|
|
528
|
+
// request rather than to this screen: the screen offers a Close while
|
|
529
|
+
// the create is still in flight, and a rule that saved with no pass
|
|
530
|
+
// behind it — and nothing left to retry from — is a silent no-op.
|
|
531
|
+
const backApply = canBackApplyDraft(organizeDraft)
|
|
532
|
+
? organizeDraft
|
|
533
|
+
: undefined;
|
|
534
|
+
setBackApplyDraft(backApply);
|
|
535
|
+
void createFilterAsync(
|
|
536
|
+
organizeDraft,
|
|
537
|
+
scope,
|
|
538
|
+
(named.name ?? "").trim(),
|
|
539
|
+
).then((created) => {
|
|
540
|
+
if (created && backApply) startJob(backApply);
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (scope === "all-like-these" && widenedRunsAsJob(verb)) {
|
|
545
|
+
startJob(organizeDraft);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
void runBulk(scope === "just-these" ? messageIds : matchedIds);
|
|
549
|
+
}, [
|
|
550
|
+
mode,
|
|
551
|
+
named,
|
|
552
|
+
anchorMessageId,
|
|
553
|
+
verb,
|
|
554
|
+
messageIds,
|
|
555
|
+
matchedIds,
|
|
556
|
+
createFilterAsync,
|
|
557
|
+
startJob,
|
|
558
|
+
runBulk,
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
// Two presses land in the same frame before either navigate settles, and both
|
|
562
|
+
// would send. The guard is a ref rather than the committed scope because that
|
|
563
|
+
// is state, and state has not been applied yet by the second press.
|
|
564
|
+
const commit = useCallback(() => {
|
|
565
|
+
if (blockedReason) {
|
|
566
|
+
setNudgedStep(current);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (commitSent.current) return;
|
|
570
|
+
commitSent.current = true;
|
|
571
|
+
goToStep("run");
|
|
572
|
+
sendCommit();
|
|
573
|
+
}, [blockedReason, current, goToStep, sendCommit]);
|
|
574
|
+
|
|
575
|
+
const jobSnapshot = useCallback((): RunSnapshot => {
|
|
576
|
+
const progress = organizeJob.progress;
|
|
577
|
+
const shared = {
|
|
578
|
+
matched: progress.matchedCount,
|
|
579
|
+
applied: progress.appliedCount,
|
|
580
|
+
failures: [],
|
|
581
|
+
};
|
|
582
|
+
if (organizeJob.isError) {
|
|
583
|
+
return { ...NOT_STARTED, state: "commitFailed" };
|
|
584
|
+
}
|
|
585
|
+
if (organizeJob.isDone) {
|
|
586
|
+
return {
|
|
587
|
+
...shared,
|
|
588
|
+
state:
|
|
589
|
+
progress.failedCount > 0 ? "backApplyFailed" : "backApplyComplete",
|
|
590
|
+
failed: progress.failedCount,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (organizeJob.isStarting || organizeJob.isRunning) {
|
|
594
|
+
return { ...shared, state: "backApplyRunning", failed: 0 };
|
|
595
|
+
}
|
|
596
|
+
return NOT_STARTED;
|
|
597
|
+
}, [organizeJob]);
|
|
598
|
+
|
|
599
|
+
const rowsById = useMemo(() => {
|
|
600
|
+
const rows = new Map<string, WizardMessage>();
|
|
601
|
+
for (const message of [...selection, ...matchSample.messages]) {
|
|
602
|
+
rows.set(message.id, message);
|
|
603
|
+
}
|
|
604
|
+
return rows;
|
|
605
|
+
}, [selection, matchSample.messages]);
|
|
606
|
+
|
|
607
|
+
const bulkSnapshot = useCallback((): RunSnapshot => {
|
|
608
|
+
if (!bulkRun) return NOT_STARTED;
|
|
609
|
+
const { matched, outcome } = bulkRun;
|
|
610
|
+
if (!outcome) {
|
|
611
|
+
return {
|
|
612
|
+
state: "backApplyRunning",
|
|
613
|
+
matched,
|
|
614
|
+
applied: bulk.progress?.done ?? 0,
|
|
615
|
+
failed: 0,
|
|
616
|
+
failures: [],
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (outcome.done === 0 && (outcome.error || outcome.failedIds.length > 0)) {
|
|
620
|
+
return { ...NOT_STARTED, state: "commitFailed" };
|
|
621
|
+
}
|
|
622
|
+
const failures = outcome.failedIds
|
|
623
|
+
.map((id) => rowsById.get(id))
|
|
624
|
+
.filter((message): message is WizardMessage => message !== undefined);
|
|
625
|
+
return {
|
|
626
|
+
state:
|
|
627
|
+
outcome.failedIds.length > 0 ? "backApplyFailed" : "backApplyComplete",
|
|
628
|
+
matched,
|
|
629
|
+
applied: outcome.done,
|
|
630
|
+
failed: outcome.failedIds.length,
|
|
631
|
+
failures,
|
|
632
|
+
};
|
|
633
|
+
}, [bulkRun, bulk.progress, rowsById]);
|
|
634
|
+
|
|
635
|
+
const runSnapshot = (): RunSnapshot => {
|
|
636
|
+
if (committedScope === "standing" || committedScope === "temporary") {
|
|
637
|
+
if (createFilter.isError)
|
|
638
|
+
return { ...NOT_STARTED, state: "commitFailed" };
|
|
639
|
+
if (!createFilter.isSuccess) return NOT_STARTED;
|
|
640
|
+
if (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
|
|
641
|
+
if (organizeJob.isError) {
|
|
642
|
+
return { ...NOT_STARTED, state: "backApplyStartFailed" };
|
|
643
|
+
}
|
|
644
|
+
return jobSnapshot();
|
|
645
|
+
}
|
|
646
|
+
if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
|
|
647
|
+
return jobSnapshot();
|
|
648
|
+
}
|
|
649
|
+
return bulkSnapshot();
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// Retry stays on the run screen: it re-sends the same commit rather than
|
|
653
|
+
// walking back to Review, which would push an entry the wizard does not own
|
|
654
|
+
// and leave Cancel rewinding to a step instead of out.
|
|
655
|
+
const retry = (): void => {
|
|
656
|
+
if (committedScope === "standing" || committedScope === "temporary") {
|
|
657
|
+
if (createFilter.isError) {
|
|
658
|
+
createFilter.reset();
|
|
659
|
+
sendCommit();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (backApplyDraft) startJob(backApplyDraft);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
|
|
666
|
+
sendCommit();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const outstanding = bulkRun?.outcome?.failedIds ?? [];
|
|
670
|
+
void runBulk(outstanding.length > 0 ? outstanding : messageIds);
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// Cancel rewinds the entries the wizard owns and leaves the selection where
|
|
674
|
+
// it was — nothing has happened to it (#477 1.6).
|
|
675
|
+
const cancel = useCallback(() => {
|
|
676
|
+
closeWizard(steps, current);
|
|
677
|
+
}, [closeWizard, steps, current]);
|
|
678
|
+
|
|
679
|
+
// The run screen's way out. The action has happened, so the rows it was
|
|
680
|
+
// aimed at are no longer a selection waiting to be acted on.
|
|
681
|
+
const dismiss = useCallback(() => {
|
|
682
|
+
closeWizard(steps, current);
|
|
683
|
+
onFinished();
|
|
684
|
+
}, [closeWizard, steps, current, onFinished]);
|
|
685
|
+
|
|
686
|
+
// Hardware back on the run screen leaves the wizard, the movement the header
|
|
687
|
+
// arrow and the footer already make there (`backExits`). Run is an ordinary
|
|
688
|
+
// pushed entry, so without this the button pops back to Review, where the
|
|
689
|
+
// commit is offered a second time; the run screen holds the outcome, which is
|
|
690
|
+
// what makes rewinding the entries under it safe (#477 1.6).
|
|
691
|
+
//
|
|
692
|
+
// The rewind `dismiss` performs is `router.history.go(-N)`, which the history
|
|
693
|
+
// classifies as `"GO"` — so it does not re-enter this blocker and recurse.
|
|
694
|
+
// `selection-wizard-history.spec.ts` walks a real browser back off the run
|
|
695
|
+
// screen, which is what pins that classification.
|
|
696
|
+
useBlocker({
|
|
697
|
+
shouldBlockFn: ({ action }) => {
|
|
698
|
+
if (action !== "BACK") return false;
|
|
699
|
+
dismiss();
|
|
700
|
+
return true;
|
|
701
|
+
},
|
|
702
|
+
enableBeforeUnload: false,
|
|
703
|
+
disabled: current !== "run",
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const run = runSnapshot();
|
|
707
|
+
const sample = {
|
|
708
|
+
messages: widened ? matchSample.messages : selection,
|
|
709
|
+
count,
|
|
710
|
+
label: widened ? "A sample of what matches" : "Your selection",
|
|
711
|
+
emptyReason:
|
|
712
|
+
mode === "similar" && semanticUnavailable
|
|
713
|
+
? ("notIndexed" as const)
|
|
714
|
+
: ("noMatch" as const),
|
|
715
|
+
loading: widened && (count.status === "loading" || matchSample.isPending),
|
|
124
716
|
};
|
|
125
717
|
|
|
126
718
|
return (
|
|
@@ -131,19 +723,23 @@ export function SelectionWizardHost({
|
|
|
131
723
|
blockedReason={blockedReason}
|
|
132
724
|
nudged={nudgedStep === current}
|
|
133
725
|
onBack={goBack}
|
|
134
|
-
onExit={
|
|
726
|
+
onExit={cancel}
|
|
135
727
|
onContinue={advance}
|
|
136
|
-
onCommit={
|
|
728
|
+
onCommit={commit}
|
|
137
729
|
match={{
|
|
138
|
-
selectedCount,
|
|
730
|
+
selectedCount: selection.length,
|
|
139
731
|
mode,
|
|
140
|
-
onModeChange:
|
|
141
|
-
|
|
732
|
+
onModeChange: changeMode,
|
|
733
|
+
semanticUnavailable,
|
|
734
|
+
semanticErrorDetail:
|
|
735
|
+
widen.error instanceof Error ? widen.error.message : undefined,
|
|
736
|
+
semanticFallbackTaken,
|
|
737
|
+
onSemanticFallback: takeSemanticFallback,
|
|
142
738
|
sample,
|
|
143
739
|
}}
|
|
144
740
|
properties={{
|
|
145
|
-
clauses:
|
|
146
|
-
matchOperator:
|
|
741
|
+
clauses: named.clauses,
|
|
742
|
+
matchOperator: named.matchOperator,
|
|
147
743
|
onMatchOperatorChange: (matchOperator) =>
|
|
148
744
|
setDraft((held) => ({ ...held, matchOperator })),
|
|
149
745
|
clauseEdit,
|
|
@@ -153,39 +749,100 @@ export function SelectionWizardHost({
|
|
|
153
749
|
onChangeDraft: changeClauseDraft,
|
|
154
750
|
onSubmitClause: submitClause,
|
|
155
751
|
onCancelClause: () => setClauseEdit(undefined),
|
|
156
|
-
|
|
752
|
+
clauseFields: SUPPORTED_CLAUSE_FIELDS,
|
|
753
|
+
clauseSuggestions,
|
|
754
|
+
conversionNotice: conversion
|
|
755
|
+
? searchConversionNotice(conversion)
|
|
756
|
+
: undefined,
|
|
757
|
+
semanticFallbackTaken,
|
|
758
|
+
sample: { ...sample, label: "What this matches" },
|
|
759
|
+
}}
|
|
760
|
+
folder={{
|
|
761
|
+
mailboxes,
|
|
762
|
+
mailboxId: named.moveMailboxId,
|
|
763
|
+
onSelect: (moveMailboxId) =>
|
|
764
|
+
setDraft((held) => ({ ...held, moveMailboxId })),
|
|
765
|
+
onCreateFolder: accountId ? createFolder : undefined,
|
|
766
|
+
restriction: folderRestriction,
|
|
157
767
|
}}
|
|
158
768
|
rule={{
|
|
159
|
-
draft,
|
|
769
|
+
draft: named,
|
|
160
770
|
onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
|
|
161
771
|
onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
|
|
772
|
+
restriction: ruleRestriction,
|
|
162
773
|
}}
|
|
163
774
|
name={{
|
|
164
|
-
name:
|
|
775
|
+
name: named.name ?? "",
|
|
165
776
|
onNameChange: (name) => setDraft((held) => ({ ...held, name })),
|
|
166
777
|
nudged: nudgedStep === current,
|
|
167
778
|
}}
|
|
168
779
|
review={{
|
|
169
780
|
verb,
|
|
170
781
|
mode,
|
|
171
|
-
selectedCount,
|
|
172
|
-
clauses:
|
|
173
|
-
matchOperator:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
782
|
+
selectedCount: selection.length,
|
|
783
|
+
clauses: named.clauses,
|
|
784
|
+
matchOperator: named.matchOperator,
|
|
785
|
+
folder: folderLabel,
|
|
786
|
+
scope: named.scope,
|
|
787
|
+
until: named.until,
|
|
788
|
+
ruleName: steps.includes("name") ? named.name : undefined,
|
|
177
789
|
sample,
|
|
178
790
|
}}
|
|
179
791
|
run={{
|
|
180
|
-
state:
|
|
792
|
+
state: run.state,
|
|
181
793
|
verb,
|
|
182
|
-
scope:
|
|
183
|
-
matched:
|
|
184
|
-
applied:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
794
|
+
scope: named.scope,
|
|
795
|
+
matched: run.matched,
|
|
796
|
+
applied: run.applied,
|
|
797
|
+
failedCount: run.failed,
|
|
798
|
+
failures: run.failures,
|
|
799
|
+
onRetry: retry,
|
|
800
|
+
onDismiss: dismiss,
|
|
188
801
|
}}
|
|
189
802
|
/>
|
|
190
803
|
);
|
|
191
804
|
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* The wizard's mount point, and the only one: both entries walk this host, so
|
|
808
|
+
* there is one owner of the step in the URL and of the history entries the
|
|
809
|
+
* wizard pushes. The walk itself is a separate component, mounted only while a
|
|
810
|
+
* step is held, so every answer it collects is gone by the time the next one
|
|
811
|
+
* starts.
|
|
812
|
+
*
|
|
813
|
+
* The URL says which affordance opened the wizard, and that decides two things
|
|
814
|
+
* and no more: which step it opens on, and whether the query seeds the clauses.
|
|
815
|
+
* A search entry ticks nothing and its rule belongs to the account the query
|
|
816
|
+
* names, so the selection the bar would have handed over is not the one it
|
|
817
|
+
* walks.
|
|
818
|
+
*/
|
|
819
|
+
export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
820
|
+
const fromSearch = useWizardEntryValue() === "search";
|
|
821
|
+
const { searchConversion } = useListHeaderChrome();
|
|
822
|
+
const { accounts } = useMailContext();
|
|
823
|
+
const { step, goToStep, goBack, closeWizard } = useWizardStep(
|
|
824
|
+
fromSearch ? "properties" : "match",
|
|
825
|
+
);
|
|
826
|
+
if (!step) return null;
|
|
827
|
+
const conversion = fromSearch
|
|
828
|
+
? (searchConversion ?? NO_CONVERSION)
|
|
829
|
+
: undefined;
|
|
830
|
+
return (
|
|
831
|
+
<SelectionWizardSession
|
|
832
|
+
{...props}
|
|
833
|
+
{...(conversion
|
|
834
|
+
? {
|
|
835
|
+
verb: "organize" as const,
|
|
836
|
+
accountId: conversion.targetAccountId ?? accounts[0]?.accountId,
|
|
837
|
+
selection: EMPTY_SELECTION,
|
|
838
|
+
crossAccount: false,
|
|
839
|
+
searchConversion: conversion,
|
|
840
|
+
}
|
|
841
|
+
: {})}
|
|
842
|
+
step={step}
|
|
843
|
+
goToStep={goToStep}
|
|
844
|
+
goBack={goBack}
|
|
845
|
+
closeWizard={closeWizard}
|
|
846
|
+
/>
|
|
847
|
+
);
|
|
848
|
+
}
|