@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.317",
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": {
@@ -515,6 +515,7 @@ export const MessageList = ({
515
515
  subject: thread.subject ?? "(No subject)",
516
516
  date: formatEmailDate(thread.sentDate),
517
517
  accountId,
518
+ mailboxId: thread.mailboxId,
518
519
  });
519
520
  }
520
521
  return rows;
@@ -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 moves the mail that already matches, not only
756
- // the mail that arrives next. The pass is chained to the create's own
757
- // request rather than to this screen: the screen offers a Close while
758
- // the create is still in flight, and a rule that saved with no pass
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 && backApply) startJob(backApply);
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 (!backApplyDraft) return { ...NOT_STARTED, state: "filterSaved" };
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: backApplyDraft !== undefined,
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 (backApplyDraft) startJob(backApplyDraft);
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 { FilterToggleError } from "./FilterToggleError";
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(FilterToggleError, {
13
- enabling: true,
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("FilterToggleError (#1103)", () => {
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(&#x27;|')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(&#x27;|')t run Invoices/);
43
+ assert.match(html, /turned off or has expired/);
44
+ assert.match(html, /Report an issue/);
45
+ });
36
46
  });
@@ -1,10 +1,10 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { ApiError } from "@/lib/api";
3
- import { FilterToggleError } from "./FilterToggleError";
3
+ import { FilterActionError } from "./FilterActionError";
4
4
 
5
- const meta: Meta<typeof FilterToggleError> = {
6
- title: "Playground/Shipped/Settings/Filters/FilterToggleError",
7
- component: FilterToggleError,
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 FilterToggleError>;
23
+ type Story = StoryObj<typeof FilterActionError>;
24
24
 
25
25
  export const RefusedWithoutFolder: Story = {
26
26
  args: {
27
- enabling: true,
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
- enabling: false,
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 FilterToggleErrorProps {
5
- enabling: boolean;
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 FilterToggleError({
17
- enabling,
16
+ export function FilterActionError({
17
+ title,
18
18
  error,
19
19
  onRetry,
20
20
  reportHref,
21
- }: FilterToggleErrorProps) {
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"
@@ -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
- * `false` means the create did not land. The failure itself is on `isError`,
88
- * which is what the surface reports and retries from, so it is not raised a
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<boolean> => {
97
- if (!accountId) return false;
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
- () => true,
103
- () => false,
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,
@@ -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
- * Whether the draft's predicate can be back-applied over the existing corpus. A
77
- * `HasWords` clause matches the full message body, which only the live
78
- * index-time filter and the vector widen evaluate — the vector-free back-apply
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 canBackApplyDraft = (draft: OrganizeDraft): boolean =>
84
- !draft.literalClauses.some((clause) => clause.field === "HasWords");
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 { filterToggleReportHref } from "@/lib/filter-report";
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
- <FilterToggleError
145
- enabling={toggle.enabling}
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
  )}