@remit/web-client 0.0.317 → 0.0.318
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/MessageList.tsx +1 -0
- package/src/components/mail/SelectionWizardHost.tsx +54 -21
- package/src/components/settings/{FilterToggleError.render.test.ts → FilterActionError.render.test.ts} +15 -5
- package/src/components/settings/{FilterToggleError.stories.tsx → FilterActionError.stories.tsx} +17 -7
- package/src/components/settings/{FilterToggleError.tsx → FilterActionError.tsx} +6 -10
- package/src/components/settings/FiltersList.render.test.ts +65 -0
- package/src/components/settings/FiltersList.stories.tsx +25 -0
- package/src/components/settings/FiltersList.tsx +47 -0
- package/src/hooks/useFilters.ts +9 -7
- package/src/hooks/useOrganizeJob.ts +15 -0
- package/src/lib/filter-report.ts +9 -0
- package/src/lib/organize/filter-run.test.ts +79 -0
- package/src/lib/organize/filter-run.ts +48 -0
- package/src/lib/organize/organize-model.ts +12 -8
- package/src/lib/wizard-selection.ts +6 -0
- package/src/routes/settings/filters.tsx +42 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.318",
|
|
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": {
|
|
@@ -33,6 +33,7 @@ import { useBlocker } from "@tanstack/react-router";
|
|
|
33
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
34
34
|
import {
|
|
35
35
|
useFolderAppointments,
|
|
36
|
+
useInboxMailbox,
|
|
36
37
|
useJunkMailbox,
|
|
37
38
|
} from "@/hooks/useArchiveMailbox";
|
|
38
39
|
import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
|
|
@@ -61,8 +62,6 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
61
62
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
62
63
|
import {
|
|
63
64
|
buildWizardDraft,
|
|
64
|
-
canBackApplyDraft,
|
|
65
|
-
type OrganizeDraft,
|
|
66
65
|
type OrganizeScope,
|
|
67
66
|
organizeScopeFor,
|
|
68
67
|
} from "@/lib/organize/organize-model";
|
|
@@ -367,11 +366,6 @@ function SelectionWizardSession({
|
|
|
367
366
|
}
|
|
368
367
|
| undefined
|
|
369
368
|
>(undefined);
|
|
370
|
-
// The predicate the create chains its pass to. Undefined when the rule cannot
|
|
371
|
-
// be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
|
|
372
|
-
// in which case the filter still saves and applies to incoming mail. Kept, not
|
|
373
|
-
// cleared, so a failed start can be retried.
|
|
374
|
-
const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
|
|
375
369
|
const commitSent = useRef(false);
|
|
376
370
|
|
|
377
371
|
const messageIds = useMemo(
|
|
@@ -395,6 +389,26 @@ function SelectionWizardSession({
|
|
|
395
389
|
const anchorMessageId = messageIds[0];
|
|
396
390
|
const subjects = useSelectedSubjects(messageIds);
|
|
397
391
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
392
|
+
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
393
|
+
// A saved rule's first pass runs over the inbox. Ticked rows in any other
|
|
394
|
+
// folder take the action directly, so a rule made from Archive or from a
|
|
395
|
+
// search across folders still files the mail it was made from.
|
|
396
|
+
const outsideInbox = useMemo<BulkActionTarget[]>(
|
|
397
|
+
() =>
|
|
398
|
+
inboxMailboxId
|
|
399
|
+
? selection
|
|
400
|
+
.filter(
|
|
401
|
+
(message) =>
|
|
402
|
+
message.mailboxId !== undefined &&
|
|
403
|
+
message.mailboxId !== inboxMailboxId,
|
|
404
|
+
)
|
|
405
|
+
.map((message) => ({
|
|
406
|
+
id: message.id,
|
|
407
|
+
accountId: message.accountId,
|
|
408
|
+
}))
|
|
409
|
+
: [],
|
|
410
|
+
[selection, inboxMailboxId],
|
|
411
|
+
);
|
|
398
412
|
|
|
399
413
|
// No door is offered over an escalated predicate, so nothing here has a widen
|
|
400
414
|
// to probe for.
|
|
@@ -728,7 +742,7 @@ function SelectionWizardSession({
|
|
|
728
742
|
});
|
|
729
743
|
}, [escalated, verb, named.moveMailboxId, junkMailboxId, claimEnding]);
|
|
730
744
|
|
|
731
|
-
const { start: startJob } = organizeJob;
|
|
745
|
+
const { start: startJob, watch: watchJob } = organizeJob;
|
|
732
746
|
const { createFilterAsync } = createFilter;
|
|
733
747
|
|
|
734
748
|
const sendCommit = useCallback(() => {
|
|
@@ -752,21 +766,18 @@ function SelectionWizardSession({
|
|
|
752
766
|
setCommittedScope(scope);
|
|
753
767
|
|
|
754
768
|
if (scope === "standing" || scope === "temporary") {
|
|
755
|
-
// Creating a filter also
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
// behind it — and nothing left to retry from — is a silent no-op.
|
|
760
|
-
const backApply = canBackApplyDraft(organizeDraft)
|
|
761
|
-
? organizeDraft
|
|
762
|
-
: undefined;
|
|
763
|
-
setBackApplyDraft(backApply);
|
|
769
|
+
// Creating a filter also runs it over the inbox: the server queues that
|
|
770
|
+
// pass with the create and hands back its job id, which this screen
|
|
771
|
+
// follows. Ticked rows outside the inbox are not the job's to reach, so
|
|
772
|
+
// they take the action directly.
|
|
764
773
|
void createFilterAsync(
|
|
765
774
|
organizeDraft,
|
|
766
775
|
scope,
|
|
767
776
|
(named.name ?? "").trim(),
|
|
768
777
|
).then((created) => {
|
|
769
|
-
if (created
|
|
778
|
+
if (!created) return;
|
|
779
|
+
watchJob(created.organizeJobId);
|
|
780
|
+
if (outsideInbox.length > 0) void runBulk(outsideInbox);
|
|
770
781
|
});
|
|
771
782
|
return;
|
|
772
783
|
}
|
|
@@ -792,6 +803,8 @@ function SelectionWizardSession({
|
|
|
792
803
|
bulkTargets,
|
|
793
804
|
matchedIds,
|
|
794
805
|
createFilterAsync,
|
|
806
|
+
watchJob,
|
|
807
|
+
outsideInbox,
|
|
795
808
|
startJob,
|
|
796
809
|
runBulk,
|
|
797
810
|
]);
|
|
@@ -878,13 +891,22 @@ function SelectionWizardSession({
|
|
|
878
891
|
};
|
|
879
892
|
}, [bulkRun, runProgress, rowsById]);
|
|
880
893
|
|
|
894
|
+
const createdFilter = createFilter.data;
|
|
895
|
+
// The direct action on ticked rows outside the inbox fell short. Its
|
|
896
|
+
// failures are reported over the job's progress: they are mail the user
|
|
897
|
+
// picked, and the job will never reach them.
|
|
898
|
+
const bulkFellShort =
|
|
899
|
+
bulkRun !== undefined &&
|
|
900
|
+
(bulkRun.failureReason !== undefined ||
|
|
901
|
+
(bulkRun.outcome?.failedIds.length ?? 0) > 0);
|
|
902
|
+
|
|
881
903
|
const runSnapshot = (): RunSnapshot => {
|
|
882
904
|
if (escalated) return bulkSnapshot();
|
|
883
905
|
if (committedScope === "standing" || committedScope === "temporary") {
|
|
884
906
|
if (createFilter.isError)
|
|
885
907
|
return { ...NOT_STARTED, state: "commitFailed" };
|
|
886
908
|
if (!createFilter.isSuccess) return NOT_STARTED;
|
|
887
|
-
if (
|
|
909
|
+
if (bulkFellShort) return bulkSnapshot();
|
|
888
910
|
return jobSnapshot(true);
|
|
889
911
|
}
|
|
890
912
|
if (committedScope === "all-like-these" && widenedRunsAsJob(verb)) {
|
|
@@ -899,12 +921,23 @@ function SelectionWizardSession({
|
|
|
899
921
|
// walking back to Review, which would push an entry the wizard does not own
|
|
900
922
|
// and leave Cancel rewinding to a step instead of out.
|
|
901
923
|
const retry = (): void => {
|
|
924
|
+
const ruleSaved =
|
|
925
|
+
committedScope === "standing" || committedScope === "temporary";
|
|
926
|
+
if (ruleSaved && bulkFellShort && bulkRun) {
|
|
927
|
+
const failed = new Set(bulkRun.outcome?.failedIds ?? []);
|
|
928
|
+
void runBulk(
|
|
929
|
+
failed.size > 0
|
|
930
|
+
? bulkRun.sent.filter((target) => failed.has(target.id))
|
|
931
|
+
: bulkRun.sent,
|
|
932
|
+
);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
902
935
|
const intent = retryIntent({
|
|
903
936
|
runState: run.state,
|
|
904
937
|
isEscalated: escalated !== undefined,
|
|
905
938
|
committedScope,
|
|
906
939
|
createFilterFailed: createFilter.isError,
|
|
907
|
-
backApplyPending:
|
|
940
|
+
backApplyPending: createdFilter !== undefined,
|
|
908
941
|
widenRunsAsJob: widenedRunsAsJob(verb),
|
|
909
942
|
failedIds: bulkRun?.outcome?.failedIds ?? [],
|
|
910
943
|
sent: bulkRun?.sent ?? bulkTargets,
|
|
@@ -924,7 +957,7 @@ function SelectionWizardSession({
|
|
|
924
957
|
sendCommit();
|
|
925
958
|
return;
|
|
926
959
|
case "startBackApply":
|
|
927
|
-
if (
|
|
960
|
+
if (createdFilter) organizeJob.startForFilter(createdFilter.filterId);
|
|
928
961
|
return;
|
|
929
962
|
case "waitOnJob":
|
|
930
963
|
return;
|
|
@@ -3,14 +3,14 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import React, { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import { ApiError } from "@/lib/api";
|
|
6
|
-
import {
|
|
6
|
+
import { FilterActionError } from "./FilterActionError";
|
|
7
7
|
|
|
8
8
|
(globalThis as { React?: typeof React }).React = React;
|
|
9
9
|
|
|
10
|
-
const render = (error: unknown) =>
|
|
10
|
+
const render = (error: unknown, title = "Couldn't turn the filter on") =>
|
|
11
11
|
renderToString(
|
|
12
|
-
createElement(
|
|
13
|
-
|
|
12
|
+
createElement(FilterActionError, {
|
|
13
|
+
title,
|
|
14
14
|
error,
|
|
15
15
|
onRetry: () => undefined,
|
|
16
16
|
reportHref: (message: string) =>
|
|
@@ -18,7 +18,7 @@ const render = (error: unknown) =>
|
|
|
18
18
|
}) as never,
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
describe("
|
|
21
|
+
describe("FilterActionError (#1103, #1354)", () => {
|
|
22
22
|
it("shows a refusal with its fix and a report link, and no Retry", () => {
|
|
23
23
|
const html = render(new ApiError("Pick a folder in the rule.", 400));
|
|
24
24
|
assert.match(html, /Couldn('|')t turn the filter on/);
|
|
@@ -33,4 +33,14 @@ describe("FilterToggleError (#1103)", () => {
|
|
|
33
33
|
assert.match(html, />Retry</);
|
|
34
34
|
assert.match(html, /Report an issue/);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it("names the filter a Run now could not start, with the server's reason", () => {
|
|
38
|
+
const html = render(
|
|
39
|
+
new ApiError("This filter is turned off or has expired.", 400),
|
|
40
|
+
"Couldn't run Invoices",
|
|
41
|
+
);
|
|
42
|
+
assert.match(html, /Couldn('|')t run Invoices/);
|
|
43
|
+
assert.match(html, /turned off or has expired/);
|
|
44
|
+
assert.match(html, /Report an issue/);
|
|
45
|
+
});
|
|
36
46
|
});
|
package/src/components/settings/{FilterToggleError.stories.tsx → FilterActionError.stories.tsx}
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { ApiError } from "@/lib/api";
|
|
3
|
-
import {
|
|
3
|
+
import { FilterActionError } from "./FilterActionError";
|
|
4
4
|
|
|
5
|
-
const meta: Meta<typeof
|
|
6
|
-
title: "Playground/Shipped/Settings/Filters/
|
|
7
|
-
component:
|
|
5
|
+
const meta: Meta<typeof FilterActionError> = {
|
|
6
|
+
title: "Playground/Shipped/Settings/Filters/FilterActionError",
|
|
7
|
+
component: FilterActionError,
|
|
8
8
|
parameters: { layout: "padded" },
|
|
9
9
|
decorators: [
|
|
10
10
|
(Story) => (
|
|
@@ -20,11 +20,11 @@ const meta: Meta<typeof FilterToggleError> = {
|
|
|
20
20
|
};
|
|
21
21
|
export default meta;
|
|
22
22
|
|
|
23
|
-
type Story = StoryObj<typeof
|
|
23
|
+
type Story = StoryObj<typeof FilterActionError>;
|
|
24
24
|
|
|
25
25
|
export const RefusedWithoutFolder: Story = {
|
|
26
26
|
args: {
|
|
27
|
-
|
|
27
|
+
title: "Couldn't turn the filter on",
|
|
28
28
|
error: new ApiError(
|
|
29
29
|
"This filter has no folder to move mail into yet. Wait for its folder to be created on the mail server, or pick a folder in the rule, then turn it on.",
|
|
30
30
|
400,
|
|
@@ -34,7 +34,17 @@ export const RefusedWithoutFolder: Story = {
|
|
|
34
34
|
|
|
35
35
|
export const ServerUnavailable: Story = {
|
|
36
36
|
args: {
|
|
37
|
-
|
|
37
|
+
title: "Couldn't turn the filter off",
|
|
38
38
|
error: new ApiError("The server did not answer.", 503),
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
|
+
|
|
42
|
+
export const RunNowRefused: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
title: "Couldn't run Invoices over the inbox",
|
|
45
|
+
error: new ApiError(
|
|
46
|
+
"This filter is turned off or has expired. Turn it on to run it over the inbox.",
|
|
47
|
+
400,
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
|
|
2
2
|
import { getErrorStatus } from "@/lib/error-classifier";
|
|
3
3
|
|
|
4
|
-
interface
|
|
5
|
-
|
|
4
|
+
interface FilterActionErrorProps {
|
|
5
|
+
title: string;
|
|
6
6
|
error: unknown;
|
|
7
7
|
onRetry: () => void;
|
|
8
8
|
reportHref: (message: string) => string;
|
|
@@ -13,21 +13,17 @@ const isRefusal = (error: unknown): boolean => {
|
|
|
13
13
|
return status !== undefined && status >= 400 && status < 500;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
export function
|
|
17
|
-
|
|
16
|
+
export function FilterActionError({
|
|
17
|
+
title,
|
|
18
18
|
error,
|
|
19
19
|
onRetry,
|
|
20
20
|
reportHref,
|
|
21
|
-
}:
|
|
21
|
+
}: FilterActionErrorProps) {
|
|
22
22
|
return (
|
|
23
23
|
<div className="space-y-1">
|
|
24
24
|
<ErrorState
|
|
25
25
|
variant="inline"
|
|
26
|
-
title={
|
|
27
|
-
enabling
|
|
28
|
-
? "Couldn't turn the filter on"
|
|
29
|
-
: "Couldn't turn the filter off"
|
|
30
|
-
}
|
|
26
|
+
title={title}
|
|
31
27
|
error={error}
|
|
32
28
|
onRetry={isRefusal(error) ? undefined : onRetry}
|
|
33
29
|
/>
|
|
@@ -4,6 +4,7 @@ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.t
|
|
|
4
4
|
import type { LabelOption } from "@remit/ui";
|
|
5
5
|
import React, { createElement } from "react";
|
|
6
6
|
import { renderToString } from "react-dom/server";
|
|
7
|
+
import type { FilterRunStatus } from "@/lib/organize/filter-run";
|
|
7
8
|
import { FiltersList } from "./FiltersList";
|
|
8
9
|
|
|
9
10
|
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
@@ -38,6 +39,7 @@ const render = (
|
|
|
38
39
|
filters: RemitImapFilterResponse[],
|
|
39
40
|
semanticUnavailable = false,
|
|
40
41
|
labelById: Map<string, LabelOption> = new Map(),
|
|
42
|
+
run: { runFilterId?: string; runStatus?: FilterRunStatus } = {},
|
|
41
43
|
) =>
|
|
42
44
|
renderToString(
|
|
43
45
|
createElement(FiltersList, {
|
|
@@ -47,6 +49,8 @@ const render = (
|
|
|
47
49
|
onEdit: () => undefined,
|
|
48
50
|
onDelete: () => undefined,
|
|
49
51
|
onToggle: () => undefined,
|
|
52
|
+
onRunNow: () => undefined,
|
|
53
|
+
...run,
|
|
50
54
|
semanticUnavailable,
|
|
51
55
|
now: NOW,
|
|
52
56
|
}) as never,
|
|
@@ -153,4 +157,65 @@ describe("FiltersList", () => {
|
|
|
153
157
|
assert.match(html, /Turn on filter Travel/);
|
|
154
158
|
});
|
|
155
159
|
}
|
|
160
|
+
|
|
161
|
+
it("offers Run now on an active filter with an action (#1354)", () => {
|
|
162
|
+
const html = render([filter({})]);
|
|
163
|
+
assert.match(html, /Run now: filter Travel/);
|
|
164
|
+
assert.match(html, />Run now</);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("offers no Run now on a turned-off or expired filter", () => {
|
|
168
|
+
const html = render([
|
|
169
|
+
filter({ state: "Disabled", disabledReason: "UserDisabled" }),
|
|
170
|
+
filter({
|
|
171
|
+
filterId: "f-2",
|
|
172
|
+
name: "Lisbon trip",
|
|
173
|
+
scope: "Temporary",
|
|
174
|
+
expiresAt: "2026-07-10T00:00:00Z",
|
|
175
|
+
}),
|
|
176
|
+
]);
|
|
177
|
+
assert.doesNotMatch(html, /Run now/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("offers no Run now on a filter with nothing to do", () => {
|
|
181
|
+
const html = render([
|
|
182
|
+
filter({ actionMailboxId: "None", actionLabelId: "None" }),
|
|
183
|
+
]);
|
|
184
|
+
assert.doesNotMatch(html, /Run now/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("says the run is going on the filter that was run, and holds the button", () => {
|
|
188
|
+
const html = render([filter({})], false, new Map(), {
|
|
189
|
+
runFilterId: "f-1",
|
|
190
|
+
runStatus: { kind: "running" },
|
|
191
|
+
});
|
|
192
|
+
assert.match(html, /role="status"/);
|
|
193
|
+
assert.match(html, /Running over the inbox/);
|
|
194
|
+
assert.match(html, /disabled=""/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("reports what the run filed once the job is done", () => {
|
|
198
|
+
const html = render([filter({})], false, new Map(), {
|
|
199
|
+
runFilterId: "f-1",
|
|
200
|
+
runStatus: { kind: "done", matched: 3, applied: 2 },
|
|
201
|
+
});
|
|
202
|
+
assert.match(html, /Filed 2 of 3 matching messages in the inbox/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("says so when nothing in the inbox matched", () => {
|
|
206
|
+
const html = render([filter({})], false, new Map(), {
|
|
207
|
+
runFilterId: "f-1",
|
|
208
|
+
runStatus: { kind: "done", matched: 0, applied: 0 },
|
|
209
|
+
});
|
|
210
|
+
assert.match(html, /Nothing in the inbox matched/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("puts no stale status on the row when the run failed", () => {
|
|
214
|
+
const html = render([filter({})], false, new Map(), {
|
|
215
|
+
runFilterId: "f-1",
|
|
216
|
+
runStatus: { kind: "failed", error: new Error("turned off") },
|
|
217
|
+
});
|
|
218
|
+
assert.doesNotMatch(html, /role="status"/);
|
|
219
|
+
assert.match(html, />Run now</);
|
|
220
|
+
});
|
|
156
221
|
});
|
|
@@ -22,6 +22,7 @@ const meta: Meta<typeof FiltersList> = {
|
|
|
22
22
|
onEdit: () => undefined,
|
|
23
23
|
onDelete: () => undefined,
|
|
24
24
|
onToggle: () => undefined,
|
|
25
|
+
onRunNow: () => undefined,
|
|
25
26
|
now: NOW,
|
|
26
27
|
},
|
|
27
28
|
};
|
|
@@ -87,3 +88,27 @@ export const Toggling: Story = {
|
|
|
87
88
|
togglingFilterId: "flt-1",
|
|
88
89
|
},
|
|
89
90
|
};
|
|
91
|
+
|
|
92
|
+
export const RunNowRunning: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
filters: [makeFilter({})],
|
|
95
|
+
runFilterId: "flt-1",
|
|
96
|
+
runStatus: { kind: "running" },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const RunNowDone: Story = {
|
|
101
|
+
args: {
|
|
102
|
+
filters: [makeFilter({})],
|
|
103
|
+
runFilterId: "flt-1",
|
|
104
|
+
runStatus: { kind: "done", matched: 3, applied: 3 },
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const RunNowMatchedNothing: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
filters: [makeFilter({})],
|
|
111
|
+
runFilterId: "flt-1",
|
|
112
|
+
runStatus: { kind: "done", matched: 0, applied: 0 },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
WidenChip,
|
|
8
8
|
} from "@remit/ui";
|
|
9
9
|
import { Trash2 } from "lucide-react";
|
|
10
|
+
import type { FilterRunStatus } from "@/lib/organize/filter-run";
|
|
10
11
|
import {
|
|
11
12
|
disabledReasonCopy,
|
|
12
13
|
filterDisplayStatus,
|
|
@@ -14,6 +15,20 @@ import {
|
|
|
14
15
|
} from "@/lib/organize/filter-status";
|
|
15
16
|
import { NO_ACTION } from "@/lib/organize/organize-model";
|
|
16
17
|
|
|
18
|
+
const runStatusLine = (status: FilterRunStatus): string | undefined => {
|
|
19
|
+
switch (status.kind) {
|
|
20
|
+
case "queuing":
|
|
21
|
+
case "running":
|
|
22
|
+
return "Running over the inbox…";
|
|
23
|
+
case "done":
|
|
24
|
+
return status.matched === 0
|
|
25
|
+
? "Done. Nothing in the inbox matched."
|
|
26
|
+
: `Done. Filed ${status.applied} of ${status.matched} matching messages in the inbox.`;
|
|
27
|
+
case "failed":
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
17
32
|
interface FiltersListProps {
|
|
18
33
|
filters: RemitImapFilterResponse[];
|
|
19
34
|
/** Resolve a destination mailbox id to a folder name for display. */
|
|
@@ -26,6 +41,12 @@ interface FiltersListProps {
|
|
|
26
41
|
deletingFilterId?: string;
|
|
27
42
|
onToggle: (filterId: string, enabled: boolean) => void;
|
|
28
43
|
togglingFilterId?: string;
|
|
44
|
+
/** Run the filter over the inbox now (#1354). */
|
|
45
|
+
onRunNow: (filterId: string) => void;
|
|
46
|
+
/** The filter the last Run now was for. */
|
|
47
|
+
runFilterId?: string;
|
|
48
|
+
/** How that run stands; a failure is reported above the list. */
|
|
49
|
+
runStatus?: FilterRunStatus;
|
|
29
50
|
/**
|
|
30
51
|
* This deployment ships no vector pipeline (RFC 038 D4). A filter carrying a
|
|
31
52
|
* semantic anchor lists with its widen chip inactive — it matches by its
|
|
@@ -52,6 +73,9 @@ export function FiltersList({
|
|
|
52
73
|
deletingFilterId,
|
|
53
74
|
onToggle,
|
|
54
75
|
togglingFilterId,
|
|
76
|
+
onRunNow,
|
|
77
|
+
runFilterId,
|
|
78
|
+
runStatus,
|
|
55
79
|
semanticUnavailable = false,
|
|
56
80
|
now = Date.now(),
|
|
57
81
|
}: FiltersListProps) {
|
|
@@ -80,6 +104,13 @@ export function FiltersList({
|
|
|
80
104
|
? labelById.get(filter.actionLabelId)
|
|
81
105
|
: undefined;
|
|
82
106
|
const expiresLabel = formatExpiresAt(filter.expiresAt);
|
|
107
|
+
const run = runFilterId === filter.filterId ? runStatus : undefined;
|
|
108
|
+
const busy = run?.kind === "queuing" || run?.kind === "running";
|
|
109
|
+
const runLine = run ? runStatusLine(run) : undefined;
|
|
110
|
+
const runnable =
|
|
111
|
+
status === "Active" &&
|
|
112
|
+
(filter.actionMailboxId !== NO_ACTION ||
|
|
113
|
+
filter.actionLabelId !== NO_ACTION);
|
|
83
114
|
|
|
84
115
|
return (
|
|
85
116
|
<li
|
|
@@ -120,6 +151,11 @@ export function FiltersList({
|
|
|
120
151
|
{reason && (
|
|
121
152
|
<p className="mt-0.5 text-xs text-warning">{reason}</p>
|
|
122
153
|
)}
|
|
154
|
+
{runLine && (
|
|
155
|
+
<p role="status" className="mt-0.5 text-xs text-fg-muted">
|
|
156
|
+
{runLine}
|
|
157
|
+
</p>
|
|
158
|
+
)}
|
|
123
159
|
{(filter.hasAnchor || label) && (
|
|
124
160
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
125
161
|
{filter.hasAnchor && (
|
|
@@ -142,6 +178,17 @@ export function FiltersList({
|
|
|
142
178
|
</div>
|
|
143
179
|
)}
|
|
144
180
|
</button>
|
|
181
|
+
{runnable && (
|
|
182
|
+
<Button
|
|
183
|
+
variant="ghost"
|
|
184
|
+
size="sm"
|
|
185
|
+
onClick={() => onRunNow(filter.filterId)}
|
|
186
|
+
disabled={busy}
|
|
187
|
+
aria-label={`Run now: filter ${filter.name}`}
|
|
188
|
+
>
|
|
189
|
+
{busy ? "Running…" : "Run now"}
|
|
190
|
+
</Button>
|
|
191
|
+
)}
|
|
145
192
|
{!expired && (
|
|
146
193
|
<Button
|
|
147
194
|
variant="ghost"
|
package/src/hooks/useFilters.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
filterOperationsListFiltersOptions,
|
|
6
6
|
filterOperationsListFiltersQueryKey,
|
|
7
7
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
|
+
import type { RemitImapCreateFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
8
9
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
10
|
import { useCallback } from "react";
|
|
10
11
|
import {
|
|
@@ -84,23 +85,23 @@ export const useCreateFilter = (accountId: string | undefined) => {
|
|
|
84
85
|
* to the request rather than to the surface is what keeps it running when the
|
|
85
86
|
* surface is closed while the create is still in flight.
|
|
86
87
|
*
|
|
87
|
-
* `
|
|
88
|
-
* which is what the surface reports and retries from, so it is not
|
|
89
|
-
* second time here.
|
|
88
|
+
* `undefined` means the create did not land. The failure itself is on
|
|
89
|
+
* `isError`, which is what the surface reports and retries from, so it is not
|
|
90
|
+
* raised a second time here.
|
|
90
91
|
*/
|
|
91
92
|
const createFilterAsync = useCallback(
|
|
92
93
|
async (
|
|
93
94
|
draft: OrganizeDraft,
|
|
94
95
|
scope: Extract<OrganizeScope, "standing" | "temporary">,
|
|
95
96
|
name: string,
|
|
96
|
-
): Promise<
|
|
97
|
-
if (!accountId) return
|
|
97
|
+
): Promise<RemitImapCreateFilterResponse | undefined> => {
|
|
98
|
+
if (!accountId) return undefined;
|
|
98
99
|
return mutateAsync({
|
|
99
100
|
path: { accountId },
|
|
100
101
|
body: buildCreateFilterInput(draft, scope, name),
|
|
101
102
|
}).then(
|
|
102
|
-
() =>
|
|
103
|
-
() =>
|
|
103
|
+
(created) => created,
|
|
104
|
+
() => undefined,
|
|
104
105
|
);
|
|
105
106
|
},
|
|
106
107
|
[accountId, mutateAsync],
|
|
@@ -109,6 +110,7 @@ export const useCreateFilter = (accountId: string | undefined) => {
|
|
|
109
110
|
return {
|
|
110
111
|
createFilter,
|
|
111
112
|
createFilterAsync,
|
|
113
|
+
data: mutation.data,
|
|
112
114
|
isPending: mutation.isPending,
|
|
113
115
|
isSuccess: mutation.isSuccess,
|
|
114
116
|
isError: mutation.isError,
|
|
@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|
|
7
7
|
import { useCallback, useState } from "react";
|
|
8
8
|
import {
|
|
9
9
|
buildOrganizeInput,
|
|
10
|
+
buildRunFilterInput,
|
|
10
11
|
type OrganizeDraft,
|
|
11
12
|
} from "@/lib/organize/organize-model";
|
|
12
13
|
import {
|
|
@@ -92,6 +93,18 @@ export const useOrganizeJob = (accountId: string | undefined) => {
|
|
|
92
93
|
[accountId, createJob],
|
|
93
94
|
);
|
|
94
95
|
|
|
96
|
+
/** Run a saved filter over the inbox, the same pass its create queued. */
|
|
97
|
+
const startForFilter = useCallback(
|
|
98
|
+
(filterId: string) => {
|
|
99
|
+
if (!accountId) return;
|
|
100
|
+
createJob({
|
|
101
|
+
path: { accountId },
|
|
102
|
+
body: buildRunFilterInput(filterId),
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
[accountId, createJob],
|
|
106
|
+
);
|
|
107
|
+
|
|
95
108
|
const { refetch } = jobQuery;
|
|
96
109
|
// Looks at the job already in flight again. Distinct from `start`, which
|
|
97
110
|
// queues a second pass over the same mail.
|
|
@@ -117,6 +130,8 @@ export const useOrganizeJob = (accountId: string | undefined) => {
|
|
|
117
130
|
|
|
118
131
|
return {
|
|
119
132
|
start,
|
|
133
|
+
startForFilter,
|
|
134
|
+
watch: setOrganizeJobId,
|
|
120
135
|
refreshStatus,
|
|
121
136
|
progress,
|
|
122
137
|
isStarting: createMutation.isPending,
|
package/src/lib/filter-report.ts
CHANGED
|
@@ -8,3 +8,12 @@ export function filterToggleReportHref(message: string): string {
|
|
|
8
8
|
}),
|
|
9
9
|
);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
export function filterRunReportHref(message: string): string {
|
|
13
|
+
return buildGitHubIssueUrl(
|
|
14
|
+
buildBugReportContext({
|
|
15
|
+
title: `Filter run now: ${message}`,
|
|
16
|
+
errorMessage: message,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { type FilterRunReading, filterRunStatus } from "./filter-run";
|
|
4
|
+
|
|
5
|
+
const reading = (over: Partial<FilterRunReading> = {}): FilterRunReading => ({
|
|
6
|
+
progress: {
|
|
7
|
+
state: undefined,
|
|
8
|
+
matchedCount: 0,
|
|
9
|
+
appliedCount: 0,
|
|
10
|
+
failedCount: 0,
|
|
11
|
+
errorMessage: "",
|
|
12
|
+
},
|
|
13
|
+
failure: undefined,
|
|
14
|
+
isStarting: false,
|
|
15
|
+
isRunning: false,
|
|
16
|
+
isDone: false,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("filterRunStatus (#1354)", () => {
|
|
21
|
+
it("is nothing before a run", () => {
|
|
22
|
+
assert.equal(filterRunStatus(reading()), undefined);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is queuing while the request is out, then running", () => {
|
|
26
|
+
assert.deepEqual(filterRunStatus(reading({ isStarting: true })), {
|
|
27
|
+
kind: "queuing",
|
|
28
|
+
});
|
|
29
|
+
assert.deepEqual(filterRunStatus(reading({ isRunning: true })), {
|
|
30
|
+
kind: "running",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is a failure carrying the worker's reason when the job failed", () => {
|
|
35
|
+
const status = filterRunStatus(
|
|
36
|
+
reading({
|
|
37
|
+
isDone: true,
|
|
38
|
+
progress: {
|
|
39
|
+
state: "Failed",
|
|
40
|
+
matchedCount: 0,
|
|
41
|
+
appliedCount: 0,
|
|
42
|
+
failedCount: 0,
|
|
43
|
+
errorMessage: "This filter is turned off or has expired.",
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
assert.equal(status?.kind, "failed");
|
|
48
|
+
assert.match(
|
|
49
|
+
String(status?.kind === "failed" && (status.error as Error).message),
|
|
50
|
+
/turned off/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("is a failure when the request itself failed", () => {
|
|
55
|
+
const error = new Error("offline");
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
filterRunStatus(reading({ failure: { kind: "startFailed", error } })),
|
|
58
|
+
{ kind: "failed", error },
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is done with the counts of a completed job", () => {
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
filterRunStatus(
|
|
65
|
+
reading({
|
|
66
|
+
isDone: true,
|
|
67
|
+
progress: {
|
|
68
|
+
state: "Complete",
|
|
69
|
+
matchedCount: 4,
|
|
70
|
+
appliedCount: 3,
|
|
71
|
+
failedCount: 1,
|
|
72
|
+
errorMessage: "",
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
{ kind: "done", matched: 4, applied: 3 },
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OrganizeJobFailure,
|
|
3
|
+
OrganizeJobProgress,
|
|
4
|
+
} from "@/hooks/useOrganizeJob";
|
|
5
|
+
|
|
6
|
+
/** Where a Run now stands, as the filter's row says it. */
|
|
7
|
+
export type FilterRunStatus =
|
|
8
|
+
| { kind: "queuing" }
|
|
9
|
+
| { kind: "running" }
|
|
10
|
+
| { kind: "done"; matched: number; applied: number }
|
|
11
|
+
| { kind: "failed"; error: unknown };
|
|
12
|
+
|
|
13
|
+
export interface FilterRunReading {
|
|
14
|
+
progress: OrganizeJobProgress;
|
|
15
|
+
failure: OrganizeJobFailure | undefined;
|
|
16
|
+
isStarting: boolean;
|
|
17
|
+
isRunning: boolean;
|
|
18
|
+
isDone: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The row's status for the job a Run now started. A job the worker failed —
|
|
23
|
+
* a filter turned off since, or deleted — is a failure like a refused start,
|
|
24
|
+
* never a lingering "running".
|
|
25
|
+
*/
|
|
26
|
+
export const filterRunStatus = (
|
|
27
|
+
reading: FilterRunReading,
|
|
28
|
+
): FilterRunStatus | undefined => {
|
|
29
|
+
if (reading.failure) return { kind: "failed", error: reading.failure.error };
|
|
30
|
+
if (reading.isStarting) return { kind: "queuing" };
|
|
31
|
+
if (reading.isDone) {
|
|
32
|
+
if (reading.progress.state === "Failed") {
|
|
33
|
+
return {
|
|
34
|
+
kind: "failed",
|
|
35
|
+
error: new Error(
|
|
36
|
+
reading.progress.errorMessage || "The run over the inbox failed.",
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
kind: "done",
|
|
42
|
+
matched: reading.progress.matchedCount,
|
|
43
|
+
applied: reading.progress.appliedCount,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (reading.isRunning) return { kind: "running" };
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
@@ -73,15 +73,19 @@ export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
|
|
|
73
73
|
(draft.labelId !== undefined && draft.labelId !== NO_ACTION);
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* projection carries no body and rejects it (`bodyContentRejection`,
|
|
80
|
-
* organize.ts). A rule that carries one is still a valid standing filter for
|
|
81
|
-
* incoming mail, so the back-apply is skipped, not run into that guard.
|
|
76
|
+
* Ask for a saved filter's pass over the inbox. The server reads the filter's
|
|
77
|
+
* own rule and action when the job runs and ignores the predicate fields,
|
|
78
|
+
* which the request shape still requires.
|
|
82
79
|
*/
|
|
83
|
-
export const
|
|
84
|
-
|
|
80
|
+
export const buildRunFilterInput = (
|
|
81
|
+
filterId: string,
|
|
82
|
+
): RemitImapOrganizeInput => ({
|
|
83
|
+
filterId,
|
|
84
|
+
matchOperator: "And",
|
|
85
|
+
literalClauses: [],
|
|
86
|
+
actionLabelId: NO_ACTION,
|
|
87
|
+
actionMailboxId: NO_ACTION,
|
|
88
|
+
});
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
91
|
* Build the read-only preview / back-apply matcher input. The action fields do
|
|
@@ -13,6 +13,11 @@ export interface WizardSelectionMessage extends WizardMessage {
|
|
|
13
13
|
* which every account of one user shares (#456).
|
|
14
14
|
*/
|
|
15
15
|
accountId: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* The folder the row sits in, when the listing says. A saved rule's first
|
|
18
|
+
* pass runs over the inbox, so a row elsewhere takes the action directly.
|
|
19
|
+
*/
|
|
20
|
+
mailboxId: string | undefined;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -34,4 +39,5 @@ export const wizardSelectionFrom = (
|
|
|
34
39
|
subject: row.subject,
|
|
35
40
|
date: row.timeLabel,
|
|
36
41
|
accountId: row.accountId,
|
|
42
|
+
mailboxId: row.mailboxId,
|
|
37
43
|
}));
|
|
@@ -7,9 +7,9 @@ import { type LabelOption, SettingsShell } from "@remit/ui";
|
|
|
7
7
|
import { useQuery } from "@tanstack/react-query";
|
|
8
8
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
9
9
|
import { useCallback, useMemo, useState } from "react";
|
|
10
|
+
import { FilterActionError } from "@/components/settings/FilterActionError";
|
|
10
11
|
import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
|
|
11
12
|
import { FiltersList } from "@/components/settings/FiltersList";
|
|
12
|
-
import { FilterToggleError } from "@/components/settings/FilterToggleError";
|
|
13
13
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
14
14
|
import {
|
|
15
15
|
useDeleteFilter,
|
|
@@ -18,9 +18,14 @@ import {
|
|
|
18
18
|
} from "@/hooks/useFilters";
|
|
19
19
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
20
20
|
import { useLabelList } from "@/hooks/useLabels";
|
|
21
|
-
import {
|
|
21
|
+
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
22
|
+
import {
|
|
23
|
+
filterRunReportHref,
|
|
24
|
+
filterToggleReportHref,
|
|
25
|
+
} from "@/lib/filter-report";
|
|
22
26
|
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
23
27
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
28
|
+
import { filterRunStatus } from "@/lib/organize/filter-run";
|
|
24
29
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
25
30
|
|
|
26
31
|
export const Route = createFileRoute("/settings/filters")({
|
|
@@ -55,6 +60,17 @@ export function AccountFilters({
|
|
|
55
60
|
useFilterList(accountId);
|
|
56
61
|
const { deleteFilter, deletingFilterId } = useDeleteFilter(accountId);
|
|
57
62
|
const toggle = useToggleFilter(accountId);
|
|
63
|
+
const run = useOrganizeJob(accountId);
|
|
64
|
+
const { startForFilter } = run;
|
|
65
|
+
const [runFilterId, setRunFilterId] = useState<string | undefined>();
|
|
66
|
+
const runNow = useCallback(
|
|
67
|
+
(filterId: string) => {
|
|
68
|
+
setRunFilterId(filterId);
|
|
69
|
+
startForFilter(filterId);
|
|
70
|
+
},
|
|
71
|
+
[startForFilter],
|
|
72
|
+
);
|
|
73
|
+
const runStatus = filterRunStatus(run);
|
|
58
74
|
const [editingFilterId, setEditingFilterId] = useState<string | undefined>();
|
|
59
75
|
|
|
60
76
|
const { data: mailboxesData } = useQuery({
|
|
@@ -108,6 +124,9 @@ export function AccountFilters({
|
|
|
108
124
|
const editingFilter = filters.find(
|
|
109
125
|
(filter) => filter.filterId === editingFilterId,
|
|
110
126
|
);
|
|
127
|
+
const runName = filters.find(
|
|
128
|
+
(filter) => filter.filterId === runFilterId,
|
|
129
|
+
)?.name;
|
|
111
130
|
|
|
112
131
|
return (
|
|
113
132
|
<section className="space-y-2">
|
|
@@ -141,13 +160,29 @@ export function AccountFilters({
|
|
|
141
160
|
) : (
|
|
142
161
|
<>
|
|
143
162
|
{toggle.isError && (
|
|
144
|
-
<
|
|
145
|
-
|
|
163
|
+
<FilterActionError
|
|
164
|
+
title={
|
|
165
|
+
toggle.enabling
|
|
166
|
+
? "Couldn't turn the filter on"
|
|
167
|
+
: "Couldn't turn the filter off"
|
|
168
|
+
}
|
|
146
169
|
error={toggle.error}
|
|
147
170
|
onRetry={toggle.retry}
|
|
148
171
|
reportHref={filterToggleReportHref}
|
|
149
172
|
/>
|
|
150
173
|
)}
|
|
174
|
+
{runFilterId && runStatus?.kind === "failed" && (
|
|
175
|
+
<FilterActionError
|
|
176
|
+
title={`Couldn't run ${runName ?? "the filter"} over the inbox`}
|
|
177
|
+
error={runStatus.error}
|
|
178
|
+
onRetry={
|
|
179
|
+
run.failure?.kind === "statusUnreadable"
|
|
180
|
+
? run.refreshStatus
|
|
181
|
+
: () => runNow(runFilterId)
|
|
182
|
+
}
|
|
183
|
+
reportHref={filterRunReportHref}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
151
186
|
<FiltersList
|
|
152
187
|
filters={filters}
|
|
153
188
|
mailboxName={mailboxName}
|
|
@@ -157,6 +192,9 @@ export function AccountFilters({
|
|
|
157
192
|
deletingFilterId={deletingFilterId}
|
|
158
193
|
onToggle={toggle.toggleFilter}
|
|
159
194
|
togglingFilterId={toggle.togglingFilterId}
|
|
195
|
+
onRunNow={runNow}
|
|
196
|
+
runFilterId={runFilterId}
|
|
197
|
+
runStatus={runStatus}
|
|
160
198
|
/>
|
|
161
199
|
</>
|
|
162
200
|
)}
|