@remit/web-client 0.0.93 → 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.tsx +21 -13
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/SelectionWizardHost.tsx +79 -10
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/hooks/useRulePreview.ts +1 -2
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +55 -7
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.94",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -415,20 +415,28 @@ function BriefSelectionChrome({
|
|
|
415
415
|
/>
|
|
416
416
|
);
|
|
417
417
|
|
|
418
|
+
// Mounted as the pane's overlay rather than beside the header: the brief hands
|
|
419
|
+
// its body over to the search results panel while a query is being typed, and
|
|
420
|
+
// the wizard is not part of the body it hands over. The overlay slot also puts
|
|
421
|
+
// it inside the list header's chrome, which is where the converted query it
|
|
422
|
+
// opens on comes from.
|
|
418
423
|
return (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
424
|
+
<MailListHeader
|
|
425
|
+
{...header}
|
|
426
|
+
selectionBar={selectionBar}
|
|
427
|
+
paneOverlay={
|
|
428
|
+
<SelectionWizardHost
|
|
429
|
+
verb={wizardVerb}
|
|
430
|
+
accountId={scope.accountId}
|
|
431
|
+
mailboxId={scope.mailboxId}
|
|
432
|
+
selection={wizardSelection}
|
|
433
|
+
crossAccount={scope.moveDisabledHint !== undefined}
|
|
434
|
+
onFinished={exitSelection}
|
|
435
|
+
/>
|
|
436
|
+
}
|
|
437
|
+
>
|
|
438
|
+
{children}
|
|
439
|
+
</MailListHeader>
|
|
432
440
|
);
|
|
433
441
|
}
|
|
434
442
|
|
|
@@ -38,10 +38,15 @@ import { dedupeByThread } from "@/lib/starred-rows";
|
|
|
38
38
|
import { MailViewChrome } from "./MailViewChrome";
|
|
39
39
|
import type { MessageListCommands } from "./MessageList";
|
|
40
40
|
import { MessageRow } from "./MessageRow";
|
|
41
|
+
import {
|
|
42
|
+
SelectionWizardHost,
|
|
43
|
+
type WizardSelectionMessage,
|
|
44
|
+
} from "./SelectionWizardHost";
|
|
41
45
|
import {
|
|
42
46
|
type OpenMessageOptions,
|
|
43
47
|
ThreadListInteraction,
|
|
44
48
|
ThreadListSelectionBar,
|
|
49
|
+
useThreadListSelection,
|
|
45
50
|
} from "./ThreadListInteraction";
|
|
46
51
|
|
|
47
52
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
@@ -49,6 +54,42 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
49
54
|
attachment: (t) => t.hasAttachment === true,
|
|
50
55
|
};
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The wizard, on the one surface whose bar borrows it rather than raising it.
|
|
59
|
+
*
|
|
60
|
+
* The list header offers "make this a filter" wherever a search is active, and
|
|
61
|
+
* the step that affordance pushes has to be answered on the view that offered
|
|
62
|
+
* it — an affordance whose press lands on nothing is the dead button clause 1.7
|
|
63
|
+
* exists to prevent. Starred spans accounts and mailboxes, so a rule made from
|
|
64
|
+
* the ticked rows has no single account to belong to; a rule made from the
|
|
65
|
+
* query belongs to the account the query names, which the host reads for
|
|
66
|
+
* itself.
|
|
67
|
+
*/
|
|
68
|
+
function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
|
|
69
|
+
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
70
|
+
const selection = useMemo<WizardSelectionMessage[]>(
|
|
71
|
+
() =>
|
|
72
|
+
rows
|
|
73
|
+
.filter((row) => selectedIds.has(row.id))
|
|
74
|
+
.map((row) => ({
|
|
75
|
+
id: row.id,
|
|
76
|
+
sender: row.fromName,
|
|
77
|
+
email: row.fromEmail,
|
|
78
|
+
subject: row.subject,
|
|
79
|
+
date: row.timeLabel,
|
|
80
|
+
})),
|
|
81
|
+
[rows, selectedIds],
|
|
82
|
+
);
|
|
83
|
+
return (
|
|
84
|
+
<SelectionWizardHost
|
|
85
|
+
verb="organize"
|
|
86
|
+
selection={selection}
|
|
87
|
+
crossAccount
|
|
88
|
+
onFinished={exitSelection}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
interface FlaggedListProps {
|
|
53
94
|
selectedMessageId?: string;
|
|
54
95
|
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
@@ -221,6 +262,7 @@ export function FlaggedList({
|
|
|
221
262
|
(listState === "ready" ? listBody : undefined)
|
|
222
263
|
}
|
|
223
264
|
/>
|
|
265
|
+
<StarredWizardHost rows={rows} />
|
|
224
266
|
</ThreadListInteraction>
|
|
225
267
|
</MailViewChrome>
|
|
226
268
|
);
|
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
searchTokenLabel,
|
|
87
87
|
} from "@/lib/search-tokens";
|
|
88
88
|
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
89
|
-
import {
|
|
89
|
+
import { useOpenWizard } from "@/lib/wizard-history";
|
|
90
90
|
|
|
91
91
|
export interface MailListHeaderProps {
|
|
92
92
|
title: string;
|
|
@@ -102,8 +102,11 @@ export interface MailListHeaderProps {
|
|
|
102
102
|
*/
|
|
103
103
|
selectionBar?: (chrome: ListHeaderChrome) => ReactNode;
|
|
104
104
|
/**
|
|
105
|
-
* An overlay covering the pane, above the list (the
|
|
106
|
-
*
|
|
105
|
+
* An overlay covering the pane, above the list (the selection wizard). Kept
|
|
106
|
+
* out of the body and rendered on both the ordinary layout and the phone
|
|
107
|
+
* search takeover, so a surface that covers the screen is not taken down by
|
|
108
|
+
* whatever the body swapped to underneath it. Inside the chrome provider, so
|
|
109
|
+
* an overlay a view mounts from above still reads the search state.
|
|
107
110
|
*/
|
|
108
111
|
paneOverlay?: ReactNode;
|
|
109
112
|
/** Pinned below the scrollable list (e.g. the keyboard hint bar). */
|
|
@@ -169,7 +172,7 @@ export function MailListHeader({
|
|
|
169
172
|
const tier = useLayoutTier();
|
|
170
173
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
171
174
|
const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
|
|
172
|
-
const
|
|
175
|
+
const openWizard = useOpenWizard();
|
|
173
176
|
|
|
174
177
|
// Leaving the view ends the search: the shell drops the query, and the chrome
|
|
175
178
|
// it opened — the phone takeover, the expanded tablet field — closes with it
|
|
@@ -320,14 +323,15 @@ export function MailListHeader({
|
|
|
320
323
|
}
|
|
321
324
|
: routeScope;
|
|
322
325
|
|
|
323
|
-
// Make-this-a-filter (
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
326
|
+
// Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
|
|
327
|
+
// entry. It opens on the properties step with the clauses `convertSearchToRule`
|
|
328
|
+
// derives from the query, nothing ticked, and the notice for what the query
|
|
329
|
+
// could not be turned into. The conversion is computed once, here, and handed
|
|
330
|
+
// to the wizard through the chrome — the reason the affordance gives and the
|
|
331
|
+
// clauses the wizard seeds from are the same answer. The literal filter cannot
|
|
332
|
+
// reproduce the search's semantic reach, so the conversion states it whenever
|
|
333
|
+
// the search surfaced a "Related" section — a direct signal, read here from
|
|
334
|
+
// the semantic results, never a capability probe.
|
|
331
335
|
//
|
|
332
336
|
// It belongs to the search, not to any one way of showing it. The affordance
|
|
333
337
|
// therefore sits in the pane, above whichever body is up — the read-only
|
|
@@ -338,35 +342,32 @@ export function MailListHeader({
|
|
|
338
342
|
// the URL, taking the affordance down with it a few hundred milliseconds after
|
|
339
343
|
// it appeared, and leaving the brief (which keeps the panel for any query) as
|
|
340
344
|
// the only place it survived.
|
|
341
|
-
const accountToken = parsed.tokens.find((token) => token.type === "account");
|
|
342
|
-
const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
|
|
343
345
|
const searchHadSemanticReach = related.length > 0;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
346
|
+
const conversion = useMemo(
|
|
347
|
+
() => convertSearchToRule(parsed, { searchHadSemanticReach }),
|
|
348
|
+
[parsed, searchHadSemanticReach],
|
|
349
|
+
);
|
|
350
|
+
const searchConversion = hasQuery ? conversion : undefined;
|
|
351
|
+
const makeFilter = hasQuery
|
|
352
|
+
? {
|
|
353
|
+
// The wizard is a full-screen surface, so the phone takeover it was
|
|
354
|
+
// pressed from stands down rather than sitting under it — and the list
|
|
355
|
+
// underneath, which hosts the wizard, is mounted again by the time the
|
|
356
|
+
// step lands.
|
|
357
|
+
onClick: () => {
|
|
358
|
+
setSearchOpen(false);
|
|
359
|
+
openWizard("properties", "search");
|
|
360
|
+
},
|
|
361
|
+
blockedReason: isConvertible(conversion)
|
|
362
|
+
? undefined
|
|
363
|
+
: "Add a sender or words to filter on",
|
|
364
|
+
}
|
|
365
|
+
: undefined;
|
|
355
366
|
// Handed to the bar rather than rendered here: the bar knows whether rows
|
|
356
367
|
// are ticked, and a selection's own verbs own the surface while they are up.
|
|
357
368
|
const makeFilterAction = makeFilter ? (
|
|
358
369
|
<MakeFilterAction {...makeFilter} />
|
|
359
370
|
) : null;
|
|
360
|
-
const filterDialog =
|
|
361
|
-
filterOpen && targetAccountId ? (
|
|
362
|
-
<SearchFilterDialog
|
|
363
|
-
open={filterOpen}
|
|
364
|
-
accountId={targetAccountId}
|
|
365
|
-
parsed={parsed}
|
|
366
|
-
searchHadSemanticReach={searchHadSemanticReach}
|
|
367
|
-
onClose={() => setFilterOpen(false)}
|
|
368
|
-
/>
|
|
369
|
-
) : null;
|
|
370
371
|
|
|
371
372
|
// Tablet + desktop keep the inline toolbar search; while a query is being
|
|
372
373
|
// typed the list-pane body swaps to the same sectioned results the phone
|
|
@@ -421,6 +422,7 @@ export function MailListHeader({
|
|
|
421
422
|
title,
|
|
422
423
|
searchResults: chromeResults,
|
|
423
424
|
makeFilterSlot: makeFilterAction,
|
|
425
|
+
searchConversion,
|
|
424
426
|
navSlot: layout && !layout.showNavPane && (
|
|
425
427
|
<Button
|
|
426
428
|
variant="ghost"
|
|
@@ -484,6 +486,7 @@ export function MailListHeader({
|
|
|
484
486
|
searchSuggest,
|
|
485
487
|
chromeResults,
|
|
486
488
|
makeFilterAction,
|
|
489
|
+
searchConversion,
|
|
487
490
|
],
|
|
488
491
|
);
|
|
489
492
|
|
|
@@ -494,7 +497,7 @@ export function MailListHeader({
|
|
|
494
497
|
onSelectSearchResult?.(result);
|
|
495
498
|
};
|
|
496
499
|
return (
|
|
497
|
-
|
|
500
|
+
<ListHeaderChromeContext.Provider value={chrome}>
|
|
498
501
|
<MobileSearchView
|
|
499
502
|
value={searchInput}
|
|
500
503
|
onChange={onSearchChange}
|
|
@@ -515,8 +518,8 @@ export function MailListHeader({
|
|
|
515
518
|
suggest={searchSuggest}
|
|
516
519
|
suggestList={suggestList}
|
|
517
520
|
/>
|
|
518
|
-
{
|
|
519
|
-
|
|
521
|
+
{paneOverlay}
|
|
522
|
+
</ListHeaderChromeContext.Provider>
|
|
520
523
|
);
|
|
521
524
|
}
|
|
522
525
|
|
|
@@ -527,7 +530,6 @@ export function MailListHeader({
|
|
|
527
530
|
{suggestList}
|
|
528
531
|
<div className="min-h-0 flex-1">{body}</div>
|
|
529
532
|
{footer}
|
|
530
|
-
{filterDialog}
|
|
531
533
|
{paneOverlay}
|
|
532
534
|
</section>
|
|
533
535
|
</ListHeaderChromeContext.Provider>
|
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
type MoveMailboxOption,
|
|
13
13
|
type RuleClause,
|
|
14
14
|
type RunState,
|
|
15
|
+
type SearchConversion,
|
|
15
16
|
SelectionWizard,
|
|
16
17
|
type StepId,
|
|
18
|
+
searchConversionNotice,
|
|
17
19
|
senderLabel,
|
|
18
20
|
stepBlockedReason,
|
|
19
21
|
stepIndex,
|
|
@@ -44,6 +46,8 @@ import { useRulePreview } from "@/hooks/useRulePreview";
|
|
|
44
46
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
45
47
|
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
46
48
|
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
49
|
+
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
50
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
47
51
|
import { buildMoveTargets } from "@/lib/move-targets";
|
|
48
52
|
import {
|
|
49
53
|
buildWizardDraft,
|
|
@@ -57,9 +61,8 @@ import {
|
|
|
57
61
|
SUPPORTED_CLAUSE_FIELDS,
|
|
58
62
|
} from "@/lib/organize/rule-model";
|
|
59
63
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
60
|
-
import { useWizardStep } from "@/lib/wizard-history";
|
|
64
|
+
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
61
65
|
|
|
62
|
-
const OPENING_STEP: StepId = "match";
|
|
63
66
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
64
67
|
|
|
65
68
|
/** A ticked row, as the wizard's samples and its clause prefill read it. */
|
|
@@ -74,6 +77,14 @@ interface SelectionWizardSessionProps extends SelectionWizardHostProps {
|
|
|
74
77
|
goToStep: (step: StepId) => void;
|
|
75
78
|
goBack: () => void;
|
|
76
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;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
export interface SelectionWizardHostProps {
|
|
@@ -90,6 +101,22 @@ export interface SelectionWizardHostProps {
|
|
|
90
101
|
onFinished: () => void;
|
|
91
102
|
}
|
|
92
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
|
+
};
|
|
116
|
+
|
|
117
|
+
/** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
|
|
118
|
+
const EMPTY_SELECTION: readonly WizardSelectionMessage[] = [];
|
|
119
|
+
|
|
93
120
|
/** How far a commit has got, whichever of the three ways it took. */
|
|
94
121
|
interface RunSnapshot {
|
|
95
122
|
state: RunState;
|
|
@@ -163,14 +190,29 @@ function SelectionWizardSession({
|
|
|
163
190
|
mailboxId,
|
|
164
191
|
selection,
|
|
165
192
|
crossAccount = false,
|
|
193
|
+
searchConversion,
|
|
166
194
|
onFinished,
|
|
167
195
|
step,
|
|
168
196
|
goToStep,
|
|
169
197
|
goBack,
|
|
170
198
|
closeWizard,
|
|
171
199
|
}: SelectionWizardSessionProps) {
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
);
|
|
174
216
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
175
217
|
undefined,
|
|
176
218
|
);
|
|
@@ -299,7 +341,7 @@ function SelectionWizardSession({
|
|
|
299
341
|
});
|
|
300
342
|
const { runAction } = bulk;
|
|
301
343
|
|
|
302
|
-
const steps = stepsFor({ verb, mode, scope: draft.scope });
|
|
344
|
+
const steps = stepsFor({ verb, mode, scope: draft.scope, fromSearch });
|
|
303
345
|
// The step the screens are on, which is the held one only while the answers
|
|
304
346
|
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
305
347
|
// is how a footer comes to name a screen nobody is looking at.
|
|
@@ -709,6 +751,9 @@ function SelectionWizardSession({
|
|
|
709
751
|
onCancelClause: () => setClauseEdit(undefined),
|
|
710
752
|
clauseFields: SUPPORTED_CLAUSE_FIELDS,
|
|
711
753
|
clauseSuggestions,
|
|
754
|
+
conversionNotice: conversion
|
|
755
|
+
? searchConversionNotice(conversion)
|
|
756
|
+
: undefined,
|
|
712
757
|
semanticFallbackTaken,
|
|
713
758
|
sample: { ...sample, label: "What this matches" },
|
|
714
759
|
}}
|
|
@@ -759,17 +804,41 @@ function SelectionWizardSession({
|
|
|
759
804
|
}
|
|
760
805
|
|
|
761
806
|
/**
|
|
762
|
-
* The wizard's mount point,
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
* collects is gone by the time the next one
|
|
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.
|
|
766
818
|
*/
|
|
767
819
|
export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
768
|
-
const
|
|
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
|
+
);
|
|
769
826
|
if (!step) return null;
|
|
827
|
+
const conversion = fromSearch
|
|
828
|
+
? (searchConversion ?? NO_CONVERSION)
|
|
829
|
+
: undefined;
|
|
770
830
|
return (
|
|
771
831
|
<SelectionWizardSession
|
|
772
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
|
+
: {})}
|
|
773
842
|
step={step}
|
|
774
843
|
goToStep={goToStep}
|
|
775
844
|
goBack={goBack}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* "Make this a filter" is the wizard's second entry (#477 clause 1.8), and the
|
|
9
|
+
* step it pushes has to be answered on the view that offered it.
|
|
10
|
+
*
|
|
11
|
+
* The affordance is built once by `MailListHeader` and handed down as
|
|
12
|
+
* `makeFilterSlot`, so any surface can put it on screen — but the wizard is
|
|
13
|
+
* mounted per surface, and a surface that renders the row without one leaves
|
|
14
|
+
* the press on a URL nothing answers. That is the dead button clause 1.7 exists
|
|
15
|
+
* to prevent, and it is invisible in review: the row and the host are two files
|
|
16
|
+
* apart, and the affordance keeps working everywhere else.
|
|
17
|
+
*
|
|
18
|
+
* So the pairing is the assertion. A surface that renders the row names where
|
|
19
|
+
* its step is answered, and that file is checked to mount a host. A fourth
|
|
20
|
+
* surface fails here until it does the same.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** How the chrome's slot is read — `MailListHeader` writes it, everyone else reads it. */
|
|
26
|
+
const RENDERS_SLOT = /\.makeFilterSlot/;
|
|
27
|
+
const MOUNTS_HOST = /<SelectionWizardHost/;
|
|
28
|
+
|
|
29
|
+
/** Surface that renders the affordance → the file whose composition answers the step. */
|
|
30
|
+
const ANSWERED_BY: Record<string, string> = {
|
|
31
|
+
"DailyBrief.tsx": "DailyBrief.tsx",
|
|
32
|
+
"MessageList.tsx": "MessageList.tsx",
|
|
33
|
+
// The starred list's bar is shared with the brief's, so its host is mounted
|
|
34
|
+
// by the view rather than beside the bar.
|
|
35
|
+
"ThreadListInteraction.tsx": "FlaggedList.tsx",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const read = (file: string): string =>
|
|
39
|
+
readFileSync(resolve(here, file), "utf8");
|
|
40
|
+
|
|
41
|
+
const surfacesRenderingTheAffordance = (): string[] =>
|
|
42
|
+
readdirSync(here)
|
|
43
|
+
.filter((file) => file.endsWith(".tsx"))
|
|
44
|
+
.filter((file) => RENDERS_SLOT.test(read(file)));
|
|
45
|
+
|
|
46
|
+
describe("the make-filter entry", () => {
|
|
47
|
+
it("is rendered only by surfaces that say where its step is answered", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
surfacesRenderingTheAffordance().sort(),
|
|
50
|
+
Object.keys(ANSWERED_BY).sort(),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is answered by a mounted wizard on every one of them", () => {
|
|
55
|
+
for (const [surface, host] of Object.entries(ANSWERED_BY)) {
|
|
56
|
+
assert.match(
|
|
57
|
+
read(host),
|
|
58
|
+
MOUNTS_HOST,
|
|
59
|
+
`${surface} is answered by ${host}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type PreviewCount, UNCOUNTABLE_PREDICATE_REASON } from "@remit/ui";
|
|
3
3
|
import { useMutation } from "@tanstack/react-query";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
PREVIEW_DEBOUNCE_MS,
|
|
10
10
|
type PreviewState,
|
|
11
11
|
predicateSignature,
|
|
12
|
-
UNCOUNTABLE_PREDICATE_REASON,
|
|
13
12
|
} from "@/lib/organize/rule-model";
|
|
14
13
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
15
14
|
|
|
@@ -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 = {
|
|
@@ -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()) {
|