@remit/web-client 0.0.306 → 0.0.308
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/calendar/AgendaView.stories.tsx +2 -6
- package/src/components/calendar/OpenCalendarEvent.stories.tsx +1 -1
- package/src/components/calendar/calendar-app.stories.tsx +119 -0
- package/src/components/calendar/calendar-story-fixtures.ts +0 -20
- package/src/components/config-import/steps.tsx +7 -6
- package/src/components/mail/DraftsView.tsx +3 -7
- package/src/components/mail/OutboxPane.tsx +14 -22
- package/src/components/settings/FilterEditor.render.test.ts +1 -0
- package/src/components/settings/FilterToggleError.render.test.ts +36 -0
- package/src/components/settings/FilterToggleError.stories.tsx +40 -0
- package/src/components/settings/FilterToggleError.tsx +44 -0
- package/src/components/settings/FiltersList.render.test.ts +42 -0
- package/src/components/settings/FiltersList.stories.tsx +89 -0
- package/src/components/settings/FiltersList.tsx +26 -2
- package/src/components/settings/settings-filter.stories.tsx +1 -0
- package/src/hooks/useFilters.ts +39 -0
- package/src/lib/filter-report.ts +10 -0
- package/src/lib/organize/filter-edit-model.test.ts +1 -0
- package/src/lib/organize/filter-status.test.ts +21 -0
- package/src/lib/organize/filter-status.ts +34 -5
- package/src/mocks/story-frame/calendar-window.ts +21 -0
- package/src/mocks/story-frame/mail-handlers.ts +21 -3
- package/src/mocks/story-frame/mail-world.ts +9 -0
- package/src/routes/settings/-filters.test.ts +1 -0
- package/src/routes/settings/filters.tsx +32 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.308",
|
|
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": {
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import type { Density } from "@remit/ui";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
3
|
import { expect, within } from "storybook/test";
|
|
4
|
+
import { instancesWithin } from "@/mocks/story-frame/calendar-window";
|
|
4
5
|
import { AgendaView } from "./AgendaView";
|
|
5
|
-
import {
|
|
6
|
-
calendars,
|
|
7
|
-
fortnight,
|
|
8
|
-
instancesWithin,
|
|
9
|
-
STORY_DATE,
|
|
10
|
-
} from "./calendar-story-fixtures";
|
|
6
|
+
import { calendars, fortnight, STORY_DATE } from "./calendar-story-fixtures";
|
|
11
7
|
import {
|
|
12
8
|
type CalendarServer,
|
|
13
9
|
CalendarStory,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { instancesWithin } from "@/mocks/story-frame/calendar-window";
|
|
3
4
|
import { useOpenCalendarEvent } from "@/routing";
|
|
4
5
|
import {
|
|
5
6
|
calendars,
|
|
6
7
|
fortnight,
|
|
7
|
-
instancesWithin,
|
|
8
8
|
ROADMAP_OBJECT,
|
|
9
9
|
roadmapResource,
|
|
10
10
|
STANDUP_OBJECT,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { AppStory } from "@/mocks/story-frame/AppStory";
|
|
4
|
+
import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
|
|
5
|
+
import { mailWorld } from "@/mocks/story-frame/mail-world";
|
|
6
|
+
import {
|
|
7
|
+
calendars,
|
|
8
|
+
fortnight,
|
|
9
|
+
ROADMAP_OBJECT,
|
|
10
|
+
roadmapResource,
|
|
11
|
+
STANDUP_OBJECT,
|
|
12
|
+
STANDUP_RECURRENCE,
|
|
13
|
+
STORY_DATE,
|
|
14
|
+
STORY_WEEK,
|
|
15
|
+
standupResource,
|
|
16
|
+
} from "./calendar-story-fixtures";
|
|
17
|
+
|
|
18
|
+
const world = mailWorld({
|
|
19
|
+
calendars,
|
|
20
|
+
calendarEvents: fortnight,
|
|
21
|
+
calendarResources: [roadmapResource, standupResource],
|
|
22
|
+
});
|
|
23
|
+
const desktop = { viewport: { value: "desktop", isRotated: false } };
|
|
24
|
+
const phone = { viewport: { value: "mobile", isRotated: false } };
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Playground/Shipped/Calendar/App",
|
|
28
|
+
component: AppStory,
|
|
29
|
+
args: { url: STORY_WEEK },
|
|
30
|
+
parameters: {
|
|
31
|
+
layout: "fullscreen",
|
|
32
|
+
msw: { handlers: mailHandlers(world) },
|
|
33
|
+
},
|
|
34
|
+
globals: desktop,
|
|
35
|
+
} satisfies Meta<typeof AppStory>;
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>;
|
|
39
|
+
|
|
40
|
+
const page = () => within(document.body);
|
|
41
|
+
|
|
42
|
+
export const Week: Story = {
|
|
43
|
+
play: async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
(await page().findAllByText("Roadmap review"))[0],
|
|
46
|
+
).toBeVisible();
|
|
47
|
+
await expect(
|
|
48
|
+
(await page().findAllByRole("link", { name: "Calendar" }))[0],
|
|
49
|
+
).toBeVisible();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Empty: Story = {
|
|
54
|
+
parameters: { msw: { handlers: mailHandlers(mailWorld()) } },
|
|
55
|
+
play: async () => {
|
|
56
|
+
await expect(
|
|
57
|
+
(await page().findAllByRole("link", { name: "Calendar" }))[0],
|
|
58
|
+
).toBeVisible();
|
|
59
|
+
await expect(page().queryByText("Roadmap review")).toBeNull();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const OpenEvent: Story = {
|
|
64
|
+
args: { url: `${STORY_WEEK}/${ROADMAP_OBJECT}` },
|
|
65
|
+
play: async () => {
|
|
66
|
+
await expect(await page().findByText("Room Zuid")).toBeVisible();
|
|
67
|
+
await expect(
|
|
68
|
+
await page().findByRole("button", { name: "Edit" }),
|
|
69
|
+
).toBeVisible();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const RecurrenceScope: Story = {
|
|
74
|
+
args: { url: `${STORY_WEEK}/${STANDUP_OBJECT}/${STANDUP_RECURRENCE}` },
|
|
75
|
+
play: async () => {
|
|
76
|
+
await userEvent.click(await page().findByRole("button", { name: "Edit" }));
|
|
77
|
+
await expect(
|
|
78
|
+
await page().findByText("What should the change apply to?"),
|
|
79
|
+
).toBeVisible();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const NewEvent: Story = {
|
|
84
|
+
args: { url: `${STORY_WEEK}/new` },
|
|
85
|
+
play: async () => {
|
|
86
|
+
await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
|
|
87
|
+
await expect(page().getByLabelText("Title")).toHaveValue("");
|
|
88
|
+
await expect(
|
|
89
|
+
await page().findByRole("radio", { name: "Northwind" }),
|
|
90
|
+
).toBeChecked();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const PhoneNewEvent: Story = {
|
|
95
|
+
args: { url: `/calendar/day/${STORY_DATE}/new` },
|
|
96
|
+
globals: phone,
|
|
97
|
+
play: async () => {
|
|
98
|
+
await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
|
|
99
|
+
await expect(page().getByLabelText("Title")).toHaveValue("");
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const PhoneDay: Story = {
|
|
104
|
+
args: { url: `/calendar/day/${STORY_DATE}` },
|
|
105
|
+
globals: phone,
|
|
106
|
+
play: async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
(await page().findAllByText("Roadmap review"))[0],
|
|
109
|
+
).toBeVisible();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const PhoneEvent: Story = {
|
|
114
|
+
args: { url: `/calendar/day/${STORY_DATE}/${ROADMAP_OBJECT}` },
|
|
115
|
+
globals: phone,
|
|
116
|
+
play: async () => {
|
|
117
|
+
await expect(await page().findByText("Room Zuid")).toBeVisible();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -116,23 +116,3 @@ export const standupResource = {
|
|
|
116
116
|
"END:VCALENDAR",
|
|
117
117
|
].join("\r\n"),
|
|
118
118
|
} as RemitImapCalendarEventResponse;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The occurrences inside the window a request asked for.
|
|
122
|
-
*
|
|
123
|
-
* The panes read a week at a time and hold several weeks at once, so a server
|
|
124
|
-
* that answered every window with the same list would draw each day's events
|
|
125
|
-
* once per window it appears in.
|
|
126
|
-
*/
|
|
127
|
-
export const instancesWithin = (
|
|
128
|
-
url: URL,
|
|
129
|
-
instances: readonly RemitImapCalendarEventInstance[],
|
|
130
|
-
): RemitImapCalendarEventInstance[] => {
|
|
131
|
-
const from = Date.parse(url.searchParams.get("from") ?? "");
|
|
132
|
-
const to = Date.parse(url.searchParams.get("to") ?? "");
|
|
133
|
-
if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
|
|
134
|
-
return instances.filter((instance) => {
|
|
135
|
-
const start = Date.parse(instance.start);
|
|
136
|
-
return start >= from && start < to;
|
|
137
|
-
});
|
|
138
|
-
};
|
|
@@ -621,10 +621,10 @@ export function StepPendingFolders({
|
|
|
621
621
|
<WizardShell
|
|
622
622
|
steps={IMPORT_STEPS}
|
|
623
623
|
activeStep={3}
|
|
624
|
-
title={folders.length > 0 ? "
|
|
624
|
+
title={folders.length > 0 ? "Creating folders" : "Import finished"}
|
|
625
625
|
subtitle={
|
|
626
626
|
folders.length > 0
|
|
627
|
-
? "The config names folders this
|
|
627
|
+
? "The config names folders this account does not have yet. Reader creates them on the mail server."
|
|
628
628
|
: "Everything in the file landed, and every folder it named resolved."
|
|
629
629
|
}
|
|
630
630
|
footer={
|
|
@@ -642,10 +642,11 @@ export function StepPendingFolders({
|
|
|
642
642
|
{folders.length > 0 ? (
|
|
643
643
|
<>
|
|
644
644
|
<Banner tone="warning">
|
|
645
|
-
{folders.length} settings are imported but switched off.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
645
|
+
{folders.length} settings are imported but switched off. Reader
|
|
646
|
+
creates each missing folder on the mail server the next time the
|
|
647
|
+
account syncs, and turns the setting on once the server confirms
|
|
648
|
+
it. A rule whose folder cannot be created stays off, and Settings
|
|
649
|
+
→ Filters says why.
|
|
649
650
|
</Banner>
|
|
650
651
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
651
652
|
<FolderClock className="size-4" />
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
ComfortableRowTextContent,
|
|
33
33
|
cn,
|
|
34
34
|
comfortableRowClass,
|
|
35
|
+
PaneHeader,
|
|
35
36
|
type ThreadRowData,
|
|
36
37
|
type ThreadSection,
|
|
37
38
|
} from "@remit/ui";
|
|
@@ -194,18 +195,13 @@ export function DraftsView({
|
|
|
194
195
|
|
|
195
196
|
return (
|
|
196
197
|
<section className="flex h-full w-full flex-col bg-surface">
|
|
197
|
-
{
|
|
198
|
-
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
199
|
-
<NavMenuButton />
|
|
200
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
201
|
-
{title}
|
|
202
|
-
</h1>
|
|
198
|
+
<PaneHeader title={title} leading={<NavMenuButton />}>
|
|
203
199
|
{(unreadCount ?? 0) > 0 && (
|
|
204
200
|
<span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
|
|
205
201
|
{unreadCount} unread
|
|
206
202
|
</span>
|
|
207
203
|
)}
|
|
208
|
-
</
|
|
204
|
+
</PaneHeader>
|
|
209
205
|
|
|
210
206
|
{/* Scrollable body */}
|
|
211
207
|
<div className="flex-1 overflow-y-auto">
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
cn,
|
|
27
27
|
LIST_ROW_SELECTOR,
|
|
28
28
|
OutboxRow,
|
|
29
|
+
PaneHeader,
|
|
29
30
|
ReadingPaneEmpty,
|
|
30
31
|
useRovingFocus,
|
|
31
32
|
} from "@remit/ui";
|
|
@@ -458,18 +459,7 @@ function OutboxList() {
|
|
|
458
459
|
|
|
459
460
|
return (
|
|
460
461
|
<div className="h-full flex flex-col bg-canvas">
|
|
461
|
-
{
|
|
462
|
-
the bottom hairline on the same y as the mailbox view so the grid
|
|
463
|
-
line stays continuous across nav → outbox (no staircase, #422). */}
|
|
464
|
-
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
465
|
-
<NavMenuButton />
|
|
466
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
467
|
-
Outbox
|
|
468
|
-
</h1>
|
|
469
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
470
|
-
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
471
|
-
</span>
|
|
472
|
-
</header>
|
|
462
|
+
<OutboxListHeader count={messages.length} />
|
|
473
463
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
474
464
|
{messages.map((message) => (
|
|
475
465
|
<OutboxMessageRow
|
|
@@ -484,6 +474,16 @@ function OutboxList() {
|
|
|
484
474
|
);
|
|
485
475
|
}
|
|
486
476
|
|
|
477
|
+
function OutboxListHeader({ count }: { count: number }) {
|
|
478
|
+
return (
|
|
479
|
+
<PaneHeader title="Outbox" leading={<NavMenuButton />}>
|
|
480
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
481
|
+
{count} {count === 1 ? "message" : "messages"}
|
|
482
|
+
</span>
|
|
483
|
+
</PaneHeader>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
487
|
/**
|
|
488
488
|
* Outbox reading pane. Mount in the `reading` slot of `AppShellSlotted`.
|
|
489
489
|
* Only rendered ≥ 1024px.
|
|
@@ -496,7 +496,7 @@ function OutboxReading() {
|
|
|
496
496
|
{/* Detail datum bar (40px): outbox has no message-action toolbar,
|
|
497
497
|
but the datum bar must be present so its bottom hairline lines
|
|
498
498
|
up with the list pane's, keeping the grid continuous (#422). */}
|
|
499
|
-
<
|
|
499
|
+
<PaneHeader />
|
|
500
500
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
501
501
|
{selectedMessage ? (
|
|
502
502
|
<OutboxMessageDetail message={selectedMessage} />
|
|
@@ -556,15 +556,7 @@ function OutboxPhone() {
|
|
|
556
556
|
|
|
557
557
|
return (
|
|
558
558
|
<div className="h-full flex flex-col bg-canvas">
|
|
559
|
-
<
|
|
560
|
-
<NavMenuButton />
|
|
561
|
-
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
562
|
-
Outbox
|
|
563
|
-
</h1>
|
|
564
|
-
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
565
|
-
{messages.length} {messages.length === 1 ? "message" : "messages"}
|
|
566
|
-
</span>
|
|
567
|
-
</header>
|
|
559
|
+
<OutboxListHeader count={messages.length} />
|
|
568
560
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
569
561
|
{messages.map((message) => (
|
|
570
562
|
<OutboxMessageRow
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import React, { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { ApiError } from "@/lib/api";
|
|
6
|
+
import { FilterToggleError } from "./FilterToggleError";
|
|
7
|
+
|
|
8
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
9
|
+
|
|
10
|
+
const render = (error: unknown) =>
|
|
11
|
+
renderToString(
|
|
12
|
+
createElement(FilterToggleError, {
|
|
13
|
+
enabling: true,
|
|
14
|
+
error,
|
|
15
|
+
onRetry: () => undefined,
|
|
16
|
+
reportHref: (message: string) =>
|
|
17
|
+
`https://example.test/new?body=${encodeURIComponent(message)}`,
|
|
18
|
+
}) as never,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
describe("FilterToggleError (#1103)", () => {
|
|
22
|
+
it("shows a refusal with its fix and a report link, and no Retry", () => {
|
|
23
|
+
const html = render(new ApiError("Pick a folder in the rule.", 400));
|
|
24
|
+
assert.match(html, /Couldn('|')t turn the filter on/);
|
|
25
|
+
assert.match(html, /Pick a folder in the rule\./);
|
|
26
|
+
assert.match(html, /Report an issue/);
|
|
27
|
+
assert.match(html, /example\.test\/new\?body=Pick/);
|
|
28
|
+
assert.doesNotMatch(html, />Retry</);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("offers Retry when the server did not answer", () => {
|
|
32
|
+
const html = render(new ApiError("Unavailable", 503));
|
|
33
|
+
assert.match(html, />Retry</);
|
|
34
|
+
assert.match(html, /Report an issue/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { ApiError } from "@/lib/api";
|
|
3
|
+
import { FilterToggleError } from "./FilterToggleError";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FilterToggleError> = {
|
|
6
|
+
title: "Playground/Shipped/Settings/Filters/FilterToggleError",
|
|
7
|
+
component: FilterToggleError,
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="mx-auto max-w-md">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
args: {
|
|
17
|
+
onRetry: () => undefined,
|
|
18
|
+
reportHref: () => "https://github.com/remit-mail/reader/issues/new",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof FilterToggleError>;
|
|
24
|
+
|
|
25
|
+
export const RefusedWithoutFolder: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
enabling: true,
|
|
28
|
+
error: new ApiError(
|
|
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
|
+
400,
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ServerUnavailable: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
enabling: false,
|
|
38
|
+
error: new ApiError("The server did not answer.", 503),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
|
|
2
|
+
import { getErrorStatus } from "@/lib/error-classifier";
|
|
3
|
+
|
|
4
|
+
interface FilterToggleErrorProps {
|
|
5
|
+
enabling: boolean;
|
|
6
|
+
error: unknown;
|
|
7
|
+
onRetry: () => void;
|
|
8
|
+
reportHref: (message: string) => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const isRefusal = (error: unknown): boolean => {
|
|
12
|
+
const status = getErrorStatus(error);
|
|
13
|
+
return status !== undefined && status >= 400 && status < 500;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function FilterToggleError({
|
|
17
|
+
enabling,
|
|
18
|
+
error,
|
|
19
|
+
onRetry,
|
|
20
|
+
reportHref,
|
|
21
|
+
}: FilterToggleErrorProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-1">
|
|
24
|
+
<ErrorState
|
|
25
|
+
variant="inline"
|
|
26
|
+
title={
|
|
27
|
+
enabling
|
|
28
|
+
? "Couldn't turn the filter on"
|
|
29
|
+
: "Couldn't turn the filter off"
|
|
30
|
+
}
|
|
31
|
+
error={error}
|
|
32
|
+
onRetry={isRefusal(error) ? undefined : onRetry}
|
|
33
|
+
/>
|
|
34
|
+
<a
|
|
35
|
+
href={reportHref(formatErrorMessage(error))}
|
|
36
|
+
target="_blank"
|
|
37
|
+
rel="noreferrer"
|
|
38
|
+
className="text-xs text-accent hover:underline"
|
|
39
|
+
>
|
|
40
|
+
Report an issue
|
|
41
|
+
</a>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -21,6 +21,7 @@ const filter = (
|
|
|
21
21
|
name: "Travel",
|
|
22
22
|
scope: "Standing",
|
|
23
23
|
state: "Active",
|
|
24
|
+
disabledReason: "None",
|
|
24
25
|
hasAnchor: true,
|
|
25
26
|
ruleChangedAt: 0,
|
|
26
27
|
actionChangedAt: 0,
|
|
@@ -45,6 +46,7 @@ const render = (
|
|
|
45
46
|
labelById,
|
|
46
47
|
onEdit: () => undefined,
|
|
47
48
|
onDelete: () => undefined,
|
|
49
|
+
onToggle: () => undefined,
|
|
48
50
|
semanticUnavailable,
|
|
49
51
|
now: NOW,
|
|
50
52
|
}) as never,
|
|
@@ -111,4 +113,44 @@ describe("FiltersList", () => {
|
|
|
111
113
|
assert.match(html, /Expired/);
|
|
112
114
|
assert.match(html, /expired/);
|
|
113
115
|
});
|
|
116
|
+
|
|
117
|
+
it("offers to turn a running filter off", () => {
|
|
118
|
+
const html = render([filter({})]);
|
|
119
|
+
assert.match(html, /Turn off filter Travel/);
|
|
120
|
+
assert.doesNotMatch(html, /Disabled/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const reasons = [
|
|
124
|
+
["UserDisabled", /Turned off\./],
|
|
125
|
+
["AwaitingFolder", /Off until its folder exists on the mail server/],
|
|
126
|
+
["FolderCreateFailed", /refused to create its folder/],
|
|
127
|
+
["FolderMissing", /no longer exists/],
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
it("drops the turns-on-by-itself copy once the user picked a folder", () => {
|
|
131
|
+
const html = render([
|
|
132
|
+
filter({
|
|
133
|
+
state: "Disabled",
|
|
134
|
+
disabledReason: "AwaitingFolder",
|
|
135
|
+
actionMailboxId: "mbx-travel",
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
assert.doesNotMatch(html, /turns on by itself/);
|
|
139
|
+
assert.match(html, /Turned off\./);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
for (const [reason, copy] of reasons) {
|
|
143
|
+
it(`marks a filter disabled for ${reason} with its reason and a way back on (#1103)`, () => {
|
|
144
|
+
const html = render([
|
|
145
|
+
filter({
|
|
146
|
+
state: "Disabled",
|
|
147
|
+
disabledReason: reason,
|
|
148
|
+
actionMailboxId: "None",
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
assert.match(html, /Disabled/);
|
|
152
|
+
assert.match(html, copy);
|
|
153
|
+
assert.match(html, /Turn on filter Travel/);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
114
156
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { FiltersList } from "./FiltersList";
|
|
4
|
+
|
|
5
|
+
const NOW = Date.parse("2026-09-25T12:00:00Z");
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof FiltersList> = {
|
|
8
|
+
title: "Playground/Shipped/Settings/Filters/FiltersList",
|
|
9
|
+
component: FiltersList,
|
|
10
|
+
parameters: { layout: "padded" },
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="mx-auto max-w-md">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
args: {
|
|
19
|
+
mailboxName: (mailboxId: string) =>
|
|
20
|
+
mailboxId === "mbx-invoices" ? "Invoices" : undefined,
|
|
21
|
+
labelById: new Map(),
|
|
22
|
+
onEdit: () => undefined,
|
|
23
|
+
onDelete: () => undefined,
|
|
24
|
+
onToggle: () => undefined,
|
|
25
|
+
now: NOW,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
type Story = StoryObj<typeof FiltersList>;
|
|
31
|
+
|
|
32
|
+
const makeFilter = (
|
|
33
|
+
overrides: Partial<RemitImapFilterResponse>,
|
|
34
|
+
): RemitImapFilterResponse => ({
|
|
35
|
+
filterId: "flt-1",
|
|
36
|
+
accountConfigId: "acc-1",
|
|
37
|
+
name: "Invoices",
|
|
38
|
+
scope: "Standing",
|
|
39
|
+
state: "Active",
|
|
40
|
+
disabledReason: "None",
|
|
41
|
+
hasAnchor: false,
|
|
42
|
+
ruleChangedAt: 0,
|
|
43
|
+
actionChangedAt: 0,
|
|
44
|
+
matchOperator: "And",
|
|
45
|
+
literalClauses: [{ field: "From", value: "billing@example.com" }],
|
|
46
|
+
actionLabelId: "None",
|
|
47
|
+
actionMailboxId: "mbx-invoices",
|
|
48
|
+
createdAt: 0,
|
|
49
|
+
updatedAt: 0,
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const disabled = (
|
|
54
|
+
reason: RemitImapFilterResponse["disabledReason"],
|
|
55
|
+
overrides: Partial<RemitImapFilterResponse> = {},
|
|
56
|
+
): RemitImapFilterResponse[] => [
|
|
57
|
+
makeFilter({ state: "Disabled", disabledReason: reason, ...overrides }),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export const Active: Story = {
|
|
61
|
+
args: { filters: [makeFilter({})] },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const TurnedOffByUser: Story = {
|
|
65
|
+
args: { filters: disabled("UserDisabled") },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const AwaitingFolder: Story = {
|
|
69
|
+
args: { filters: disabled("AwaitingFolder", { actionMailboxId: "None" }) },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const FolderCreateFailed: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
filters: disabled("FolderCreateFailed", { actionMailboxId: "None" }),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const FolderMissing: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
filters: disabled("FolderMissing", { actionMailboxId: "mbx-deleted" }),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const Toggling: Story = {
|
|
85
|
+
args: {
|
|
86
|
+
filters: disabled("UserDisabled"),
|
|
87
|
+
togglingFilterId: "flt-1",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@remit/ui";
|
|
9
9
|
import { Trash2 } from "lucide-react";
|
|
10
10
|
import {
|
|
11
|
+
disabledReasonCopy,
|
|
11
12
|
filterDisplayStatus,
|
|
12
13
|
formatExpiresAt,
|
|
13
14
|
} from "@/lib/organize/filter-status";
|
|
@@ -23,6 +24,8 @@ interface FiltersListProps {
|
|
|
23
24
|
onEdit: (filterId: string) => void;
|
|
24
25
|
onDelete: (filterId: string) => void;
|
|
25
26
|
deletingFilterId?: string;
|
|
27
|
+
onToggle: (filterId: string, enabled: boolean) => void;
|
|
28
|
+
togglingFilterId?: string;
|
|
26
29
|
/**
|
|
27
30
|
* This deployment ships no vector pipeline (RFC 038 D4). A filter carrying a
|
|
28
31
|
* semantic anchor lists with its widen chip inactive — it matches by its
|
|
@@ -47,6 +50,8 @@ export function FiltersList({
|
|
|
47
50
|
onEdit,
|
|
48
51
|
onDelete,
|
|
49
52
|
deletingFilterId,
|
|
53
|
+
onToggle,
|
|
54
|
+
togglingFilterId,
|
|
50
55
|
semanticUnavailable = false,
|
|
51
56
|
now = Date.now(),
|
|
52
57
|
}: FiltersListProps) {
|
|
@@ -64,6 +69,8 @@ export function FiltersList({
|
|
|
64
69
|
{filters.map((filter) => {
|
|
65
70
|
const status = filterDisplayStatus(filter, now);
|
|
66
71
|
const expired = status === "Expired";
|
|
72
|
+
const disabled = status === "Disabled";
|
|
73
|
+
const reason = disabled ? disabledReasonCopy(filter) : undefined;
|
|
67
74
|
const folder =
|
|
68
75
|
filter.actionMailboxId !== NO_ACTION
|
|
69
76
|
? mailboxName(filter.actionMailboxId)
|
|
@@ -88,12 +95,15 @@ export function FiltersList({
|
|
|
88
95
|
<div className="flex items-center gap-2">
|
|
89
96
|
<span
|
|
90
97
|
className={`truncate text-sm font-medium ${
|
|
91
|
-
expired ? "text-fg-muted" : "text-fg"
|
|
98
|
+
expired || disabled ? "text-fg-muted" : "text-fg"
|
|
92
99
|
}`}
|
|
93
100
|
>
|
|
94
101
|
{filter.name}
|
|
95
102
|
</span>
|
|
96
|
-
<Badge
|
|
103
|
+
<Badge
|
|
104
|
+
tone={expired ? "neutral" : disabled ? "warning" : "positive"}
|
|
105
|
+
dot
|
|
106
|
+
>
|
|
97
107
|
{status}
|
|
98
108
|
</Badge>
|
|
99
109
|
</div>
|
|
@@ -107,6 +117,9 @@ export function FiltersList({
|
|
|
107
117
|
? " · always"
|
|
108
118
|
: ""}
|
|
109
119
|
</p>
|
|
120
|
+
{reason && (
|
|
121
|
+
<p className="mt-0.5 text-xs text-warning">{reason}</p>
|
|
122
|
+
)}
|
|
110
123
|
{(filter.hasAnchor || label) && (
|
|
111
124
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
112
125
|
{filter.hasAnchor && (
|
|
@@ -129,6 +142,17 @@ export function FiltersList({
|
|
|
129
142
|
</div>
|
|
130
143
|
)}
|
|
131
144
|
</button>
|
|
145
|
+
{!expired && (
|
|
146
|
+
<Button
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
onClick={() => onToggle(filter.filterId, disabled)}
|
|
150
|
+
disabled={togglingFilterId === filter.filterId}
|
|
151
|
+
aria-label={`${disabled ? "Turn on" : "Turn off"} filter ${filter.name}`}
|
|
152
|
+
>
|
|
153
|
+
{disabled ? "Turn on" : "Turn off"}
|
|
154
|
+
</Button>
|
|
155
|
+
)}
|
|
132
156
|
<Button
|
|
133
157
|
variant="ghost"
|
|
134
158
|
size="sm"
|
package/src/hooks/useFilters.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
filterDetailOperationsDeleteFilterMutation,
|
|
3
|
+
filterDetailOperationsUpdateFilterMutation,
|
|
3
4
|
filterOperationsCreateFilterMutation,
|
|
4
5
|
filterOperationsListFiltersOptions,
|
|
5
6
|
filterOperationsListFiltersQueryKey,
|
|
@@ -145,3 +146,41 @@ export const useDeleteFilter = (accountId: string | undefined) => {
|
|
|
145
146
|
: undefined,
|
|
146
147
|
};
|
|
147
148
|
};
|
|
149
|
+
|
|
150
|
+
export const useToggleFilter = (accountId: string | undefined) => {
|
|
151
|
+
const queryClient = useQueryClient();
|
|
152
|
+
const mutation = useMutation({
|
|
153
|
+
...filterDetailOperationsUpdateFilterMutation(),
|
|
154
|
+
onSuccess: () => {
|
|
155
|
+
if (!accountId) return;
|
|
156
|
+
queryClient.invalidateQueries({
|
|
157
|
+
queryKey: buildFilterListKey(accountId),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const { mutate, variables } = mutation;
|
|
162
|
+
|
|
163
|
+
const toggleFilter = useCallback(
|
|
164
|
+
(filterId: string, enabled: boolean) => {
|
|
165
|
+
if (!accountId) return;
|
|
166
|
+
mutate({
|
|
167
|
+
path: { accountId, filterId },
|
|
168
|
+
body: { state: enabled ? "Active" : "Disabled" },
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
[accountId, mutate],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const retry = useCallback(() => {
|
|
175
|
+
if (variables) mutate(variables);
|
|
176
|
+
}, [mutate, variables]);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
toggleFilter,
|
|
180
|
+
retry,
|
|
181
|
+
togglingFilterId: mutation.isPending ? variables?.path.filterId : undefined,
|
|
182
|
+
isError: mutation.isError,
|
|
183
|
+
error: mutation.error,
|
|
184
|
+
enabling: variables?.body?.state === "Active",
|
|
185
|
+
};
|
|
186
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { buildBugReportContext, buildGitHubIssueUrl } from "./bug-report";
|
|
2
|
+
|
|
3
|
+
export function filterToggleReportHref(message: string): string {
|
|
4
|
+
return buildGitHubIssueUrl(
|
|
5
|
+
buildBugReportContext({
|
|
6
|
+
title: `Filter toggle: ${message}`,
|
|
7
|
+
errorMessage: message,
|
|
8
|
+
}),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -9,6 +9,27 @@ import {
|
|
|
9
9
|
const NOW = Date.parse("2026-07-12T12:00:00Z");
|
|
10
10
|
|
|
11
11
|
describe("filterDisplayStatus", () => {
|
|
12
|
+
it("marks a disabled filter Disabled (#1103)", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
filterDisplayStatus({ scope: "Standing", state: "Disabled" }, NOW),
|
|
15
|
+
"Disabled",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("marks a disabled temporary filter past its date Expired", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
filterDisplayStatus(
|
|
22
|
+
{
|
|
23
|
+
scope: "Temporary",
|
|
24
|
+
state: "Disabled",
|
|
25
|
+
expiresAt: "2020-01-01T00:00:00Z",
|
|
26
|
+
},
|
|
27
|
+
NOW,
|
|
28
|
+
),
|
|
29
|
+
"Expired",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
12
33
|
it("treats a Standing filter as always Active", () => {
|
|
13
34
|
assert.equal(
|
|
14
35
|
filterDisplayStatus({ scope: "Standing", state: "Active" }, NOW),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
2
|
|
|
3
|
-
export type FilterDisplayStatus = "Active" | "Expired";
|
|
3
|
+
export type FilterDisplayStatus = "Active" | "Expired" | "Disabled";
|
|
4
|
+
|
|
5
|
+
export type FilterDisabledReason = RemitImapFilterResponse["disabledReason"];
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The displayed Active/Expired status of a filter.
|
|
@@ -19,12 +21,39 @@ export const filterDisplayStatus = (
|
|
|
19
21
|
now: number,
|
|
20
22
|
): FilterDisplayStatus => {
|
|
21
23
|
if (filter.state === "Expired") return "Expired";
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
+
const running = filter.state === "Disabled" ? "Disabled" : "Active";
|
|
25
|
+
if (filter.scope !== "Temporary") return running;
|
|
26
|
+
if (!filter.expiresAt) return running;
|
|
24
27
|
|
|
25
28
|
const expiresAtMs = Date.parse(filter.expiresAt);
|
|
26
|
-
if (Number.isNaN(expiresAtMs)) return
|
|
27
|
-
return expiresAtMs <= now ? "Expired" :
|
|
29
|
+
if (Number.isNaN(expiresAtMs)) return running;
|
|
30
|
+
return expiresAtMs <= now ? "Expired" : running;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DISABLED_REASON_COPY: Record<
|
|
34
|
+
Exclude<FilterDisabledReason, "None">,
|
|
35
|
+
string
|
|
36
|
+
> = {
|
|
37
|
+
UserDisabled: "Turned off. Turn it on to run it on new mail again.",
|
|
38
|
+
AwaitingFolder:
|
|
39
|
+
"Off until its folder exists on the mail server. It turns on by itself once the folder is created.",
|
|
40
|
+
FolderCreateFailed:
|
|
41
|
+
"Off: the mail server refused to create its folder. Pick another folder in the rule, then turn it on.",
|
|
42
|
+
FolderMissing:
|
|
43
|
+
"Off: the folder it moves mail into no longer exists. Pick another folder in the rule, then turn it on.",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const disabledReasonCopy = (
|
|
47
|
+
filter: Pick<RemitImapFilterResponse, "disabledReason" | "actionMailboxId">,
|
|
48
|
+
): string | undefined => {
|
|
49
|
+
if (filter.disabledReason === "None") return undefined;
|
|
50
|
+
const folderless =
|
|
51
|
+
filter.disabledReason === "AwaitingFolder" ||
|
|
52
|
+
filter.disabledReason === "FolderCreateFailed";
|
|
53
|
+
if (folderless && filter.actionMailboxId !== "None") {
|
|
54
|
+
return DISABLED_REASON_COPY.UserDisabled;
|
|
55
|
+
}
|
|
56
|
+
return DISABLED_REASON_COPY[filter.disabledReason];
|
|
28
57
|
};
|
|
29
58
|
|
|
30
59
|
/**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The occurrences inside the window a request asked for.
|
|
5
|
+
*
|
|
6
|
+
* The panes read a week at a time and hold several weeks at once, so a server
|
|
7
|
+
* that answered every window with the same list would draw each day's events
|
|
8
|
+
* once per window it appears in.
|
|
9
|
+
*/
|
|
10
|
+
export const instancesWithin = (
|
|
11
|
+
url: URL,
|
|
12
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
13
|
+
): RemitImapCalendarEventInstance[] => {
|
|
14
|
+
const from = Date.parse(url.searchParams.get("from") ?? "");
|
|
15
|
+
const to = Date.parse(url.searchParams.get("to") ?? "");
|
|
16
|
+
if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
|
|
17
|
+
return instances.filter((instance) => {
|
|
18
|
+
const start = Date.parse(instance.start);
|
|
19
|
+
return start >= from && start < to;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AccountDetailOperationsUpdateAccountResponse,
|
|
3
3
|
AddressDetailOperationsUpdateAddressResponse,
|
|
4
4
|
AddressOperationsSearchAddressesResponse,
|
|
5
|
+
CalendarEventDetailOperationsGetCalendarEventResponse,
|
|
5
6
|
CalendarEventOperationsListCalendarEventsResponse,
|
|
6
7
|
CalendarOperationsListCalendarsResponse,
|
|
7
8
|
CalendarSuggestionOperationsListCalendarSuggestionsResponse,
|
|
@@ -35,6 +36,7 @@ import type {
|
|
|
35
36
|
} from "@remit/api-http-client/types.gen.ts";
|
|
36
37
|
import { type HttpHandler, HttpResponse, http } from "msw";
|
|
37
38
|
import { makeConfig } from "@/test-support/fixtures";
|
|
39
|
+
import { instancesWithin } from "./calendar-window";
|
|
38
40
|
import type { MailWorld } from "./mail-world";
|
|
39
41
|
|
|
40
42
|
const API = "/api";
|
|
@@ -487,13 +489,29 @@ export const mailHandlers = (
|
|
|
487
489
|
return new HttpResponse(null, { status: 204 });
|
|
488
490
|
}),
|
|
489
491
|
http.get(`${API}/calendars`, () =>
|
|
490
|
-
HttpResponse.json<CalendarOperationsListCalendarsResponse>({
|
|
492
|
+
HttpResponse.json<CalendarOperationsListCalendarsResponse>({
|
|
493
|
+
items: world.calendars,
|
|
494
|
+
}),
|
|
491
495
|
),
|
|
492
|
-
http.get(`${API}/calendar-events`, () =>
|
|
496
|
+
http.get(`${API}/calendar-events`, ({ request }) =>
|
|
493
497
|
HttpResponse.json<CalendarEventOperationsListCalendarEventsResponse>({
|
|
494
|
-
items:
|
|
498
|
+
items: instancesWithin(new URL(request.url), world.calendarEvents),
|
|
495
499
|
}),
|
|
496
500
|
),
|
|
501
|
+
http.get(`${API}/calendar-events/:calendarObjectId`, ({ params }) => {
|
|
502
|
+
const resource = world.calendarResources.find(
|
|
503
|
+
(candidate) =>
|
|
504
|
+
candidate.calendarObjectId === String(params.calendarObjectId),
|
|
505
|
+
);
|
|
506
|
+
if (!resource)
|
|
507
|
+
return HttpResponse.json(
|
|
508
|
+
{ status: 404, message: "Calendar event not found" },
|
|
509
|
+
{ status: 404 },
|
|
510
|
+
);
|
|
511
|
+
return HttpResponse.json<CalendarEventDetailOperationsGetCalendarEventResponse>(
|
|
512
|
+
resource,
|
|
513
|
+
);
|
|
514
|
+
}),
|
|
497
515
|
http.get(`${API}/calendar-suggestions`, () =>
|
|
498
516
|
HttpResponse.json<CalendarSuggestionOperationsListCalendarSuggestionsResponse>(
|
|
499
517
|
{ items: [] },
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
RemitImapAccountResponse,
|
|
3
3
|
RemitImapAddressResponse,
|
|
4
|
+
RemitImapCalendarEventInstance,
|
|
5
|
+
RemitImapCalendarEventResponse,
|
|
6
|
+
RemitImapCalendarResponse,
|
|
4
7
|
RemitImapCanonicalMailboxRole,
|
|
5
8
|
RemitImapMailboxResponse,
|
|
6
9
|
RemitImapMessageCategory,
|
|
@@ -27,6 +30,9 @@ export interface MailWorld {
|
|
|
27
30
|
addresses: RemitImapAddressResponse[];
|
|
28
31
|
systemUpdate: RemitImapSystemUpdateResponse;
|
|
29
32
|
bodies: Record<string, string>;
|
|
33
|
+
calendars: RemitImapCalendarResponse[];
|
|
34
|
+
calendarEvents: RemitImapCalendarEventInstance[];
|
|
35
|
+
calendarResources: RemitImapCalendarEventResponse[];
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export const MINUTE = 60_000;
|
|
@@ -429,5 +435,8 @@ export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
|
|
|
429
435
|
run: null,
|
|
430
436
|
},
|
|
431
437
|
bodies: {},
|
|
438
|
+
calendars: [],
|
|
439
|
+
calendarEvents: [],
|
|
440
|
+
calendarResources: [],
|
|
432
441
|
...overrides,
|
|
433
442
|
});
|
|
@@ -9,10 +9,16 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
|
9
9
|
import { useCallback, useMemo, useState } from "react";
|
|
10
10
|
import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
|
|
11
11
|
import { FiltersList } from "@/components/settings/FiltersList";
|
|
12
|
+
import { FilterToggleError } from "@/components/settings/FilterToggleError";
|
|
12
13
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
useDeleteFilter,
|
|
16
|
+
useFilterList,
|
|
17
|
+
useToggleFilter,
|
|
18
|
+
} from "@/hooks/useFilters";
|
|
14
19
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
15
20
|
import { useLabelList } from "@/hooks/useLabels";
|
|
21
|
+
import { filterToggleReportHref } from "@/lib/filter-report";
|
|
16
22
|
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
17
23
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
18
24
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
@@ -32,6 +38,10 @@ const filtersHelp = (
|
|
|
32
38
|
An expired filter isn't deleted the instant it lapses — it stays here,
|
|
33
39
|
marked Expired, so you can see what it did and when it stopped.
|
|
34
40
|
</p>
|
|
41
|
+
<p>
|
|
42
|
+
A filter that can't do its job turns itself off and says why, for example
|
|
43
|
+
when its folder no longer exists. Turn it back on once that is fixed.
|
|
44
|
+
</p>
|
|
35
45
|
</div>
|
|
36
46
|
);
|
|
37
47
|
|
|
@@ -44,6 +54,7 @@ export function AccountFilters({
|
|
|
44
54
|
const { filters, isPending, isError, error, refetch } =
|
|
45
55
|
useFilterList(accountId);
|
|
46
56
|
const { deleteFilter, deletingFilterId } = useDeleteFilter(accountId);
|
|
57
|
+
const toggle = useToggleFilter(accountId);
|
|
47
58
|
const [editingFilterId, setEditingFilterId] = useState<string | undefined>();
|
|
48
59
|
|
|
49
60
|
const { data: mailboxesData } = useQuery({
|
|
@@ -128,14 +139,26 @@ export function AccountFilters({
|
|
|
128
139
|
}}
|
|
129
140
|
/>
|
|
130
141
|
) : (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
<>
|
|
143
|
+
{toggle.isError && (
|
|
144
|
+
<FilterToggleError
|
|
145
|
+
enabling={toggle.enabling}
|
|
146
|
+
error={toggle.error}
|
|
147
|
+
onRetry={toggle.retry}
|
|
148
|
+
reportHref={filterToggleReportHref}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
<FiltersList
|
|
152
|
+
filters={filters}
|
|
153
|
+
mailboxName={mailboxName}
|
|
154
|
+
labelById={labelById}
|
|
155
|
+
onEdit={setEditingFilterId}
|
|
156
|
+
onDelete={deleteFilter}
|
|
157
|
+
deletingFilterId={deletingFilterId}
|
|
158
|
+
onToggle={toggle.toggleFilter}
|
|
159
|
+
togglingFilterId={toggle.togglingFilterId}
|
|
160
|
+
/>
|
|
161
|
+
</>
|
|
139
162
|
)}
|
|
140
163
|
</section>
|
|
141
164
|
);
|