@remit/web-client 0.0.101 → 0.0.103
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/MoveToTrigger.render.test.ts +39 -10
- package/src/components/mail/MoveToTrigger.tsx +58 -41
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +41 -0
- package/src/components/mail/SelectionWizardHost.tsx +16 -10
- package/src/components/mail/SpamRescue.tsx +4 -3
- package/src/components/settings/DeleteFolderDialog.tsx +10 -17
- package/src/lib/move-options.test.ts +20 -6
- package/src/lib/move-options.ts +13 -4
- package/src/lib/organize/search-to-rule.test.ts +37 -1
- package/src/lib/organize/search-to-rule.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.103",
|
|
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,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The move-to-folder picker. It only fetches folders once it is opened, it
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* opens on the account's top level with nested folders behind the one holding
|
|
4
|
+
* them, it marks the folder the messages are already in rather than offering it
|
|
5
|
+
* as a destination, and on desktop Escape or a click outside puts it away.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import assert from "node:assert/strict";
|
|
@@ -54,19 +55,23 @@ const mount = (
|
|
|
54
55
|
const FOLDERS = [
|
|
55
56
|
makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
|
|
56
57
|
makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
|
|
58
|
+
makeMailbox({ mailboxId: "mbx-clients", fullPath: "Work/Clients" }),
|
|
57
59
|
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
58
60
|
];
|
|
59
61
|
|
|
62
|
+
const rowText = (dom: DomHarness): string[] =>
|
|
63
|
+
dom.queryAll("[role=treeitem]").map((row) => row.textContent ?? "");
|
|
64
|
+
|
|
60
65
|
describe("MoveToTrigger", () => {
|
|
61
66
|
it("reports itself as collapsed until it is opened", () => {
|
|
62
67
|
const dom = mount({ mailboxes: FOLDERS });
|
|
63
68
|
const trigger = dom.byLabel("Move to folder");
|
|
64
69
|
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
65
70
|
assert.equal(trigger.getAttribute("aria-controls"), null);
|
|
66
|
-
assert.equal(dom.query('[role="
|
|
71
|
+
assert.equal(dom.query('[role="tree"], input'), null);
|
|
67
72
|
});
|
|
68
73
|
|
|
69
|
-
it("opens
|
|
74
|
+
it("opens the top level and marks the folder we are in", () => {
|
|
70
75
|
const dom = mount({ mailboxes: FOLDERS });
|
|
71
76
|
dom.click(dom.byLabel("Move to folder"));
|
|
72
77
|
|
|
@@ -74,9 +79,7 @@ describe("MoveToTrigger", () => {
|
|
|
74
79
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
75
80
|
"true",
|
|
76
81
|
);
|
|
77
|
-
const labels = dom
|
|
78
|
-
.queryAll("[role=option]")
|
|
79
|
-
.map((option) => option.textContent ?? "");
|
|
82
|
+
const labels = rowText(dom);
|
|
80
83
|
assert.ok(labels.some((label) => label.includes("Work")));
|
|
81
84
|
assert.ok(labels.some((label) => label.includes("Receipts")));
|
|
82
85
|
|
|
@@ -86,15 +89,32 @@ describe("MoveToTrigger", () => {
|
|
|
86
89
|
assert.ok(current, "the source folder is marked as the current one");
|
|
87
90
|
});
|
|
88
91
|
|
|
89
|
-
it("
|
|
92
|
+
it("keeps a nested folder behind the folder that holds it", () => {
|
|
93
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
94
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
95
|
+
assert.equal(
|
|
96
|
+
rowText(dom).some((label) => label.includes("Clients")),
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
101
|
+
assert.ok(rowText(dom).some((label) => label.includes("Clients")));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("waits for the move to be confirmed, then closes itself", () => {
|
|
90
105
|
const moved: string[] = [];
|
|
91
106
|
const dom = mount({
|
|
92
107
|
mailboxes: FOLDERS,
|
|
93
108
|
onMove: (id) => moved.push(id),
|
|
94
109
|
});
|
|
95
110
|
dom.click(dom.byLabel("Move to folder"));
|
|
96
|
-
dom.click(dom.byText("[role=option]", "Work"));
|
|
97
111
|
|
|
112
|
+
// Picking a folder also opens it, so the move is a separate press —
|
|
113
|
+
// otherwise the first tap would fire before anything nested was reachable.
|
|
114
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
115
|
+
assert.deepEqual(moved, []);
|
|
116
|
+
|
|
117
|
+
dom.click(dom.byText("button", "Move to Work"));
|
|
98
118
|
assert.deepEqual(moved, ["mbx-work"]);
|
|
99
119
|
assert.equal(
|
|
100
120
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
@@ -102,6 +122,15 @@ describe("MoveToTrigger", () => {
|
|
|
102
122
|
);
|
|
103
123
|
});
|
|
104
124
|
|
|
125
|
+
it("names the destination on the button that runs the move", () => {
|
|
126
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
127
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
128
|
+
dom.click(dom.byLabel("Move to Work"));
|
|
129
|
+
dom.click(dom.byLabel("Move to Clients"));
|
|
130
|
+
|
|
131
|
+
assert.match(dom.text(), /Move to Clients/);
|
|
132
|
+
});
|
|
133
|
+
|
|
105
134
|
it("closes on Escape and on a click outside it", () => {
|
|
106
135
|
const dom = mount({ mailboxes: FOLDERS });
|
|
107
136
|
const isOpen = () =>
|
|
@@ -144,7 +173,7 @@ describe("MoveToTrigger", () => {
|
|
|
144
173
|
|
|
145
174
|
it("asks for the folder list only once the picker is opened", () => {
|
|
146
175
|
const dom = mount();
|
|
147
|
-
assert.equal(dom.queryAll("[role=
|
|
176
|
+
assert.equal(dom.queryAll("[role=treeitem]").length, 0);
|
|
148
177
|
dom.click(dom.byLabel("Move to folder"));
|
|
149
178
|
// No cached mailboxes and no network in the test harness: the picker
|
|
150
179
|
// shows its loading state rather than an empty list of destinations.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { type
|
|
2
|
+
import { Button, type FolderTreeNode, FolderTreePicker } from "@remit/ui";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { FolderInput } from "lucide-react";
|
|
5
5
|
import {
|
|
@@ -17,7 +17,7 @@ import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
|
|
|
17
17
|
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
18
18
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
19
19
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
20
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
20
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
21
21
|
import { cn } from "@/lib/utils";
|
|
22
22
|
|
|
23
23
|
interface MoveToTriggerProps {
|
|
@@ -66,6 +66,7 @@ export const MoveToTrigger = ({
|
|
|
66
66
|
label,
|
|
67
67
|
}: MoveToTriggerProps) => {
|
|
68
68
|
const [isOpen, setIsOpen] = useState(false);
|
|
69
|
+
const [pickedId, setPickedId] = useState<string>();
|
|
69
70
|
const isDesktop = useIsDesktop();
|
|
70
71
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
71
72
|
const triggerLabel = label ?? "Move to folder";
|
|
@@ -88,9 +89,9 @@ export const MoveToTrigger = ({
|
|
|
88
89
|
enabled: isOpen,
|
|
89
90
|
});
|
|
90
91
|
const folderAppointments = useFolderAppointments(accountId);
|
|
91
|
-
const {
|
|
92
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
92
93
|
|
|
93
|
-
const options = useMemo<
|
|
94
|
+
const options = useMemo<FolderTreeNode[]>(
|
|
94
95
|
() =>
|
|
95
96
|
buildMoveOptions({
|
|
96
97
|
mailboxes: mailboxesResponse?.items ?? [],
|
|
@@ -105,22 +106,23 @@ export const MoveToTrigger = ({
|
|
|
105
106
|
translator,
|
|
106
107
|
],
|
|
107
108
|
);
|
|
109
|
+
const delimiter = folderDelimiter(mailboxesResponse?.items ?? []);
|
|
108
110
|
|
|
109
|
-
const
|
|
110
|
-
(destinationMailboxId: string) => {
|
|
111
|
-
setIsOpen(false);
|
|
112
|
-
onMove(destinationMailboxId);
|
|
113
|
-
},
|
|
114
|
-
[onMove],
|
|
115
|
-
);
|
|
111
|
+
const picked = options.find((option) => option.id === pickedId);
|
|
116
112
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
const close = useCallback(() => {
|
|
114
|
+
setIsOpen(false);
|
|
115
|
+
setPickedId(undefined);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
// Tapping a folder both picks it and opens it, so the move waits for a
|
|
119
|
+
// confirmation — otherwise the first tap would fire before the user could
|
|
120
|
+
// reach anything nested inside it.
|
|
121
|
+
const handleMove = useCallback(() => {
|
|
122
|
+
if (!picked) return;
|
|
123
|
+
close();
|
|
124
|
+
onMove(picked.id);
|
|
125
|
+
}, [picked, close, onMove]);
|
|
124
126
|
|
|
125
127
|
// Desktop popover: dismiss on outside click + Escape.
|
|
126
128
|
useEffect(() => {
|
|
@@ -130,11 +132,11 @@ export const MoveToTrigger = ({
|
|
|
130
132
|
containerRef.current &&
|
|
131
133
|
!containerRef.current.contains(event.target as Node)
|
|
132
134
|
) {
|
|
133
|
-
|
|
135
|
+
close();
|
|
134
136
|
}
|
|
135
137
|
};
|
|
136
138
|
const handleKey = (event: KeyboardEvent) => {
|
|
137
|
-
if (event.key === "Escape")
|
|
139
|
+
if (event.key === "Escape") close();
|
|
138
140
|
};
|
|
139
141
|
document.addEventListener("mousedown", handlePointer);
|
|
140
142
|
document.addEventListener("keydown", handleKey);
|
|
@@ -142,7 +144,7 @@ export const MoveToTrigger = ({
|
|
|
142
144
|
document.removeEventListener("mousedown", handlePointer);
|
|
143
145
|
document.removeEventListener("keydown", handleKey);
|
|
144
146
|
};
|
|
145
|
-
}, [isOpen, isDesktop]);
|
|
147
|
+
}, [isOpen, isDesktop, close]);
|
|
146
148
|
|
|
147
149
|
const isTriggerDisabled = disabled || !!disabledHint;
|
|
148
150
|
|
|
@@ -156,10 +158,10 @@ export const MoveToTrigger = ({
|
|
|
156
158
|
}}
|
|
157
159
|
aria-label={triggerLabel}
|
|
158
160
|
// Mobile opens a vaul Drawer (modal dialog), desktop opens a
|
|
159
|
-
// non-modal popover whose only content is the
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
aria-haspopup={isDesktop ? "
|
|
161
|
+
// non-modal popover whose only content is the folder tree. Reflect
|
|
162
|
+
// each surface accurately so screen readers announce the right
|
|
163
|
+
// structure.
|
|
164
|
+
aria-haspopup={isDesktop ? "tree" : "dialog"}
|
|
163
165
|
aria-expanded={isOpen}
|
|
164
166
|
aria-controls={isOpen ? popoverId : undefined}
|
|
165
167
|
title={disabledHint}
|
|
@@ -182,33 +184,44 @@ export const MoveToTrigger = ({
|
|
|
182
184
|
/>
|
|
183
185
|
</div>
|
|
184
186
|
) : (
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
<FolderTreePicker
|
|
188
|
+
folders={options}
|
|
189
|
+
selectedId={pickedId}
|
|
190
|
+
delimiter={delimiter}
|
|
191
|
+
onSelect={setPickedId}
|
|
192
|
+
onCreateFolder={createFolderIn}
|
|
193
|
+
onCancel={close}
|
|
191
194
|
labels={{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
defaultValue: "Move to…",
|
|
195
|
+
filterPlaceholder: t("move_picker_filter_placeholder", {
|
|
196
|
+
defaultValue: "Filter folders…",
|
|
195
197
|
}),
|
|
196
|
-
|
|
198
|
+
filterAriaLabel: t("move_picker_filter_label", {
|
|
197
199
|
defaultValue: "Filter folders",
|
|
198
200
|
}),
|
|
199
|
-
optionLabel: (folderLabel) => `Move to ${folderLabel}`,
|
|
200
|
-
currentSuffix: "(current folder)",
|
|
201
|
-
currentTag: "current",
|
|
202
|
-
emptyMessage: (query) => `No folders match "${query}"`,
|
|
203
201
|
}}
|
|
204
202
|
/>
|
|
205
203
|
);
|
|
206
204
|
|
|
205
|
+
const confirmBar = picked && (
|
|
206
|
+
<div className="shrink-0 border-t border-line p-2">
|
|
207
|
+
<Button
|
|
208
|
+
variant="primary"
|
|
209
|
+
onClick={handleMove}
|
|
210
|
+
className="h-11 w-full font-semibold"
|
|
211
|
+
>
|
|
212
|
+
{`Move to ${picked.label}`}
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
|
|
207
217
|
if (!isDesktop) {
|
|
208
218
|
return (
|
|
209
219
|
<>
|
|
210
220
|
{TriggerButton}
|
|
211
|
-
<Drawer.Root
|
|
221
|
+
<Drawer.Root
|
|
222
|
+
open={isOpen}
|
|
223
|
+
onOpenChange={(next) => (next ? setIsOpen(true) : close())}
|
|
224
|
+
>
|
|
212
225
|
<Drawer.Portal>
|
|
213
226
|
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
|
|
214
227
|
<Drawer.Content
|
|
@@ -220,7 +233,10 @@ export const MoveToTrigger = ({
|
|
|
220
233
|
<Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
|
|
221
234
|
Move to folder
|
|
222
235
|
</Drawer.Title>
|
|
223
|
-
<div className="flex-1 overflow-hidden">
|
|
236
|
+
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
237
|
+
{pickerBody}
|
|
238
|
+
</div>
|
|
239
|
+
{confirmBar}
|
|
224
240
|
</Drawer.Content>
|
|
225
241
|
</Drawer.Portal>
|
|
226
242
|
</Drawer.Root>
|
|
@@ -240,6 +256,7 @@ export const MoveToTrigger = ({
|
|
|
240
256
|
)}
|
|
241
257
|
>
|
|
242
258
|
{pickerBody}
|
|
259
|
+
{confirmBar}
|
|
243
260
|
</div>
|
|
244
261
|
)}
|
|
245
262
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A filter made from a search belongs to the account the search ran over (#524).
|
|
9
|
+
* The surface hands its account to the host on the props; a search entry that
|
|
10
|
+
* reads past it writes the rule against whichever account happens to be first in
|
|
11
|
+
* the list, and the mail the user was looking at is never filtered.
|
|
12
|
+
*
|
|
13
|
+
* Which account wins is decided — and tested — in
|
|
14
|
+
* `../../lib/organize/search-to-rule.test.ts`. What is read off the source here
|
|
15
|
+
* is that the host asks that question with the surface's account in hand, which
|
|
16
|
+
* is the wiring the bug lived in and which no unit of the resolver can prove.
|
|
17
|
+
* The host wires routing, history and several data hooks together, so it is read
|
|
18
|
+
* rather than rendered, as this package's other component-level rules are (see
|
|
19
|
+
* `SelectionWizardHost.run-exit.test.ts`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
|
|
24
|
+
|
|
25
|
+
const host = source.match(
|
|
26
|
+
/export function SelectionWizardHost\(([\s\S]*)$/,
|
|
27
|
+
)?.[1];
|
|
28
|
+
|
|
29
|
+
describe("the search entry's account", () => {
|
|
30
|
+
it("is resolved with the account the surface handed over", () => {
|
|
31
|
+
assert.ok(host, "the host is gone");
|
|
32
|
+
const call = host.match(/searchRuleAccountId\(([\s\S]*?)\)/)?.[1];
|
|
33
|
+
assert.ok(call, "the search entry resolves no account of its own");
|
|
34
|
+
assert.match(call, /\bprops\.accountId\b/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("never reaches past it for the first configured account", () => {
|
|
38
|
+
assert.ok(host, "the host is gone");
|
|
39
|
+
assert.doesNotMatch(host, /accounts\[0\]/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
derivePropertyClauses,
|
|
8
8
|
deriveSenderClauses,
|
|
9
9
|
dominantSender,
|
|
10
|
+
type FolderTreeNode,
|
|
10
11
|
type MatchCount,
|
|
11
12
|
type MatchDoor,
|
|
12
13
|
type MatchMode,
|
|
13
|
-
type MoveMailboxOption,
|
|
14
14
|
type RuleClause,
|
|
15
15
|
type RunState,
|
|
16
16
|
type SearchConversion,
|
|
@@ -50,7 +50,7 @@ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
|
50
50
|
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
51
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
52
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
53
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
54
54
|
import {
|
|
55
55
|
buildWizardDraft,
|
|
56
56
|
canBackApplyDraft,
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
normalizeClauseValue,
|
|
63
63
|
SUPPORTED_CLAUSE_FIELDS,
|
|
64
64
|
} from "@/lib/organize/rule-model";
|
|
65
|
+
import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
|
|
65
66
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
66
67
|
import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
|
|
67
68
|
|
|
@@ -388,7 +389,7 @@ function SelectionWizardSession({
|
|
|
388
389
|
});
|
|
389
390
|
const folderAppointments = useFolderAppointments(accountId);
|
|
390
391
|
const translator = useFolderLabelTranslator();
|
|
391
|
-
const mailboxes = useMemo<
|
|
392
|
+
const mailboxes = useMemo<FolderTreeNode[]>(
|
|
392
393
|
() =>
|
|
393
394
|
buildMoveOptions({
|
|
394
395
|
mailboxes: mailboxesData?.items ?? [],
|
|
@@ -398,7 +399,7 @@ function SelectionWizardSession({
|
|
|
398
399
|
}),
|
|
399
400
|
[mailboxesData?.items, folderAppointments, mailboxId, translator],
|
|
400
401
|
);
|
|
401
|
-
const {
|
|
402
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
402
403
|
|
|
403
404
|
const folderLabel = mailboxes.find(
|
|
404
405
|
(mailbox) => mailbox.id === draft.moveMailboxId,
|
|
@@ -946,11 +947,12 @@ function SelectionWizardSession({
|
|
|
946
947
|
sample: { ...sample, label: "What this matches" },
|
|
947
948
|
}}
|
|
948
949
|
folder={{
|
|
949
|
-
mailboxes,
|
|
950
|
+
folders: mailboxes,
|
|
950
951
|
mailboxId: named.moveMailboxId,
|
|
952
|
+
delimiter: folderDelimiter(mailboxesData?.items ?? []),
|
|
951
953
|
onSelect: (moveMailboxId) =>
|
|
952
954
|
setDraft((held) => ({ ...held, moveMailboxId })),
|
|
953
|
-
onCreateFolder: accountId ?
|
|
955
|
+
onCreateFolder: accountId ? createFolderIn : undefined,
|
|
954
956
|
restriction: folderRestriction,
|
|
955
957
|
}}
|
|
956
958
|
rule={{
|
|
@@ -1002,9 +1004,9 @@ function SelectionWizardSession({
|
|
|
1002
1004
|
*
|
|
1003
1005
|
* The URL says which affordance opened the wizard, and that decides two things
|
|
1004
1006
|
* and no more: which step it opens on, and whether the query seeds the clauses.
|
|
1005
|
-
* A search entry ticks nothing
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1007
|
+
* A search entry ticks nothing, so the selection the bar would have handed over
|
|
1008
|
+
* is not the one it walks; its rule belongs to the account the surface handed
|
|
1009
|
+
* over, and to the one the query names when it names one (#524).
|
|
1008
1010
|
*/
|
|
1009
1011
|
export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
1010
1012
|
const fromSearch = useWizardEntryValue() === "search";
|
|
@@ -1023,7 +1025,11 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
|
1023
1025
|
{...(conversion
|
|
1024
1026
|
? {
|
|
1025
1027
|
verb: "organize" as const,
|
|
1026
|
-
accountId:
|
|
1028
|
+
accountId: searchRuleAccountId(
|
|
1029
|
+
conversion,
|
|
1030
|
+
props.accountId,
|
|
1031
|
+
accounts,
|
|
1032
|
+
),
|
|
1027
1033
|
selection: EMPTY_SELECTION,
|
|
1028
1034
|
crossAccount: false,
|
|
1029
1035
|
escalated: undefined,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
2
|
import {
|
|
3
|
-
type
|
|
3
|
+
type FolderTreeNode,
|
|
4
4
|
RescueBanner,
|
|
5
5
|
type RescueCandidate,
|
|
6
6
|
RescueFromSpamFlow,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
useInboxMailbox,
|
|
20
20
|
} from "@/hooks/useArchiveMailbox";
|
|
21
21
|
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
22
|
-
import { buildMoveOptions } from "@/lib/move-options";
|
|
22
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
23
23
|
import {
|
|
24
24
|
recordRescueCandidatesSurfaced,
|
|
25
25
|
recordRescueCommitted,
|
|
@@ -60,7 +60,7 @@ export function SpamRescue({
|
|
|
60
60
|
staleTime: Infinity,
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
const folders = useMemo<
|
|
63
|
+
const folders = useMemo<FolderTreeNode[]>(
|
|
64
64
|
() =>
|
|
65
65
|
buildMoveOptions({
|
|
66
66
|
mailboxes: mailboxesResponse?.items ?? [],
|
|
@@ -117,6 +117,7 @@ export function SpamRescue({
|
|
|
117
117
|
candidates={candidates}
|
|
118
118
|
defaultDestinationId={defaultDestinationId}
|
|
119
119
|
availableFolders={folders}
|
|
120
|
+
delimiter={folderDelimiter(mailboxesResponse?.items ?? [])}
|
|
120
121
|
onConfirmMove={handleConfirmMove}
|
|
121
122
|
onCancel={() => setOpen(false)}
|
|
122
123
|
/>
|
|
@@ -83,23 +83,16 @@ export function DeleteFolderDialog({
|
|
|
83
83
|
onClose();
|
|
84
84
|
}, [cancel, onClose]);
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
translator,
|
|
97
|
-
}).map((option) => ({
|
|
98
|
-
id: option.id,
|
|
99
|
-
label: option.label,
|
|
100
|
-
path: pathById.get(option.id) ?? option.label,
|
|
101
|
-
}));
|
|
102
|
-
}, [mailboxes, appointments, folder.mailboxId, translator]);
|
|
86
|
+
const destinations = useMemo<FolderTreeNode[]>(
|
|
87
|
+
() =>
|
|
88
|
+
buildMoveOptions({
|
|
89
|
+
mailboxes,
|
|
90
|
+
folderAppointments: appointments,
|
|
91
|
+
excludeMailboxId: folder.mailboxId,
|
|
92
|
+
translator,
|
|
93
|
+
}),
|
|
94
|
+
[mailboxes, appointments, folder.mailboxId, translator],
|
|
95
|
+
);
|
|
103
96
|
|
|
104
97
|
const name = useMemo(
|
|
105
98
|
() =>
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapFolderAppointment,
|
|
7
7
|
RemitImapMailboxResponse,
|
|
8
8
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import { buildMoveOptions } from "./move-options.js";
|
|
9
|
+
import { buildMoveOptions, folderDelimiter } from "./move-options.js";
|
|
10
10
|
|
|
11
11
|
const englishBundle = JSON.parse(
|
|
12
12
|
readFileSync(
|
|
@@ -100,16 +100,15 @@ describe("buildMoveOptions", () => {
|
|
|
100
100
|
assert.equal(labelOf(options, "mb-receipts"), "Receipts");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test("the
|
|
103
|
+
test("a role label keeps the provider path it nests under", () => {
|
|
104
104
|
const options = buildMoveOptions({
|
|
105
105
|
mailboxes: applePaths,
|
|
106
106
|
folderAppointments: appleAppointments,
|
|
107
107
|
translator: translate,
|
|
108
108
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
109
|
+
const trash = options.find((option) => option.id === "mb-trash");
|
|
110
|
+
assert.equal(trash?.label, "Trash");
|
|
111
|
+
assert.equal(trash?.path, "INBOX/Deleted Messages");
|
|
113
112
|
});
|
|
114
113
|
|
|
115
114
|
test("the current mailbox is marked and nothing else is", () => {
|
|
@@ -138,3 +137,18 @@ describe("buildMoveOptions", () => {
|
|
|
138
137
|
);
|
|
139
138
|
});
|
|
140
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("folderDelimiter", () => {
|
|
142
|
+
test("takes the account's own hierarchy separator", () => {
|
|
143
|
+
assert.equal(
|
|
144
|
+
folderDelimiter([
|
|
145
|
+
make({ mailboxId: "mb-1", fullPath: "INBOX", hierarchyDelimiter: "." }),
|
|
146
|
+
]),
|
|
147
|
+
".",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("falls back to a slash when there are no mailboxes yet", () => {
|
|
152
|
+
assert.equal(folderDelimiter([]), "/");
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/lib/move-options.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
RemitImapFolderAppointment,
|
|
3
3
|
RemitImapMailboxResponse,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import type {
|
|
5
|
+
import type { FolderTreeNode } from "@remit/ui";
|
|
6
6
|
import { excludeFolder } from "./delete-folder.js";
|
|
7
7
|
import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
|
|
8
8
|
import { buildMoveTargets } from "./move-targets.js";
|
|
@@ -19,11 +19,20 @@ interface MoveOptionsInput {
|
|
|
19
19
|
translator?: Translator;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The account's hierarchy separator, which the tree splits paths on. Every
|
|
24
|
+
* mailbox of an account reports the same one, so the first is the account's.
|
|
25
|
+
*/
|
|
26
|
+
export const folderDelimiter = (
|
|
27
|
+
mailboxes: readonly RemitImapMailboxResponse[],
|
|
28
|
+
): string => mailboxes[0]?.hierarchyDelimiter ?? "/";
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* The single shaping of a mailbox list into Move-to picker options. Labels
|
|
24
32
|
* follow the account's role appointments (RFC 032, #976) and any
|
|
25
33
|
* `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
|
|
26
|
-
* provider's leaf
|
|
34
|
+
* provider's leaf, while the provider path it nests and filters under stays
|
|
35
|
+
* untouched.
|
|
27
36
|
*/
|
|
28
37
|
export const buildMoveOptions = ({
|
|
29
38
|
mailboxes,
|
|
@@ -31,7 +40,7 @@ export const buildMoveOptions = ({
|
|
|
31
40
|
currentMailboxId,
|
|
32
41
|
excludeMailboxId,
|
|
33
42
|
translator,
|
|
34
|
-
}: MoveOptionsInput):
|
|
43
|
+
}: MoveOptionsInput): FolderTreeNode[] => {
|
|
35
44
|
const targets = buildMoveTargets(mailboxes, folderAppointments);
|
|
36
45
|
const roleMap = buildMailboxRoleMap(folderAppointments);
|
|
37
46
|
const destinations = excludeMailboxId
|
|
@@ -40,7 +49,7 @@ export const buildMoveOptions = ({
|
|
|
40
49
|
return destinations.map((mailbox) => ({
|
|
41
50
|
id: mailbox.mailboxId,
|
|
42
51
|
label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
|
|
43
|
-
|
|
52
|
+
path: mailbox.fullPath,
|
|
44
53
|
isCurrent: mailbox.mailboxId === currentMailboxId,
|
|
45
54
|
}));
|
|
46
55
|
};
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { isConvertible } from "@remit/ui";
|
|
4
4
|
import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
|
|
5
|
-
import { convertSearchToRule } from "./search-to-rule";
|
|
5
|
+
import { convertSearchToRule, searchRuleAccountId } from "./search-to-rule";
|
|
6
6
|
|
|
7
7
|
const CONTEXT: SearchTokenContext = {
|
|
8
8
|
mailboxesByName: new Map([["archive", "mbx-archive"]]),
|
|
@@ -124,3 +124,39 @@ describe("convertSearchToRule — nothing left to make a filter from", () => {
|
|
|
124
124
|
assert.equal(isConvertible(convert("in:archive receipts")), true);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
describe("searchRuleAccountId — whose account the filter belongs to", () => {
|
|
129
|
+
const ACCOUNTS = [{ accountId: "acc-first" }, { accountId: "acc-work" }];
|
|
130
|
+
|
|
131
|
+
it("uses the account the surface is showing for an ordinary search", () => {
|
|
132
|
+
assert.equal(
|
|
133
|
+
searchRuleAccountId(convert("invoice"), "acc-work", ACCOUNTS),
|
|
134
|
+
"acc-work",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("lets an account: facet name a different account than the surface", () => {
|
|
139
|
+
assert.equal(
|
|
140
|
+
searchRuleAccountId(
|
|
141
|
+
convert("account:work invoice"),
|
|
142
|
+
"acc-other",
|
|
143
|
+
ACCOUNTS,
|
|
144
|
+
),
|
|
145
|
+
"acc-work",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("falls back to the first configured account only when nothing names one", () => {
|
|
150
|
+
assert.equal(
|
|
151
|
+
searchRuleAccountId(convert("invoice"), undefined, ACCOUNTS),
|
|
152
|
+
"acc-first",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("has no account to give when none is configured", () => {
|
|
157
|
+
assert.equal(
|
|
158
|
+
searchRuleAccountId(convert("invoice"), undefined, []),
|
|
159
|
+
undefined,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
1
2
|
import type {
|
|
2
3
|
DroppedFacet,
|
|
3
4
|
DroppedFacetType,
|
|
@@ -103,3 +104,17 @@ export const convertSearchToRule = (
|
|
|
103
104
|
droppedSemantic: keptTerms && searchHadSemanticReach,
|
|
104
105
|
};
|
|
105
106
|
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which account a rule converted from a search belongs to (#524). A query names
|
|
110
|
+
* one only through an explicit `account:` facet, which an ordinary search does
|
|
111
|
+
* not carry; without it the rule belongs to the account whose mail the search
|
|
112
|
+
* ran over, which is the account the surface handed over. Only a surface that
|
|
113
|
+
* has no single account of its own leaves the first configured one to answer.
|
|
114
|
+
*/
|
|
115
|
+
export const searchRuleAccountId = (
|
|
116
|
+
conversion: SearchConversion,
|
|
117
|
+
surfaceAccountId: string | undefined,
|
|
118
|
+
accounts: readonly Pick<RemitImapAccountResponse, "accountId">[],
|
|
119
|
+
): string | undefined =>
|
|
120
|
+
conversion.targetAccountId ?? surfaceAccountId ?? accounts[0]?.accountId;
|