@remit/web-client 0.0.106 → 0.0.108
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 +69 -11
- package/src/components/mail/FlaggedList.tsx +5 -0
- package/src/components/mail/MailListHeader.tsx +18 -17
- package/src/components/mail/MailViewChrome.tsx +2 -1
- package/src/components/mail/SelectionWizardHost.job-status.test.ts +50 -0
- package/src/components/mail/SelectionWizardHost.tsx +38 -27
- package/src/components/mail/organize-run-state.test.ts +97 -0
- package/src/components/mail/organize-run-state.ts +39 -0
- package/src/hooks/useOrganizeJob.render.test.ts +142 -0
- package/src/hooks/useOrganizeJob.ts +28 -2
- package/src/lib/search-surface.ts +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.108",
|
|
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": {
|
|
@@ -42,8 +42,10 @@ import {
|
|
|
42
42
|
type FilterSheetProps,
|
|
43
43
|
type FilterSheetSource,
|
|
44
44
|
KeyboardHintBar,
|
|
45
|
+
partitionSpamResults,
|
|
45
46
|
type SearchResult,
|
|
46
47
|
SelectionTopBar,
|
|
48
|
+
SpamResultsOffer,
|
|
47
49
|
type ThreadRowData,
|
|
48
50
|
type ThreadSection,
|
|
49
51
|
} from "@remit/ui";
|
|
@@ -81,6 +83,7 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
81
83
|
import { useMailContext } from "@/lib/mail-context";
|
|
82
84
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
83
85
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
86
|
+
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
84
87
|
import {
|
|
85
88
|
type SelectionWizardControl,
|
|
86
89
|
useSelectionWizard,
|
|
@@ -463,6 +466,7 @@ export function DailyBrief({
|
|
|
463
466
|
const tokenContext = useSearchTokenContext();
|
|
464
467
|
const isDesktop = useIsDesktop();
|
|
465
468
|
const wizard = useSelectionWizard();
|
|
469
|
+
const navigate = useNavigate();
|
|
466
470
|
|
|
467
471
|
const nonMuted = useMemo(
|
|
468
472
|
() => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
|
|
@@ -592,9 +596,33 @@ export function DailyBrief({
|
|
|
592
596
|
);
|
|
593
597
|
}, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
|
|
594
598
|
|
|
599
|
+
// The unscoped search reaches every folder, Spam included (see `filteredRows`
|
|
600
|
+
// above), and the brief is the one global-scope view whose own rows now stand
|
|
601
|
+
// in for the read-only results panel once the query commits. The panel held
|
|
602
|
+
// Spam out and offered a way to it instead (`MailListHeader`'s `spamOffer`);
|
|
603
|
+
// the brief's own body has to do the same, or a committed search surfaces
|
|
604
|
+
// junk mail inline and drops the way back to it. A bare token query (e.g.
|
|
605
|
+
// `is:unread`) never reaches the widened endpoint, so there is nothing to
|
|
606
|
+
// hold out.
|
|
607
|
+
const { bodyRows, briefSpamOffer } = useMemo(() => {
|
|
608
|
+
if (!sq) return { bodyRows: filteredRows, briefSpamOffer: undefined };
|
|
609
|
+
const asResults = filteredRows.map((row) =>
|
|
610
|
+
rowToSearchResult(row, resultFolderIndex),
|
|
611
|
+
);
|
|
612
|
+
const { spam } = partitionSpamResults(asResults);
|
|
613
|
+
if (spam.length === 0) {
|
|
614
|
+
return { bodyRows: filteredRows, briefSpamOffer: undefined };
|
|
615
|
+
}
|
|
616
|
+
const spamIds = new Set(spam.map((result) => result.id));
|
|
617
|
+
return {
|
|
618
|
+
bodyRows: filteredRows.filter((row) => !spamIds.has(row.id)),
|
|
619
|
+
briefSpamOffer: spamOfferForResults(asResults),
|
|
620
|
+
};
|
|
621
|
+
}, [filteredRows, sq, resultFolderIndex]);
|
|
622
|
+
|
|
595
623
|
const sections = useMemo<ThreadSection[]>(
|
|
596
|
-
() => groupBriefSections(
|
|
597
|
-
[
|
|
624
|
+
() => groupBriefSections(bodyRows),
|
|
625
|
+
[bodyRows],
|
|
598
626
|
);
|
|
599
627
|
|
|
600
628
|
const accountSources = useMemo<FilterSheetSource[]>(() => {
|
|
@@ -693,10 +721,14 @@ export function DailyBrief({
|
|
|
693
721
|
]);
|
|
694
722
|
|
|
695
723
|
// The brief is genuinely empty (caught up) only when nothing is narrowing the
|
|
696
|
-
// view: no account source and no search. When a
|
|
697
|
-
// the list says so instead, so the narrowing
|
|
724
|
+
// view: no account source and no search — free text or token. When a
|
|
725
|
+
// source/search yields nothing, the list says so instead, so the narrowing
|
|
726
|
+
// is still visible as the reason.
|
|
698
727
|
const caughtUp =
|
|
699
|
-
sections.length === 0 &&
|
|
728
|
+
sections.length === 0 &&
|
|
729
|
+
selectedAccountId === "all" &&
|
|
730
|
+
sq.length === 0 &&
|
|
731
|
+
queryTokens.length === 0;
|
|
700
732
|
|
|
701
733
|
// "Caught up" is a claim about the user's mail, so it may only be made once
|
|
702
734
|
// the server has confirmed no sync is running (#452). The config snapshot
|
|
@@ -752,12 +784,32 @@ export function DailyBrief({
|
|
|
752
784
|
briefSkeleton
|
|
753
785
|
)
|
|
754
786
|
) : (
|
|
755
|
-
<
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
787
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
788
|
+
{briefSpamOffer && (
|
|
789
|
+
<SpamResultsOffer
|
|
790
|
+
count={briefSpamOffer.count}
|
|
791
|
+
onScopeToSpam={() =>
|
|
792
|
+
navigate({
|
|
793
|
+
to: "/mail/$mailboxId",
|
|
794
|
+
params: { mailboxId: briefSpamOffer.mailboxId },
|
|
795
|
+
search: {
|
|
796
|
+
q: searchQuery || undefined,
|
|
797
|
+
selectedMessageId: undefined,
|
|
798
|
+
selectedThreadId: undefined,
|
|
799
|
+
},
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
/>
|
|
803
|
+
)}
|
|
804
|
+
<div className="min-h-0 flex-1">
|
|
805
|
+
<BriefListBody
|
|
806
|
+
sections={sections}
|
|
807
|
+
briefCategory={selectedCategory}
|
|
808
|
+
selectedThreadId={selectedMessageId}
|
|
809
|
+
onSelectThread={onSelectMessage}
|
|
810
|
+
/>
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
761
813
|
);
|
|
762
814
|
|
|
763
815
|
// The cursor and selection wrap the whole pane, not just the list body: the
|
|
@@ -785,6 +837,12 @@ export function DailyBrief({
|
|
|
785
837
|
relatedResults,
|
|
786
838
|
relatedLoading,
|
|
787
839
|
onSelectSearchResult,
|
|
840
|
+
// The body already narrows to the committed query
|
|
841
|
+
// (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
|
|
842
|
+
// above), so a committed search is a selectable list here exactly as
|
|
843
|
+
// it is on the mailbox route (#212) — the two-engine panel stays for
|
|
844
|
+
// the typing/uncommitted state only.
|
|
845
|
+
searchResultsInBody: true,
|
|
788
846
|
}}
|
|
789
847
|
rows={filteredRows}
|
|
790
848
|
>
|
|
@@ -237,6 +237,11 @@ export function FlaggedList({
|
|
|
237
237
|
searchResults={searchResults}
|
|
238
238
|
searchLoading={isLoading}
|
|
239
239
|
onSelectSearchResult={(result) => onSelectMessage?.(result.id)}
|
|
240
|
+
// A committed search renders in this view's own rows (`rows` already
|
|
241
|
+
// narrows to the query above), so the multi-select toolbar stays
|
|
242
|
+
// reachable exactly as it does on the mailbox route (#212). The
|
|
243
|
+
// two-engine panel stays for the typing/uncommitted state only.
|
|
244
|
+
searchResultsInBody
|
|
240
245
|
>
|
|
241
246
|
<ThreadListInteraction
|
|
242
247
|
selectedMessageId={selectedMessageId}
|
|
@@ -129,13 +129,13 @@ export interface MailListHeaderProps {
|
|
|
129
129
|
relatedResultsLabel?: string;
|
|
130
130
|
/**
|
|
131
131
|
* The body renders the committed search itself as a selectable list — the
|
|
132
|
-
* mailbox route's `MessageList`
|
|
133
|
-
* multi-select toolbar and the
|
|
134
|
-
*
|
|
135
|
-
* swapping in the read-only
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* and
|
|
132
|
+
* mailbox route's `MessageList` and the brief and Starred's own rows filter
|
|
133
|
+
* to the search and host the multi-select toolbar and (on the mailbox route)
|
|
134
|
+
* the "Select all N matching" escalation (#212). When set, a committed query
|
|
135
|
+
* keeps the body on tablet/desktop instead of swapping in the read-only
|
|
136
|
+
* two-engine `SearchResults` panel; the panel still shows while the query is
|
|
137
|
+
* being typed (uncommitted). A view whose body is not a search-result list
|
|
138
|
+
* leaves this unset and keeps the panel for every query.
|
|
139
139
|
*/
|
|
140
140
|
searchResultsInBody?: boolean;
|
|
141
141
|
}
|
|
@@ -336,12 +336,12 @@ export function MailListHeader({
|
|
|
336
336
|
// It belongs to the search, not to any one way of showing it. The affordance
|
|
337
337
|
// therefore sits in the pane, above whichever body is up — the read-only
|
|
338
338
|
// results panel, or a list whose own rows narrow to the committed query — and
|
|
339
|
-
// is mounted on the same condition as the query itself. Rendering it inside
|
|
340
|
-
// results panel made it a mailbox's flash: the panel shows while the query
|
|
341
|
-
// being typed and hands back to `MessageList` the moment the query commits
|
|
342
|
-
// the URL, taking the affordance down with it a few hundred milliseconds
|
|
343
|
-
// it appeared
|
|
344
|
-
//
|
|
339
|
+
// is mounted on the same condition as the query itself. Rendering it inside
|
|
340
|
+
// the results panel made it a mailbox's flash: the panel shows while the query
|
|
341
|
+
// is being typed and hands back to `MessageList` the moment the query commits
|
|
342
|
+
// to the URL, taking the affordance down with it a few hundred milliseconds
|
|
343
|
+
// after it appeared. Mounting it here instead keeps it up through that
|
|
344
|
+
// hand-back on every view.
|
|
345
345
|
const searchHadSemanticReach = related.length > 0;
|
|
346
346
|
const conversion = useMemo(
|
|
347
347
|
() => convertSearchToRule(parsed, { searchHadSemanticReach }),
|
|
@@ -372,10 +372,11 @@ export function MailListHeader({
|
|
|
372
372
|
// Tablet + desktop keep the inline toolbar search; while a query is being
|
|
373
373
|
// typed the list-pane body swaps to the same sectioned results the phone
|
|
374
374
|
// takeover shows, under the same FilterSheet. A view whose own body renders
|
|
375
|
-
// the committed search as a selectable list (`searchResultsInBody
|
|
376
|
-
// mailbox route) keeps the panel only until the query
|
|
377
|
-
// then hands back to its
|
|
378
|
-
//
|
|
375
|
+
// the committed search as a selectable list (`searchResultsInBody` — the
|
|
376
|
+
// mailbox route, the brief, Starred) keeps the panel only until the query
|
|
377
|
+
// commits to the URL, then hands back to its own rows so multi-select (and,
|
|
378
|
+
// on the mailbox route, the "Select all N matching" escalation) is reachable
|
|
379
|
+
// (#212). Clearing the query restores the normal list.
|
|
379
380
|
const showInlineResults = showInlineSearchResults({
|
|
380
381
|
tier,
|
|
381
382
|
hasLiveInput: hasQuery,
|
|
@@ -54,7 +54,8 @@ interface MailViewChromeProps {
|
|
|
54
54
|
/**
|
|
55
55
|
* The body renders the committed search as a selectable list itself; see
|
|
56
56
|
* `MailListHeader`. The mailbox route sets this so a committed search yields
|
|
57
|
-
* its multi-select `MessageList` (#212);
|
|
57
|
+
* its multi-select `MessageList` (#212); Starred sets it for the same reason,
|
|
58
|
+
* over its own already query-narrowed rows.
|
|
58
59
|
*/
|
|
59
60
|
searchResultsInBody?: boolean;
|
|
60
61
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A back-apply whose status could not be read is still running on the server
|
|
9
|
+
* (#526), so the run screen's affordance looks at that job again rather than
|
|
10
|
+
* queuing a second pass over the same mail.
|
|
11
|
+
*
|
|
12
|
+
* The host wires routing, history and several data hooks together, so — as with
|
|
13
|
+
* this package's other component-level rules (see `SelectionWizardHost.run-exit.test.ts`)
|
|
14
|
+
* — the wiring is read off the source. What it decides is proven where it can be
|
|
15
|
+
* run: `./organize-run-state.test.ts` for the ending the screen shows, and
|
|
16
|
+
* `../../hooks/useOrganizeJob.render.test.ts` for the poll it re-issues.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
|
|
21
|
+
|
|
22
|
+
const retryBody = (): string => {
|
|
23
|
+
const body = source.match(
|
|
24
|
+
/const retry = \(\): void => \{([\s\S]*?)\n\t\};/,
|
|
25
|
+
)?.[1];
|
|
26
|
+
assert.ok(body, "the run screen has no retry");
|
|
27
|
+
return body;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("retrying from an unknown status", () => {
|
|
31
|
+
it("looks at the job it already has", () => {
|
|
32
|
+
const body = retryBody();
|
|
33
|
+
const unknown = body.indexOf('run.state === "statusUnknown"');
|
|
34
|
+
assert.ok(unknown >= 0, "retry does not recognise an unknown status");
|
|
35
|
+
assert.match(body.slice(unknown), /organizeJob\.refreshStatus\(\)/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("decides that before it can reach anything that queues a second job", () => {
|
|
39
|
+
const body = retryBody();
|
|
40
|
+
assert.ok(
|
|
41
|
+
body.indexOf('run.state === "statusUnknown"') < body.indexOf("startJob("),
|
|
42
|
+
"a second back-apply can be queued over a job that is still running",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reads what the job is doing rather than that something failed", () => {
|
|
47
|
+
assert.doesNotMatch(source, /organizeJob\.isError/);
|
|
48
|
+
assert.match(source, /organizeRunState\(\{/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
|
|
66
66
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
67
67
|
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
68
|
+
import { organizeRunState } from "./organize-run-state";
|
|
68
69
|
|
|
69
70
|
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
|
|
70
71
|
|
|
@@ -696,29 +697,34 @@ function SelectionWizardSession({
|
|
|
696
697
|
sendCommit();
|
|
697
698
|
}, [blockedReason, current, goToStep, sendCommit]);
|
|
698
699
|
|
|
699
|
-
const jobSnapshot = useCallback(
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
700
|
+
const jobSnapshot = useCallback(
|
|
701
|
+
(ruleSaved: boolean): RunSnapshot => {
|
|
702
|
+
const progress = organizeJob.progress;
|
|
703
|
+
const state = organizeRunState({
|
|
704
|
+
failure: organizeJob.failure,
|
|
705
|
+
isStarting: organizeJob.isStarting,
|
|
706
|
+
isRunning: organizeJob.isRunning,
|
|
707
|
+
isDone: organizeJob.isDone,
|
|
708
|
+
failedCount: progress.failedCount,
|
|
709
|
+
ruleSaved,
|
|
710
|
+
});
|
|
711
|
+
if (
|
|
712
|
+
state === "saving" ||
|
|
713
|
+
state === "commitFailed" ||
|
|
714
|
+
state === "backApplyStartFailed"
|
|
715
|
+
) {
|
|
716
|
+
return { ...NOT_STARTED, state };
|
|
717
|
+
}
|
|
710
718
|
return {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
failed: progress.failedCount,
|
|
719
|
+
state,
|
|
720
|
+
matched: progress.matchedCount,
|
|
721
|
+
applied: progress.appliedCount,
|
|
722
|
+
failed: state === "backApplyFailed" ? progress.failedCount : 0,
|
|
723
|
+
failures: [],
|
|
715
724
|
};
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
return NOT_STARTED;
|
|
721
|
-
}, [organizeJob]);
|
|
725
|
+
},
|
|
726
|
+
[organizeJob],
|
|
727
|
+
);
|
|
722
728
|
|
|
723
729
|
// Every row the wizard has seen a description of, so a run that names what it
|
|
724
730
|
// did not reach can name it rather than listing an id.
|
|
@@ -794,21 +800,27 @@ function SelectionWizardSession({
|
|
|
794
800
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
795
801
|
if (!createFilter.isSuccess) return NOT_STARTED;
|
|
796
802
|
if (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
|
|
797
|
-
|
|
798
|
-
return { ...NOT_STARTED, state: "backApplyStartFailed" };
|
|
799
|
-
}
|
|
800
|
-
return jobSnapshot();
|
|
803
|
+
return jobSnapshot(true);
|
|
801
804
|
}
|
|
802
805
|
if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
|
|
803
|
-
return jobSnapshot();
|
|
806
|
+
return jobSnapshot(false);
|
|
804
807
|
}
|
|
805
808
|
return bulkSnapshot();
|
|
806
809
|
};
|
|
807
810
|
|
|
811
|
+
const run = runSnapshot();
|
|
812
|
+
|
|
808
813
|
// Retry stays on the run screen: it re-sends the same commit rather than
|
|
809
814
|
// walking back to Review, which would push an entry the wizard does not own
|
|
810
815
|
// and leave Cancel rewinding to a step instead of out.
|
|
811
816
|
const retry = (): void => {
|
|
817
|
+
// A status that could not be read is a job still running on the server, so
|
|
818
|
+
// the screen looks at that job again rather than queuing a second pass over
|
|
819
|
+
// the same mail (#526).
|
|
820
|
+
if (run.state === "statusUnknown") {
|
|
821
|
+
organizeJob.refreshStatus();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
812
824
|
// The predicate is re-resolved, not resumed: every verb it carries is
|
|
813
825
|
// idempotent, so the messages the first pass already reached are a no-op.
|
|
814
826
|
if (escalated) {
|
|
@@ -881,7 +893,6 @@ function SelectionWizardSession({
|
|
|
881
893
|
disabled: current !== "run",
|
|
882
894
|
});
|
|
883
895
|
|
|
884
|
-
const run = runSnapshot();
|
|
885
896
|
const sampleMessages = escalated
|
|
886
897
|
? escalatedSample.messages
|
|
887
898
|
: previewed
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A back-apply job reports two things that used to arrive as one error flag: it
|
|
3
|
+
* never started, or its status could not be read (#526). The run screen is only
|
|
4
|
+
* allowed to say nothing happened for the first.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import {
|
|
10
|
+
type OrganizeJobReading,
|
|
11
|
+
organizeRunState,
|
|
12
|
+
} from "./organize-run-state";
|
|
13
|
+
|
|
14
|
+
const reading = (
|
|
15
|
+
over: Partial<OrganizeJobReading> = {},
|
|
16
|
+
): OrganizeJobReading => ({
|
|
17
|
+
failure: undefined,
|
|
18
|
+
isStarting: false,
|
|
19
|
+
isRunning: false,
|
|
20
|
+
isDone: false,
|
|
21
|
+
failedCount: 0,
|
|
22
|
+
ruleSaved: false,
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("organizeRunState", () => {
|
|
27
|
+
it("keeps a running job running when a status poll could not be read", () => {
|
|
28
|
+
const state = organizeRunState(
|
|
29
|
+
reading({
|
|
30
|
+
isRunning: true,
|
|
31
|
+
failure: { kind: "statusUnreadable", error: new Error("offline") },
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
assert.equal(state, "statusUnknown");
|
|
35
|
+
assert.notEqual(state, "commitFailed");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps a finished job finished when a later poll fails", () => {
|
|
39
|
+
// Polling stops on a terminal state, so the failing read is a window-focus
|
|
40
|
+
// refetch over a job that already reported its counts.
|
|
41
|
+
assert.equal(
|
|
42
|
+
organizeRunState(
|
|
43
|
+
reading({
|
|
44
|
+
isDone: true,
|
|
45
|
+
failure: { kind: "statusUnreadable", error: new Error("offline") },
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
"backApplyComplete",
|
|
49
|
+
);
|
|
50
|
+
assert.equal(
|
|
51
|
+
organizeRunState(
|
|
52
|
+
reading({
|
|
53
|
+
isDone: true,
|
|
54
|
+
failedCount: 3,
|
|
55
|
+
failure: { kind: "statusUnreadable", error: new Error("offline") },
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
"backApplyFailed",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("says nothing happened only when the create itself failed", () => {
|
|
63
|
+
assert.equal(
|
|
64
|
+
organizeRunState(
|
|
65
|
+
reading({ failure: { kind: "startFailed", error: new Error("nope") } }),
|
|
66
|
+
),
|
|
67
|
+
"commitFailed",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("leaves a saved rule standing when its pass never started", () => {
|
|
72
|
+
assert.equal(
|
|
73
|
+
organizeRunState(
|
|
74
|
+
reading({
|
|
75
|
+
ruleSaved: true,
|
|
76
|
+
failure: { kind: "startFailed", error: new Error("nope") },
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
"backApplyStartFailed",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reports a job that is starting or polling cleanly as running", () => {
|
|
84
|
+
assert.equal(
|
|
85
|
+
organizeRunState(reading({ isStarting: true })),
|
|
86
|
+
"backApplyRunning",
|
|
87
|
+
);
|
|
88
|
+
assert.equal(
|
|
89
|
+
organizeRunState(reading({ isRunning: true })),
|
|
90
|
+
"backApplyRunning",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("has nothing to report before a commit", () => {
|
|
95
|
+
assert.equal(organizeRunState(reading()), "saving");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RunState } from "@remit/ui";
|
|
2
|
+
import type { OrganizeJobFailure } from "@/hooks/useOrganizeJob";
|
|
3
|
+
|
|
4
|
+
/** What the run screen knows about a back-apply job it is reporting on. */
|
|
5
|
+
export interface OrganizeJobReading {
|
|
6
|
+
failure: OrganizeJobFailure | undefined;
|
|
7
|
+
isStarting: boolean;
|
|
8
|
+
isRunning: boolean;
|
|
9
|
+
isDone: boolean;
|
|
10
|
+
failedCount: number;
|
|
11
|
+
/**
|
|
12
|
+
* The pass belongs to a rule that already saved, so a pass that never started
|
|
13
|
+
* leaves the rule standing rather than leaving nothing behind.
|
|
14
|
+
*/
|
|
15
|
+
ruleSaved: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the run screen says the job is doing. A status that could not be read is
|
|
20
|
+
* not a job that never started (#526), so what the job is doing is read before
|
|
21
|
+
* what failed: a dropped poll leaves a running pass running and a finished one
|
|
22
|
+
* finished, and only a create that never returned an id says nothing happened.
|
|
23
|
+
*/
|
|
24
|
+
export const organizeRunState = ({
|
|
25
|
+
failure,
|
|
26
|
+
isStarting,
|
|
27
|
+
isRunning,
|
|
28
|
+
isDone,
|
|
29
|
+
failedCount,
|
|
30
|
+
ruleSaved,
|
|
31
|
+
}: OrganizeJobReading): RunState => {
|
|
32
|
+
if (failure?.kind === "startFailed") {
|
|
33
|
+
return ruleSaved ? "backApplyStartFailed" : "commitFailed";
|
|
34
|
+
}
|
|
35
|
+
if (isDone) return failedCount > 0 ? "backApplyFailed" : "backApplyComplete";
|
|
36
|
+
if (failure?.kind === "statusUnreadable") return "statusUnknown";
|
|
37
|
+
if (isStarting || isRunning) return "backApplyRunning";
|
|
38
|
+
return "saving";
|
|
39
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOrganizeJob — the back-apply job seam. It reports two failures that are not
|
|
3
|
+
* the same fact (#526): a create that never returned a job id, and a status poll
|
|
4
|
+
* that could not be read over a job the server is already running. Looking at
|
|
5
|
+
* that job again is a separate move from starting one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { afterEach, describe, it } from "node:test";
|
|
10
|
+
import { act, createElement } from "react";
|
|
11
|
+
import type { OrganizeDraft } from "../lib/organize/organize-model";
|
|
12
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
13
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
14
|
+
import { useOrganizeJob } from "./useOrganizeJob";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A poll that could not be read reaches this screen only as a transport failure:
|
|
18
|
+
* `shouldEscalate` puts every answered non-2xx on the full-screen fatal page, so
|
|
19
|
+
* a status that is merely unreadable is a `fetch` that never landed.
|
|
20
|
+
*/
|
|
21
|
+
const dropped = (): never => {
|
|
22
|
+
throw new TypeError("fetch failed");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ACCOUNT = "acc-1";
|
|
26
|
+
const JOB = "job-1";
|
|
27
|
+
|
|
28
|
+
const DRAFT: OrganizeDraft = {
|
|
29
|
+
matchOperator: "Or",
|
|
30
|
+
literalClauses: [{ field: "From", value: "noreply@example.com" }],
|
|
31
|
+
moveMailboxId: "mbx-1",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let harness: DomHarness | undefined;
|
|
35
|
+
let http: HttpMock | undefined;
|
|
36
|
+
let job: ReturnType<typeof useOrganizeJob> | undefined;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
harness?.close();
|
|
40
|
+
harness = undefined;
|
|
41
|
+
http?.restore();
|
|
42
|
+
http = undefined;
|
|
43
|
+
job = undefined;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function Probe() {
|
|
47
|
+
job = useOrganizeJob(ACCOUNT);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const current = (): ReturnType<typeof useOrganizeJob> => {
|
|
52
|
+
if (!job) throw new Error("useOrganizeJob is not mounted");
|
|
53
|
+
return job;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// A request round-trip needs a macrotask, not just a drained microtask queue.
|
|
57
|
+
const settle = async (): Promise<void> => {
|
|
58
|
+
await harness?.wait(1);
|
|
59
|
+
await harness?.flush();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Start a job the server accepts, then answer every status poll with `status`. */
|
|
63
|
+
const startJob = async (status: () => unknown): Promise<void> => {
|
|
64
|
+
http = mockFetch((call) => {
|
|
65
|
+
if (call.method === "POST") {
|
|
66
|
+
return { organizeJobId: JOB, state: "Pending" };
|
|
67
|
+
}
|
|
68
|
+
return status();
|
|
69
|
+
});
|
|
70
|
+
harness = createDomHarness();
|
|
71
|
+
harness.renderApp(createElement(Probe));
|
|
72
|
+
await act(async () => {
|
|
73
|
+
current().start(DRAFT);
|
|
74
|
+
});
|
|
75
|
+
await settle();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const posts = (): number =>
|
|
79
|
+
(http?.calls ?? []).filter((call) => call.method === "POST").length;
|
|
80
|
+
|
|
81
|
+
const statusPolls = (): number => (http?.to(`/organize/${JOB}`) ?? []).length;
|
|
82
|
+
|
|
83
|
+
describe("useOrganizeJob status reporting", () => {
|
|
84
|
+
it("reports a status poll it could not read as unreadable, over a job still running", async () => {
|
|
85
|
+
await startJob(dropped);
|
|
86
|
+
assert.equal(current().failure?.kind, "statusUnreadable");
|
|
87
|
+
assert.equal(current().isRunning, true);
|
|
88
|
+
assert.equal(current().isDone, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reports a create that never returned a job id as a start failure", async () => {
|
|
92
|
+
http = mockFetch(dropped);
|
|
93
|
+
harness = createDomHarness();
|
|
94
|
+
harness.renderApp(createElement(Probe));
|
|
95
|
+
await act(async () => {
|
|
96
|
+
current().start(DRAFT);
|
|
97
|
+
});
|
|
98
|
+
await settle();
|
|
99
|
+
assert.equal(current().failure?.kind, "startFailed");
|
|
100
|
+
assert.equal(current().isRunning, false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("re-polls the job it already has instead of starting a second one", async () => {
|
|
104
|
+
let readable = false;
|
|
105
|
+
await startJob(() =>
|
|
106
|
+
readable
|
|
107
|
+
? {
|
|
108
|
+
organizeJobId: JOB,
|
|
109
|
+
state: "Running",
|
|
110
|
+
matchedCount: 1284,
|
|
111
|
+
appliedCount: 40,
|
|
112
|
+
failedCount: 0,
|
|
113
|
+
}
|
|
114
|
+
: dropped(),
|
|
115
|
+
);
|
|
116
|
+
assert.equal(current().failure?.kind, "statusUnreadable");
|
|
117
|
+
const pollsBefore = statusPolls();
|
|
118
|
+
|
|
119
|
+
readable = true;
|
|
120
|
+
await act(async () => {
|
|
121
|
+
current().refreshStatus();
|
|
122
|
+
});
|
|
123
|
+
await settle();
|
|
124
|
+
|
|
125
|
+
assert.equal(posts(), 1);
|
|
126
|
+
assert.ok(statusPolls() > pollsBefore, "the existing job was polled again");
|
|
127
|
+
assert.equal(current().failure, undefined);
|
|
128
|
+
assert.equal(current().progress.matchedCount, 1284);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("stops reporting a job as running once it reaches a terminal state", async () => {
|
|
132
|
+
await startJob(() => ({
|
|
133
|
+
organizeJobId: JOB,
|
|
134
|
+
state: "Complete",
|
|
135
|
+
matchedCount: 1284,
|
|
136
|
+
appliedCount: 1284,
|
|
137
|
+
failedCount: 0,
|
|
138
|
+
}));
|
|
139
|
+
assert.equal(current().isDone, true);
|
|
140
|
+
assert.equal(current().failure, undefined);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -22,6 +22,25 @@ export interface OrganizeJobProgress {
|
|
|
22
22
|
errorMessage: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Why the job is not reporting, which is two separate facts (#526). A create
|
|
27
|
+
* that never returned an id means nothing was started; a status read that
|
|
28
|
+
* failed means a job is out there and this client cannot see how far it got.
|
|
29
|
+
*/
|
|
30
|
+
export interface OrganizeJobFailure {
|
|
31
|
+
kind: "startFailed" | "statusUnreadable";
|
|
32
|
+
error: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const organizeJobFailure = (
|
|
36
|
+
createError: unknown,
|
|
37
|
+
statusError: unknown,
|
|
38
|
+
): OrganizeJobFailure | undefined => {
|
|
39
|
+
if (createError) return { kind: "startFailed", error: createError };
|
|
40
|
+
if (statusError) return { kind: "statusUnreadable", error: statusError };
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
25
44
|
/**
|
|
26
45
|
* "All like these" — start a one-time retroactive back-apply (POST /organize)
|
|
27
46
|
* and poll its status to completion (GET /organize/{organizeJobId}). Polling
|
|
@@ -66,6 +85,13 @@ export const useOrganizeJob = (accountId: string | undefined) => {
|
|
|
66
85
|
[accountId, createJob],
|
|
67
86
|
);
|
|
68
87
|
|
|
88
|
+
const { refetch } = jobQuery;
|
|
89
|
+
// Looks at the job already in flight again. Distinct from `start`, which
|
|
90
|
+
// queues a second pass over the same mail.
|
|
91
|
+
const refreshStatus = useCallback(() => {
|
|
92
|
+
void refetch();
|
|
93
|
+
}, [refetch]);
|
|
94
|
+
|
|
69
95
|
const job = jobQuery.data;
|
|
70
96
|
const state = job?.state ?? createMutation.data?.state;
|
|
71
97
|
const isDone = isTerminalJobState(job?.state);
|
|
@@ -80,11 +106,11 @@ export const useOrganizeJob = (accountId: string | undefined) => {
|
|
|
80
106
|
|
|
81
107
|
return {
|
|
82
108
|
start,
|
|
109
|
+
refreshStatus,
|
|
83
110
|
progress,
|
|
84
111
|
isStarting: createMutation.isPending,
|
|
85
112
|
isRunning: !!organizeJobId && !isDone,
|
|
86
113
|
isDone,
|
|
87
|
-
|
|
88
|
-
error: createMutation.error ?? jobQuery.error,
|
|
114
|
+
failure: organizeJobFailure(createMutation.error, jobQuery.error),
|
|
89
115
|
};
|
|
90
116
|
};
|
|
@@ -13,12 +13,12 @@ import type { LayoutTier } from "@/hooks/useLayoutTier";
|
|
|
13
13
|
* Phone never swaps: it shows the body and keeps the panel in its full-screen
|
|
14
14
|
* takeover instead, so this only decides tablet/desktop.
|
|
15
15
|
*
|
|
16
|
-
* A view whose body renders committed results itself (`bodyRendersCommittedResults
|
|
17
|
-
* the mailbox route) keeps the panel only while the query is
|
|
18
|
-
* uncommitted, not yet mirrored to the URL — and hands back to
|
|
19
|
-
* the query commits, so the committed search is a selectable
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* A view whose body renders committed results itself (`bodyRendersCommittedResults`
|
|
17
|
+
* — the mailbox route, the brief, Starred) keeps the panel only while the query is
|
|
18
|
+
* still being typed — uncommitted, not yet mirrored to the URL — and hands back to
|
|
19
|
+
* the body the moment the query commits, so the committed search is a selectable
|
|
20
|
+
* list. A view that leaves the flag off keeps the panel for any query, committed
|
|
21
|
+
* or not.
|
|
22
22
|
*/
|
|
23
23
|
export const showInlineSearchResults = (input: {
|
|
24
24
|
tier: LayoutTier;
|