@remit/web-client 0.0.118 → 0.0.120

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.118",
3
+ "version": "0.0.120",
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": {
@@ -719,7 +719,10 @@ function SelectionWizardSession({
719
719
  state,
720
720
  matched: progress.matchedCount,
721
721
  applied: progress.appliedCount,
722
- failed: state === "backApplyFailed" ? progress.failedCount : 0,
722
+ failed:
723
+ state === "backApplyFailed" || state === "backApplyRestartFailed"
724
+ ? progress.failedCount
725
+ : 0,
723
726
  failures: [],
724
727
  };
725
728
  },
@@ -59,6 +59,24 @@ describe("organizeRunState", () => {
59
59
  );
60
60
  });
61
61
 
62
+ it("keeps a finished pass's ending when the retry over it could not be started", () => {
63
+ // #552: the retry is a second create, and a create that failed over a pass
64
+ // that already moved mail is not a pass that never ran.
65
+ for (const ruleSaved of [true, false]) {
66
+ assert.equal(
67
+ organizeRunState(
68
+ reading({
69
+ ruleSaved,
70
+ isDone: true,
71
+ failedCount: 84,
72
+ failure: { kind: "restartFailed", error: new Error("offline") },
73
+ }),
74
+ ),
75
+ "backApplyRestartFailed",
76
+ );
77
+ }
78
+ });
79
+
62
80
  it("says nothing happened only when the create itself failed", () => {
63
81
  assert.equal(
64
82
  organizeRunState(
@@ -20,6 +20,10 @@ export interface OrganizeJobReading {
20
20
  * not a job that never started (#526), so what the job is doing is read before
21
21
  * what failed: a dropped poll leaves a running pass running and a finished one
22
22
  * finished, and only a create that never returned an id says nothing happened.
23
+ *
24
+ * A create that failed over a pass that already ran is that same distinction on
25
+ * the create path (#552): the counts of the pass that ran stand, and what failed
26
+ * is the retry.
23
27
  */
24
28
  export const organizeRunState = ({
25
29
  failure,
@@ -29,6 +33,7 @@ export const organizeRunState = ({
29
33
  failedCount,
30
34
  ruleSaved,
31
35
  }: OrganizeJobReading): RunState => {
36
+ if (failure?.kind === "restartFailed") return "backApplyRestartFailed";
32
37
  if (failure?.kind === "startFailed") {
33
38
  return ruleSaved ? "backApplyStartFailed" : "commitFailed";
34
39
  }
@@ -35,8 +35,8 @@ afterEach(() => {
35
35
  const ACCOUNT_ID = "acc-1";
36
36
 
37
37
  const FOLDERS = [
38
- { id: "mbx-receipts", label: "Receipts" },
39
- { id: "mbx-archive", label: "Archive" },
38
+ { id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
39
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
40
40
  ];
41
41
 
42
42
  const filterFixture = (
@@ -5,7 +5,7 @@ import {
5
5
  type ClauseField,
6
6
  type FilterRule,
7
7
  FilterRuleEditor,
8
- type FolderOption,
8
+ type FolderTreeNode,
9
9
  type LabelOption,
10
10
  type MatchOperator,
11
11
  previewCountSummary,
@@ -34,7 +34,9 @@ import {
34
34
  interface FilterEditorProps {
35
35
  accountId: string;
36
36
  filter: RemitImapFilterResponse;
37
- folders: FolderOption[];
37
+ folders: readonly FolderTreeNode[];
38
+ /** The provider hierarchy separator the destination tree nests on. */
39
+ delimiter?: string;
38
40
  labels: LabelOption[];
39
41
  /**
40
42
  * This deployment ships no vector pipeline, so a semantic anchor cannot be
@@ -61,6 +63,7 @@ export function FilterEditor({
61
63
  accountId,
62
64
  filter,
63
65
  folders,
66
+ delimiter,
64
67
  labels,
65
68
  semanticUnavailable = false,
66
69
  onClose,
@@ -77,7 +80,7 @@ export function FilterEditor({
77
80
  const { count: preview } = useRulePreview(accountId, rulePredicate(rule));
78
81
  const update = useUpdateFilter(accountId, filter.filterId);
79
82
  const organizeJob = useOrganizeJob(accountId);
80
- const { createFolder } = useCreateMailbox(accountId);
83
+ const { createFolderIn } = useCreateMailbox(accountId);
81
84
  const { createLabel } = useCreateLabel(accountId);
82
85
  const onCreateLabel = async (name: string): Promise<LabelOption> => {
83
86
  const label = await createLabel(name);
@@ -219,6 +222,7 @@ export function FilterEditor({
219
222
  <FilterRuleEditor
220
223
  rule={rule}
221
224
  folders={folders}
225
+ delimiter={delimiter}
222
226
  labels={labels}
223
227
  preview={preview}
224
228
  // The update endpoint carries no anchor field at all (reader #266), so a
@@ -240,7 +244,7 @@ export function FilterEditor({
240
244
  onCancelClause={() => setClauseEdit(undefined)}
241
245
  onChangeMatchOperator={changeMatchOperator}
242
246
  onChangeMove={changeMove}
243
- onCreateFolder={createFolder}
247
+ onCreateFolder={createFolderIn}
244
248
  onChangeLabel={changeLabel}
245
249
  onCreateLabel={onCreateLabel}
246
250
  onChangeName={changeName}
@@ -2,7 +2,7 @@ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.t
2
2
  import {
3
3
  BottomSheet,
4
4
  Dialog,
5
- type FolderOption,
5
+ type FolderTreeNode,
6
6
  type LabelOption,
7
7
  } from "@remit/ui";
8
8
  import { useIsDesktop } from "@/hooks/useMediaQuery";
@@ -11,7 +11,8 @@ import { FilterEditor } from "./FilterEditor";
11
11
  interface FilterEditorSurfaceProps {
12
12
  accountId: string;
13
13
  filter: RemitImapFilterResponse;
14
- folders: FolderOption[];
14
+ folders: readonly FolderTreeNode[];
15
+ delimiter?: string;
15
16
  labels: LabelOption[];
16
17
  semanticUnavailable?: boolean;
17
18
  onClose: () => void;
@@ -27,6 +28,7 @@ export function FilterEditorSurface({
27
28
  accountId,
28
29
  filter,
29
30
  folders,
31
+ delimiter,
30
32
  labels,
31
33
  semanticUnavailable,
32
34
  onClose,
@@ -38,6 +40,7 @@ export function FilterEditorSurface({
38
40
  accountId={accountId}
39
41
  filter={filter}
40
42
  folders={folders}
43
+ delimiter={delimiter}
41
44
  labels={labels}
42
45
  semanticUnavailable={semanticUnavailable}
43
46
  onClose={onClose}
@@ -1,5 +1,5 @@
1
1
  import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { BottomSheet, type FolderOption, type LabelOption } from "@remit/ui";
2
+ import { BottomSheet, type FolderTreeNode, type LabelOption } from "@remit/ui";
3
3
  import type { Meta, StoryObj } from "@storybook/react-vite";
4
4
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
5
  import type { ReactNode } from "react";
@@ -17,11 +17,20 @@ import { FilterEditor } from "./FilterEditor";
17
17
 
18
18
  const ACCOUNT_ID = "acc-1";
19
19
 
20
- const FOLDERS: FolderOption[] = [
21
- { id: "mbx-inbox", label: "Inbox" },
22
- { id: "mbx-archive", label: "Archive" },
23
- { id: "mbx-receipts", label: "Receipts" },
24
- { id: "mbx-travel", label: "Travel" },
20
+ // Two folders named Receipts at different depths, and a Trash the account has
21
+ // renamed: the destination reads as the user names it and nests where the
22
+ // provider puts it.
23
+ const FOLDERS: FolderTreeNode[] = [
24
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
25
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
26
+ { id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
27
+ { id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
28
+ {
29
+ id: "mbx-travel-receipts",
30
+ label: "Receipts",
31
+ path: "INBOX/Travel/Receipts",
32
+ },
33
+ { id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
25
34
  ];
26
35
 
27
36
  const LABELS: LabelOption[] = [
@@ -16,7 +16,7 @@ import type {
16
16
  RemitImapMailboxResponse,
17
17
  } from "@remit/api-http-client/types.gen.ts";
18
18
  import { MailboxSyncStatus } from "@remit/domain-enums";
19
- import type { FolderOption } from "@remit/ui";
19
+ import type { FolderTreeNode } from "@remit/ui";
20
20
  import { act, createElement } from "react";
21
21
  import { MAILBOX_SYNC_FAILED_MESSAGE } from "../lib/mailbox-sync-wait";
22
22
  import { createDomHarness, type DomHarness } from "../test-support/dom";
@@ -28,7 +28,7 @@ const ACCOUNT = "acc-1";
28
28
  let harness: DomHarness | undefined;
29
29
  let http: HttpMock | undefined;
30
30
  let createFolder:
31
- | ((name: string, signal?: AbortSignal) => Promise<FolderOption>)
31
+ | ((name: string, signal?: AbortSignal) => Promise<FolderTreeNode>)
32
32
  | undefined;
33
33
 
34
34
  afterEach(() => {
@@ -132,7 +132,7 @@ describe("useCreateMailbox.createFolder validation", () => {
132
132
 
133
133
  it("passes a valid name through and resolves once the folder is confirmed synced", async () => {
134
134
  mount([mailbox("INBOX", "/")]);
135
- let result: FolderOption | undefined;
135
+ let result: FolderTreeNode | undefined;
136
136
  await act(async () => {
137
137
  result = await createFolder?.("Taxes");
138
138
  });
@@ -202,7 +202,7 @@ describe("useCreateMailbox.createFolder validation", () => {
202
202
 
203
203
  // The server confirms; the user presses "Create folder" again, same name.
204
204
  status = MailboxSyncStatus.synced;
205
- let result: FolderOption | undefined;
205
+ let result: FolderTreeNode | undefined;
206
206
  await act(async () => {
207
207
  result = await createFolder?.("Taxes");
208
208
  });
@@ -1,8 +1,9 @@
1
1
  /**
2
- * useOrganizeJob — the back-apply job seam. It reports two failures that are not
3
- * the same fact (#526): a create that never returned a job id, and a status poll
4
- * that could not be read over a job the server is already running. Looking at
5
- * that job again is a separate move from starting one.
2
+ * useOrganizeJob — the back-apply job seam. It reports three failures that are
3
+ * not the same fact (#526, #552): a create that never returned a job id, that
4
+ * same create over a pass that already ran, and a status poll that could not be
5
+ * read over a job the server is already running. Looking at that job again is a
6
+ * separate move from starting one.
6
7
  */
7
8
 
8
9
  import assert from "node:assert/strict";
@@ -75,6 +76,36 @@ const startJob = async (status: () => unknown): Promise<void> => {
75
76
  await settle();
76
77
  };
77
78
 
79
+ const COMPLETED_PASS = {
80
+ organizeJobId: JOB,
81
+ state: "Complete",
82
+ matchedCount: 1284,
83
+ appliedCount: 1200,
84
+ failedCount: 84,
85
+ };
86
+
87
+ /** Run one pass to a finish, then answer the next create with `restart`. */
88
+ const restartAfterPass = async (restart: () => unknown): Promise<void> => {
89
+ let created = false;
90
+ http = mockFetch((call) => {
91
+ if (call.method !== "POST") return COMPLETED_PASS;
92
+ if (created) return restart();
93
+ created = true;
94
+ return { organizeJobId: JOB, state: "Pending" };
95
+ });
96
+ harness = createDomHarness();
97
+ harness.renderApp(createElement(Probe));
98
+ await act(async () => {
99
+ current().start(DRAFT);
100
+ });
101
+ await settle();
102
+ assert.equal(current().isDone, true, "the first pass never finished");
103
+ await act(async () => {
104
+ current().start(DRAFT);
105
+ });
106
+ await settle();
107
+ };
108
+
78
109
  const posts = (): number =>
79
110
  (http?.calls ?? []).filter((call) => call.method === "POST").length;
80
111
 
@@ -128,6 +159,23 @@ describe("useOrganizeJob status reporting", () => {
128
159
  assert.equal(current().progress.matchedCount, 1284);
129
160
  });
130
161
 
162
+ it("reads a create that failed over a finished pass as a restart, with that pass's counts", async () => {
163
+ await restartAfterPass(dropped);
164
+ assert.equal(current().failure?.kind, "restartFailed");
165
+ assert.equal(current().progress.matchedCount, 1284);
166
+ assert.equal(current().progress.appliedCount, 1200);
167
+ assert.equal(current().progress.failedCount, 84);
168
+ assert.equal(current().isDone, true);
169
+ });
170
+
171
+ it("reports a restart that is under way as its own pass, not the one before it", async () => {
172
+ await restartAfterPass(() => new Promise<never>(() => {}));
173
+ assert.equal(current().isStarting, true);
174
+ assert.equal(current().isDone, false);
175
+ assert.equal(current().failure, undefined);
176
+ assert.equal(current().progress.matchedCount, 0);
177
+ });
178
+
131
179
  it("stops reporting a job as running once it reaches a terminal state", async () => {
132
180
  await startJob(() => ({
133
181
  organizeJobId: JOB,
@@ -23,20 +23,28 @@ export interface OrganizeJobProgress {
23
23
  }
24
24
 
25
25
  /**
26
- * Why the job is not reporting, which is two separate facts (#526). A create
27
- * that never returned an id means nothing was started; a status read that
28
- * failed means a job is out there and this client cannot see how far it got.
26
+ * Why the job is not reporting, which is three separate facts (#526, #552). A
27
+ * create that never returned an id means nothing was started; the same create
28
+ * over a pass that already ran means that pass stands and only the retry never
29
+ * left; a status read that failed means a job is out there and this client
30
+ * cannot see how far it got.
29
31
  */
30
32
  export interface OrganizeJobFailure {
31
- kind: "startFailed" | "statusUnreadable";
33
+ kind: "startFailed" | "restartFailed" | "statusUnreadable";
32
34
  error: unknown;
33
35
  }
34
36
 
35
37
  const organizeJobFailure = (
36
38
  createError: unknown,
37
39
  statusError: unknown,
40
+ passAlreadyRun: boolean,
38
41
  ): OrganizeJobFailure | undefined => {
39
- if (createError) return { kind: "startFailed", error: createError };
42
+ if (createError) {
43
+ return {
44
+ kind: passAlreadyRun ? "restartFailed" : "startFailed",
45
+ error: createError,
46
+ };
47
+ }
40
48
  if (statusError) return { kind: "statusUnreadable", error: statusError };
41
49
  return undefined;
42
50
  };
@@ -76,7 +84,6 @@ export const useOrganizeJob = (accountId: string | undefined) => {
76
84
  const start = useCallback(
77
85
  (draft: OrganizeDraft) => {
78
86
  if (!accountId) return;
79
- setOrganizeJobId(undefined);
80
87
  createJob({
81
88
  path: { accountId },
82
89
  body: buildOrganizeInput(draft),
@@ -92,7 +99,11 @@ export const useOrganizeJob = (accountId: string | undefined) => {
92
99
  void refetch();
93
100
  }, [refetch]);
94
101
 
95
- const job = jobQuery.data;
102
+ // The last pass this client read. A restart replaces it only once the server
103
+ // hands back a job id: while the create is in flight those counts are not this
104
+ // pass's, and a create that fails leaves them standing (#552).
105
+ const lastPass = jobQuery.data;
106
+ const job = createMutation.isPending ? undefined : lastPass;
96
107
  const state = job?.state ?? createMutation.data?.state;
97
108
  const isDone = isTerminalJobState(job?.state);
98
109
 
@@ -111,6 +122,10 @@ export const useOrganizeJob = (accountId: string | undefined) => {
111
122
  isStarting: createMutation.isPending,
112
123
  isRunning: !!organizeJobId && !isDone,
113
124
  isDone,
114
- failure: organizeJobFailure(createMutation.error, jobQuery.error),
125
+ failure: organizeJobFailure(
126
+ createMutation.error,
127
+ jobQuery.error,
128
+ !!lastPass,
129
+ ),
115
130
  };
116
131
  };
@@ -2,6 +2,7 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { afterEach, describe, it } from "node:test";
4
4
  import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
5
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
6
  import { createElement } from "react";
6
7
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
7
8
  import { makeAccount, makeMailbox } from "../../test-support/fixtures";
@@ -31,15 +32,32 @@ const account = makeAccount({
31
32
  });
32
33
 
33
34
  // Dutch leaf names for the appointed Drafts and Sent: only the appointment can
34
- // exclude them, so a name-based filter would not pass this.
35
+ // exclude them, so a name-based filter would not pass this. `Receipts` renamed
36
+ // to `Bonnetjes`, and a second `Receipts` inside Travel, are what the
37
+ // destination has to read and nest correctly.
35
38
  const mailboxes = [
36
- makeMailbox({ mailboxId: "mbx-receipts", fullPath: "INBOX/Receipts" }),
39
+ makeMailbox({
40
+ mailboxId: "mbx-receipts",
41
+ fullPath: "INBOX/Receipts",
42
+ displayNameOverride: "Bonnetjes",
43
+ }),
37
44
  makeMailbox({ mailboxId: "mbx-concepten", fullPath: "INBOX/Concepten" }),
38
45
  makeMailbox({ mailboxId: "mbx-verzonden", fullPath: "INBOX/Verzonden" }),
39
46
  makeMailbox({ mailboxId: "mbx-archief", fullPath: "INBOX/Archief" }),
40
47
  makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
48
+ makeMailbox({ mailboxId: "mbx-travel", fullPath: "INBOX/Travel" }),
49
+ makeMailbox({
50
+ mailboxId: "mbx-travel-receipts",
51
+ fullPath: "INBOX/Travel/Receipts",
52
+ }),
41
53
  ];
42
54
 
55
+ const createdFolder = makeMailbox({
56
+ mailboxId: "mbx-created",
57
+ fullPath: "INBOX/Travel/Hotels",
58
+ syncStatus: MailboxSyncStatus.synced,
59
+ });
60
+
43
61
  const filter: RemitImapFilterResponse = {
44
62
  filterId: "f-1",
45
63
  accountConfigId: ACCOUNT_ID,
@@ -74,9 +92,46 @@ const settleUntil = async (
74
92
  }
75
93
  };
76
94
 
77
- const offeredDestinations = async (): Promise<string[]> => {
95
+ const rowLabels = (dom: DomHarness): string[] =>
96
+ dom
97
+ .queryAll("[role=treeitem]")
98
+ .map((row) => row.getAttribute("aria-label") ?? "");
99
+
100
+ const buttonWithText = (
101
+ dom: DomHarness,
102
+ text: string,
103
+ ): HTMLElement | undefined =>
104
+ dom
105
+ .queryAll<HTMLButtonElement>("button")
106
+ .find((button) => button.textContent?.trim() === text);
107
+
108
+ /** The create form's name field, found by the label the kit gives it. */
109
+ const folderNameField = (dom: DomHarness): HTMLInputElement | undefined => {
110
+ const label = dom
111
+ .queryAll<HTMLLabelElement>("label")
112
+ .find((node) => node.textContent?.trim() === "Folder name");
113
+ const id = label?.getAttribute("for");
114
+ return id
115
+ ? ((dom.query(`input[id="${id}"]`) as HTMLInputElement | null) ?? undefined)
116
+ : undefined;
117
+ };
118
+
119
+ /**
120
+ * Opens the filter for editing and asks its destination field for the tree. The
121
+ * filter already moves matches into `INBOX/Receipts`, so the tree opens on the
122
+ * branch holding it.
123
+ */
124
+ const openDestinationTree = async (
125
+ posted?: Record<string, unknown>[],
126
+ ): Promise<DomHarness> => {
127
+ const live = [...mailboxes];
78
128
  http = mockFetch((call) => {
79
- if (call.path.endsWith("/mailboxes")) return { items: mailboxes };
129
+ if (call.path.endsWith("/mailboxes") && call.method === "POST") {
130
+ posted?.push(call.body ?? {});
131
+ live.push(createdFolder);
132
+ return createdFolder;
133
+ }
134
+ if (call.path.endsWith("/mailboxes")) return { items: live };
80
135
  if (call.path.endsWith("/filters")) return { items: [filter] };
81
136
  if (call.path.endsWith("/labels")) return { items: [] };
82
137
  if (call.path.endsWith("/organize/preview"))
@@ -91,24 +146,64 @@ const offeredDestinations = async (): Promise<string[]> => {
91
146
  () => !!dom.query('[aria-label="Edit filter Receipts"]'),
92
147
  );
93
148
  dom.click(dom.byLabel("Edit filter Receipts"));
94
- await settleUntil(
95
- dom,
96
- () => !!dom.query('select[aria-label="Destination folder"]'),
97
- );
98
- return dom
99
- .queryAll<HTMLOptionElement>(
100
- 'select[aria-label="Destination folder"] option',
101
- )
102
- .map((option) => option.value)
103
- .filter((value) => value.startsWith("mbx-"));
149
+ await settleUntil(dom, () => !!buttonWithText(dom, "Choose a folder"));
150
+ const choose = buttonWithText(dom, "Choose a folder");
151
+ assert.ok(choose, "the destination field offers the folder tree");
152
+ dom.click(choose);
153
+ await settleUntil(dom, () => rowLabels(dom).length > 0);
154
+ return dom;
155
+ };
156
+
157
+ const openFolder = async (dom: DomHarness, label: string): Promise<void> => {
158
+ dom.click(dom.byLabel(`Move to ${label}`));
159
+ await dom.flush();
104
160
  };
105
161
 
106
- describe("Settings › Filters — move destinations (#236, #540)", () => {
107
- it("offers every folder but the appointed Drafts and Sent, in role order", async () => {
108
- assert.deepEqual(await offeredDestinations(), [
109
- "mbx-inbox",
110
- "mbx-archief",
111
- "mbx-receipts",
162
+ describe("Settings › Filters — move destination (#236, #540, #549)", () => {
163
+ it("offers every folder but the appointed Drafts and Sent", async () => {
164
+ const dom = await openDestinationTree();
165
+ assert.deepEqual(rowLabels(dom), [
166
+ "Move to INBOX",
167
+ "Move to Archief",
168
+ "Move to Bonnetjes",
169
+ "Move to Travel",
170
+ ]);
171
+ });
172
+
173
+ it("reads a renamed folder as the name the account gave it", async () => {
174
+ const dom = await openDestinationTree();
175
+ assert.ok(rowLabels(dom).includes("Move to Bonnetjes"));
176
+ assert.ok(
177
+ !rowLabels(dom).some((label) => label.includes("Receipts")),
178
+ "the provider leaf is never what the row reads as",
179
+ );
180
+ });
181
+
182
+ it("nests a folder under the one holding it", async () => {
183
+ const dom = await openDestinationTree();
184
+ await openFolder(dom, "Travel");
185
+ const nested = dom
186
+ .queryAll("[role=treeitem]")
187
+ .filter((row) => row.getAttribute("aria-label") === "Move to Receipts");
188
+ assert.equal(nested.length, 1);
189
+ assert.equal(nested[0].getAttribute("aria-level"), "3");
190
+ });
191
+
192
+ it("creates a folder inside the one the tree is looking at", async () => {
193
+ const posted: Record<string, unknown>[] = [];
194
+ const dom = await openDestinationTree(posted);
195
+ await openFolder(dom, "Travel");
196
+ dom.click(dom.byLabel("New folder inside Travel"));
197
+ await dom.flush();
198
+ const name = folderNameField(dom);
199
+ assert.ok(name, "the folder name field is on screen");
200
+ dom.type(name, "Hotels");
201
+ const create = buttonWithText(dom, "Create folder");
202
+ assert.ok(create, "the create button is on screen");
203
+ dom.click(create);
204
+ await settleUntil(dom, () => posted.length > 0);
205
+ assert.deepEqual(posted, [
206
+ { fullPath: "INBOX/Travel/Hotels", namespaceType: "personal" },
112
207
  ]);
113
208
  });
114
209
  });
@@ -11,9 +11,10 @@ import { FilterEditorSurface } from "@/components/settings/FilterEditorSurface";
11
11
  import { FiltersList } from "@/components/settings/FiltersList";
12
12
  import { ErrorState } from "@/components/ui/ErrorState";
13
13
  import { useDeleteFilter, useFilterList } from "@/hooks/useFilters";
14
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
14
15
  import { useLabelList } from "@/hooks/useLabels";
15
- import { getMailboxDisplayName } from "@/lib/folder-roles";
16
- import { buildMoveTargets } from "@/lib/move-targets";
16
+ import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
17
+ import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
17
18
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
18
19
 
19
20
  export const Route = createFileRoute("/settings/filters")({
@@ -50,27 +51,33 @@ export function AccountFilters({
50
51
  staleTime: Infinity,
51
52
  });
52
53
 
54
+ const translator = useFolderLabelTranslator();
55
+ const roleMap = useMemo(
56
+ () => buildMailboxRoleMap(account.folderAppointments),
57
+ [account.folderAppointments],
58
+ );
59
+
53
60
  const mailboxName = useCallback(
54
61
  (mailboxId: string): string | undefined => {
55
62
  const mailbox = mailboxesData?.items.find(
56
63
  (item) => item.mailboxId === mailboxId,
57
64
  );
58
- return mailbox ? getMailboxDisplayName(mailbox.fullPath) : undefined;
65
+ if (!mailbox) return undefined;
66
+ return labelForMailbox(mailbox, roleMap.get(mailboxId), translator);
59
67
  },
60
- [mailboxesData?.items],
68
+ [mailboxesData?.items, roleMap, translator],
61
69
  );
62
70
 
63
71
  const folders = useMemo(
64
72
  () =>
65
- buildMoveTargets(
66
- mailboxesData?.items ?? [],
67
- account.folderAppointments,
68
- ).map((mailbox) => ({
69
- id: mailbox.mailboxId,
70
- label: getMailboxDisplayName(mailbox.fullPath),
71
- })),
72
- [mailboxesData?.items, account.folderAppointments],
73
+ buildMoveOptions({
74
+ mailboxes: mailboxesData?.items ?? [],
75
+ folderAppointments: account.folderAppointments,
76
+ translator,
77
+ }),
78
+ [mailboxesData?.items, account.folderAppointments, translator],
73
79
  );
80
+ const delimiter = folderDelimiter(mailboxesData?.items ?? []);
74
81
 
75
82
  const { labels: labelItems } = useLabelList(accountId);
76
83
  const labels: LabelOption[] = useMemo(
@@ -99,6 +106,7 @@ export function AccountFilters({
99
106
  accountId={accountId}
100
107
  filter={editingFilter}
101
108
  folders={folders}
109
+ delimiter={delimiter}
102
110
  labels={labels}
103
111
  onClose={() => setEditingFilterId(undefined)}
104
112
  />