@remit/web-client 0.0.190 → 0.0.191
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/DeleteConfirmDialog.render.test.ts +208 -26
- package/src/components/mail/DeleteConfirmDialog.tsx +53 -17
- package/src/components/mail/MessageList.tsx +71 -62
- package/src/components/mail/RoleAppointmentPromptProvider.render.test.ts +292 -0
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +246 -0
- package/src/components/mail/ThreadListInteraction.test.ts +30 -21
- package/src/components/mail/ThreadListInteraction.tsx +18 -8
- package/src/components/ui/folder-role-refusal.test.ts +94 -0
- package/src/components/ui/folder-role-refusal.ts +81 -0
- package/src/hooks/useArchiveMailbox.ts +10 -5
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/lib/format.test.ts +133 -11
- package/src/lib/format.ts +95 -17
- package/src/lib/move-options.ts +5 -0
- package/src/routes/__root.tsx +14 -11
- package/src/routes/settings/folders.tsx +12 -3
- package/src/test-support/dom.ts +6 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ceremony behind one press (#887): appoint the folder, wait for the 200,
|
|
3
|
+
* invalidate and await `/config`, then re-issue the action that was refused.
|
|
4
|
+
* Driven against the real fetch seam, so the order the requests actually leave
|
|
5
|
+
* in is what is pinned — D16 item 1 exists because `useTrashByAccount` reads at
|
|
6
|
+
* `staleTime: Infinity` and would otherwise word the retry from the answer the
|
|
7
|
+
* appointment just replaced.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
11
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
12
|
+
import { createElement } from "react";
|
|
13
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
14
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
15
|
+
import {
|
|
16
|
+
type AppointmentRequest,
|
|
17
|
+
RoleAppointmentPromptProvider,
|
|
18
|
+
useRoleAppointmentPrompt,
|
|
19
|
+
} from "./RoleAppointmentPromptProvider";
|
|
20
|
+
|
|
21
|
+
const ACCOUNT = "acc-1";
|
|
22
|
+
const OTHER_ACCOUNT = "acc-2";
|
|
23
|
+
const PICK_TRASH = "Set Prullenbak, 3 messages, as Trash";
|
|
24
|
+
const CONFIRM = "Set as Trash and delete 2 messages";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock;
|
|
28
|
+
|
|
29
|
+
const CONFIG = {
|
|
30
|
+
accounts: [ACCOUNT, OTHER_ACCOUNT].map((accountId) => ({
|
|
31
|
+
accountId,
|
|
32
|
+
email: `${accountId}@example.com`,
|
|
33
|
+
folderAppointments: [{ role: "Trash", source: "None" }],
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MAILBOXES = {
|
|
38
|
+
items: [
|
|
39
|
+
{
|
|
40
|
+
mailboxId: "mbx-inbox",
|
|
41
|
+
accountId: ACCOUNT,
|
|
42
|
+
fullPath: "INBOX",
|
|
43
|
+
hierarchyDelimiter: "/",
|
|
44
|
+
messageCount: 12,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
mailboxId: "mbx-trash",
|
|
48
|
+
accountId: ACCOUNT,
|
|
49
|
+
fullPath: "Prullenbak",
|
|
50
|
+
hierarchyDelimiter: "/",
|
|
51
|
+
messageCount: 3,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Everything answers; a test that needs a failure re-mocks the seam. */
|
|
57
|
+
const respond = (path: string): unknown => {
|
|
58
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
59
|
+
if (path.endsWith("/mailboxes")) return MAILBOXES;
|
|
60
|
+
return {};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const mountProvider = (): ((next: AppointmentRequest) => void) => {
|
|
64
|
+
let request: ((next: AppointmentRequest) => void) | undefined;
|
|
65
|
+
const Probe = () => {
|
|
66
|
+
request = useRoleAppointmentPrompt().requestAppointment;
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
harness = createDomHarness();
|
|
70
|
+
harness.render(
|
|
71
|
+
createElement(
|
|
72
|
+
QueryClientProvider,
|
|
73
|
+
{ client: harness.queryClient },
|
|
74
|
+
createElement(RoleAppointmentPromptProvider, null, createElement(Probe)),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
if (!request) throw new Error("the provider did not render");
|
|
78
|
+
return request;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Two writes, a query invalidation and the refetch it waits on all sit behind
|
|
83
|
+
* one press, and each hop is a real fetch. Rounds are bounded and the loop
|
|
84
|
+
* stops as soon as `done` holds, so a slow machine costs turns, never a pass.
|
|
85
|
+
*/
|
|
86
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
87
|
+
if (!harness) throw new Error("nothing mounted");
|
|
88
|
+
for (let round = 0; round < 40; round += 1) {
|
|
89
|
+
await harness.flush();
|
|
90
|
+
await harness.wait(0);
|
|
91
|
+
if (done()) return;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const press = async (label: string, done?: () => boolean): Promise<void> => {
|
|
96
|
+
if (!harness) throw new Error("nothing mounted");
|
|
97
|
+
harness.click(harness.byText("button", label));
|
|
98
|
+
await settle(done);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const refusedDelete = (
|
|
102
|
+
over: Partial<AppointmentRequest> = {},
|
|
103
|
+
): AppointmentRequest => ({
|
|
104
|
+
accountId: ACCOUNT,
|
|
105
|
+
role: "Trash",
|
|
106
|
+
reason: "none",
|
|
107
|
+
action: { kind: "delete", count: 2 },
|
|
108
|
+
onAppointed: async () => {},
|
|
109
|
+
...over,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const onScreen = (label: string): boolean =>
|
|
113
|
+
(harness?.queryAll("button") ?? []).some(
|
|
114
|
+
(button) => button.textContent === label,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** Pick the Trash folder in the picker, then press the confirm. */
|
|
118
|
+
const confirmWith = async (done?: () => boolean): Promise<void> => {
|
|
119
|
+
if (!harness) throw new Error("nothing mounted");
|
|
120
|
+
harness.click(harness.byLabel(PICK_TRASH));
|
|
121
|
+
await settle(() => onScreen(CONFIRM));
|
|
122
|
+
await press(CONFIRM, done);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
http = mockFetch((call) => respond(call.path));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
harness?.close();
|
|
131
|
+
harness = undefined;
|
|
132
|
+
http.restore();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("the appointment ceremony", () => {
|
|
136
|
+
it("asks nothing of the server until an action is refused", async () => {
|
|
137
|
+
mountProvider();
|
|
138
|
+
await settle();
|
|
139
|
+
assert.deepEqual(http.calls, []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("puts the account's folders, with their counts, in the picker", async () => {
|
|
143
|
+
const request = mountProvider();
|
|
144
|
+
request(refusedDelete());
|
|
145
|
+
await settle();
|
|
146
|
+
|
|
147
|
+
assert.match(harness?.text() ?? "", /No folder is set as Trash/);
|
|
148
|
+
assert.ok(
|
|
149
|
+
harness?.query(`[aria-label="${PICK_TRASH}"]`),
|
|
150
|
+
"the count is part of the row's own accessible name",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("names the account only where the instance holds more than one", async () => {
|
|
155
|
+
const request = mountProvider();
|
|
156
|
+
request(refusedDelete());
|
|
157
|
+
await settle();
|
|
158
|
+
assert.match(harness?.text() ?? "", /acc-1@example\.com/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("appoints, re-reads /config, and only then re-issues the action", async () => {
|
|
162
|
+
const order: string[] = [];
|
|
163
|
+
http.restore();
|
|
164
|
+
http = mockFetch((call) => {
|
|
165
|
+
order.push(`${call.method} ${call.path}`);
|
|
166
|
+
return respond(call.path);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const request = mountProvider();
|
|
170
|
+
request(
|
|
171
|
+
refusedDelete({
|
|
172
|
+
onAppointed: async () => {
|
|
173
|
+
order.push("replay");
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
await settle();
|
|
178
|
+
await confirmWith(() => order.includes("replay"));
|
|
179
|
+
|
|
180
|
+
const appointAt = order.findIndex((entry) =>
|
|
181
|
+
entry.startsWith(`PUT /accounts/${ACCOUNT}/folder-roles/Trash`),
|
|
182
|
+
);
|
|
183
|
+
const replayAt = order.indexOf("replay");
|
|
184
|
+
const refetchAt = order.findIndex(
|
|
185
|
+
(entry, index) => index > appointAt && entry.endsWith("/config"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
assert.ok(appointAt >= 0, "the appointment is written");
|
|
189
|
+
assert.ok(refetchAt > appointAt, "/config is re-read after the 200");
|
|
190
|
+
assert.ok(
|
|
191
|
+
replayAt > refetchAt,
|
|
192
|
+
"the action is re-issued only once the fresh answer is in",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("takes the ceremony down once the replay has run", async () => {
|
|
197
|
+
const request = mountProvider();
|
|
198
|
+
request(refusedDelete());
|
|
199
|
+
await settle();
|
|
200
|
+
await confirmWith(
|
|
201
|
+
() => !(harness?.text() ?? "").includes("No folder is set as Trash"),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
assert.doesNotMatch(harness?.text() ?? "", /No folder is set as Trash/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps the ceremony up, with the selection, when the appointment fails", async () => {
|
|
208
|
+
http.restore();
|
|
209
|
+
http = mockFetch((call) => {
|
|
210
|
+
if (!call.path.includes("/folder-roles/")) return respond(call.path);
|
|
211
|
+
return new Response(
|
|
212
|
+
JSON.stringify({
|
|
213
|
+
code: "mailbox_not_settled",
|
|
214
|
+
message: "not settled",
|
|
215
|
+
details: { mailboxId: "mbx-trash", syncStatus: "pending" },
|
|
216
|
+
}),
|
|
217
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
let replayed = 0;
|
|
222
|
+
const request = mountProvider();
|
|
223
|
+
request(
|
|
224
|
+
refusedDelete({
|
|
225
|
+
onAppointed: async () => {
|
|
226
|
+
replayed += 1;
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
await settle();
|
|
231
|
+
await confirmWith();
|
|
232
|
+
|
|
233
|
+
assert.equal(replayed, 0, "nothing is re-issued over a failed appointment");
|
|
234
|
+
assert.match(
|
|
235
|
+
harness?.text() ?? "",
|
|
236
|
+
/still being created on the mail server/,
|
|
237
|
+
"a wait is worded as a wait, not as a retry",
|
|
238
|
+
);
|
|
239
|
+
assert.ok(
|
|
240
|
+
onScreen(CONFIRM),
|
|
241
|
+
"the confirm is still pressable — retry in place",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The blocking case. A selection spanning two accounts that both lack a
|
|
247
|
+
* Trash is refused a second time the moment the replay runs, and that
|
|
248
|
+
* refusal raises its own prompt. Tearing the finished ceremony down
|
|
249
|
+
* unconditionally would destroy it in the same tick, leaving the rows rolled
|
|
250
|
+
* back and no account of why.
|
|
251
|
+
*/
|
|
252
|
+
it("leaves a prompt the replay raised alone", async () => {
|
|
253
|
+
const request = mountProvider();
|
|
254
|
+
request(
|
|
255
|
+
refusedDelete({
|
|
256
|
+
onAppointed: async () => {
|
|
257
|
+
request(
|
|
258
|
+
refusedDelete({
|
|
259
|
+
accountId: OTHER_ACCOUNT,
|
|
260
|
+
reason: "stale",
|
|
261
|
+
staleFolderLabel: "INBOX/Weg",
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
await settle();
|
|
268
|
+
await confirmWith(() =>
|
|
269
|
+
(harness?.text() ?? "").includes("The Trash folder you chose is gone"),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
assert.match(
|
|
273
|
+
harness?.text() ?? "",
|
|
274
|
+
/The Trash folder you chose is gone/,
|
|
275
|
+
"the second refusal is on screen, not swallowed",
|
|
276
|
+
);
|
|
277
|
+
assert.match(harness?.text() ?? "", /INBOX\/Weg/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("closes on cancel, and writes nothing", async () => {
|
|
281
|
+
const request = mountProvider();
|
|
282
|
+
request(refusedDelete());
|
|
283
|
+
await settle();
|
|
284
|
+
await press("Cancel");
|
|
285
|
+
|
|
286
|
+
assert.doesNotMatch(harness?.text() ?? "", /No folder is set as Trash/);
|
|
287
|
+
assert.deepEqual(
|
|
288
|
+
http.calls.filter((call) => call.path.includes("/folder-roles/")),
|
|
289
|
+
[],
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configOperationsGetConfigOptions,
|
|
3
|
+
configOperationsGetConfigQueryKey,
|
|
4
|
+
folderRoleOperationsAppointFolderRoleMutation,
|
|
5
|
+
mailboxOperationsListMailboxesOptions,
|
|
6
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
7
|
+
import type { RemitImapCanonicalMailboxRole } from "@remit/api-http-client/types.gen.ts";
|
|
8
|
+
import {
|
|
9
|
+
type PromptAction,
|
|
10
|
+
type PromptPhase,
|
|
11
|
+
type PromptReason,
|
|
12
|
+
RoleAppointmentPrompt,
|
|
13
|
+
} from "@remit/ui";
|
|
14
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
15
|
+
import {
|
|
16
|
+
createContext,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
useCallback,
|
|
19
|
+
useContext,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
} from "react";
|
|
24
|
+
import { isMailboxNotSettledRefusal } from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import { useCreateMailbox } from "@/hooks/useCreateMailbox";
|
|
26
|
+
import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
|
|
27
|
+
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
28
|
+
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
29
|
+
|
|
30
|
+
export interface AppointmentRequest {
|
|
31
|
+
accountId: string;
|
|
32
|
+
role: RemitImapCanonicalMailboxRole;
|
|
33
|
+
/** From the refusal that opened this, never re-derived from live state. */
|
|
34
|
+
reason: PromptReason;
|
|
35
|
+
action: PromptAction;
|
|
36
|
+
/** `unconfirmed`: the folder reader guessed. Filled from `/config` if absent. */
|
|
37
|
+
trashFolderLabel?: string;
|
|
38
|
+
/** `stale`: the folder that vanished. Filled from `/config` if absent. */
|
|
39
|
+
staleFolderLabel?: string;
|
|
40
|
+
guessedMailboxId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Re-issues the caller's own action once the folder is appointed. The caller
|
|
43
|
+
* owns it so its optimistic and cache machinery is not duplicated here — and
|
|
44
|
+
* so a chunked run replays the whole original selection, not the chunk the
|
|
45
|
+
* server happened to refuse.
|
|
46
|
+
*/
|
|
47
|
+
onAppointed: () => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RoleAppointmentPromptContextValue {
|
|
51
|
+
requestAppointment: (request: AppointmentRequest) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RoleAppointmentPromptContext = createContext<
|
|
55
|
+
RoleAppointmentPromptContextValue | undefined
|
|
56
|
+
>(undefined);
|
|
57
|
+
|
|
58
|
+
/** The appointment write failed, so nothing after it may run. */
|
|
59
|
+
const APPOINT_FAILED = Symbol("appoint-failed");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The appointment ceremony, mounted once and reached from either entry path.
|
|
63
|
+
* It owns both writes' order (D16 item 1): appoint, wait for the 200,
|
|
64
|
+
* invalidate and await `/config`, then re-issue the action that was refused.
|
|
65
|
+
*/
|
|
66
|
+
export const RoleAppointmentPromptProvider = ({
|
|
67
|
+
children,
|
|
68
|
+
}: {
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
}) => {
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const translator = useFolderLabelTranslator();
|
|
73
|
+
const [request, setRequest] = useState<AppointmentRequest | null>(null);
|
|
74
|
+
const [selectedId, setSelectedId] = useState<string>();
|
|
75
|
+
const [phase, setPhase] = useState<PromptPhase>({ kind: "choosing" });
|
|
76
|
+
|
|
77
|
+
const accountId = request?.accountId;
|
|
78
|
+
// Nothing is asked of the server until an action is actually refused: this
|
|
79
|
+
// provider is mounted at the root, so an ungated read would put a request
|
|
80
|
+
// behind every screen that never opens a prompt.
|
|
81
|
+
const { data: config } = useQuery({
|
|
82
|
+
...configOperationsGetConfigOptions(),
|
|
83
|
+
staleTime: Infinity,
|
|
84
|
+
enabled: !!accountId,
|
|
85
|
+
});
|
|
86
|
+
const { data: mailboxData } = useQuery({
|
|
87
|
+
...mailboxOperationsListMailboxesOptions({
|
|
88
|
+
path: { accountId: accountId ?? "" },
|
|
89
|
+
}),
|
|
90
|
+
enabled: !!accountId,
|
|
91
|
+
});
|
|
92
|
+
const { createFolderIn } = useCreateMailbox(accountId);
|
|
93
|
+
const appointMutation = useMutation(
|
|
94
|
+
folderRoleOperationsAppointFolderRoleMutation(),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const account = config?.accounts.find((one) => one.accountId === accountId);
|
|
98
|
+
const mailboxes = useMemo(() => mailboxData?.items ?? [], [mailboxData]);
|
|
99
|
+
const appointments = useMemo(
|
|
100
|
+
() => account?.folderAppointments ?? [],
|
|
101
|
+
[account],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const folders = useMemo(
|
|
105
|
+
() =>
|
|
106
|
+
buildMoveOptions({
|
|
107
|
+
mailboxes,
|
|
108
|
+
folderAppointments: appointments,
|
|
109
|
+
translator,
|
|
110
|
+
}),
|
|
111
|
+
[mailboxes, appointments, translator],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const trash = appointments.find(
|
|
115
|
+
(appointment) => appointment.role === request?.role,
|
|
116
|
+
);
|
|
117
|
+
const guessedMailboxId = request?.guessedMailboxId ?? trash?.mailboxId;
|
|
118
|
+
const guessed = mailboxes.find(
|
|
119
|
+
(mailbox) => mailbox.mailboxId === guessedMailboxId,
|
|
120
|
+
);
|
|
121
|
+
const trashFolderLabel =
|
|
122
|
+
request?.trashFolderLabel ??
|
|
123
|
+
(guessed
|
|
124
|
+
? labelForMailbox(
|
|
125
|
+
guessed,
|
|
126
|
+
buildMailboxRoleMap(appointments).get(guessed.mailboxId),
|
|
127
|
+
translator,
|
|
128
|
+
)
|
|
129
|
+
: undefined);
|
|
130
|
+
|
|
131
|
+
// Written synchronously so a refusal raised during the replay is already the
|
|
132
|
+
// live request by the time the finished ceremony tries to tear itself down.
|
|
133
|
+
const liveRequest = useRef<AppointmentRequest | null>(null);
|
|
134
|
+
|
|
135
|
+
const close = useCallback(() => {
|
|
136
|
+
liveRequest.current = null;
|
|
137
|
+
setRequest(null);
|
|
138
|
+
setSelectedId(undefined);
|
|
139
|
+
setPhase({ kind: "choosing" });
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const requestAppointment = useCallback((next: AppointmentRequest) => {
|
|
143
|
+
liveRequest.current = next;
|
|
144
|
+
setRequest(next);
|
|
145
|
+
setPhase({ kind: "choosing" });
|
|
146
|
+
// Only a confirmation of an existing guess starts with something chosen:
|
|
147
|
+
// the common case is one tap, and the tree is there to correct it.
|
|
148
|
+
setSelectedId(
|
|
149
|
+
next.reason === "unconfirmed" ? next.guessedMailboxId : undefined,
|
|
150
|
+
);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Tear the ceremony down only if it is still the one on screen. The replay
|
|
155
|
+
* can be refused a second time — a selection spanning two accounts that both
|
|
156
|
+
* lack a Trash — and that refusal opens its own prompt before this one
|
|
157
|
+
* finishes. Closing unconditionally would destroy it in the same tick, with
|
|
158
|
+
* the rows rolled back and no account of why.
|
|
159
|
+
*/
|
|
160
|
+
const closeFinished = useCallback(
|
|
161
|
+
(finished: AppointmentRequest) => {
|
|
162
|
+
if (liveRequest.current !== finished) return;
|
|
163
|
+
close();
|
|
164
|
+
},
|
|
165
|
+
[close],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const handleConfirm = useCallback(
|
|
169
|
+
(mailboxId: string) => {
|
|
170
|
+
if (!request) return;
|
|
171
|
+
setPhase({ kind: "appointing" });
|
|
172
|
+
void appointMutation
|
|
173
|
+
.mutateAsync({
|
|
174
|
+
path: { accountId: request.accountId, role: request.role },
|
|
175
|
+
body: { mailboxId },
|
|
176
|
+
})
|
|
177
|
+
.catch((error: unknown) => {
|
|
178
|
+
setPhase({
|
|
179
|
+
kind: "appoint-failed",
|
|
180
|
+
cause: isMailboxNotSettledRefusal(error)
|
|
181
|
+
? "mailbox-pending"
|
|
182
|
+
: "generic",
|
|
183
|
+
});
|
|
184
|
+
return APPOINT_FAILED;
|
|
185
|
+
})
|
|
186
|
+
.then(async (result) => {
|
|
187
|
+
if (result === APPOINT_FAILED) return;
|
|
188
|
+
await queryClient.invalidateQueries({
|
|
189
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
190
|
+
});
|
|
191
|
+
setPhase({ kind: "acting" });
|
|
192
|
+
await request.onAppointed();
|
|
193
|
+
closeFinished(request);
|
|
194
|
+
})
|
|
195
|
+
// The replay is the caller's mutation and banners its own failure;
|
|
196
|
+
// leaving the ceremony up over it would ask for the folder twice.
|
|
197
|
+
.catch(() => closeFinished(request));
|
|
198
|
+
},
|
|
199
|
+
[request, appointMutation, queryClient, closeFinished],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const value = useMemo(() => ({ requestAppointment }), [requestAppointment]);
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<RoleAppointmentPromptContext.Provider value={value}>
|
|
206
|
+
{children}
|
|
207
|
+
{request && (
|
|
208
|
+
<RoleAppointmentPrompt
|
|
209
|
+
open
|
|
210
|
+
reason={request.reason}
|
|
211
|
+
action={request.action}
|
|
212
|
+
folders={folders}
|
|
213
|
+
delimiter={folderDelimiter(mailboxes)}
|
|
214
|
+
trashFolderLabel={trashFolderLabel}
|
|
215
|
+
staleFolderLabel={
|
|
216
|
+
request.staleFolderLabel ?? trash?.staleAppointmentPath
|
|
217
|
+
}
|
|
218
|
+
accountEmail={
|
|
219
|
+
(config?.accounts.length ?? 0) > 1 ? account?.email : undefined
|
|
220
|
+
}
|
|
221
|
+
phase={phase}
|
|
222
|
+
selectedId={selectedId}
|
|
223
|
+
onSelect={setSelectedId}
|
|
224
|
+
onCreateFolder={createFolderIn}
|
|
225
|
+
onConfirm={handleConfirm}
|
|
226
|
+
onCancel={close}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
</RoleAppointmentPromptContext.Provider>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Opens the appointment ceremony for a refused action. The caller keeps the
|
|
235
|
+
* replay, so the action that was stopped is the action that runs.
|
|
236
|
+
*/
|
|
237
|
+
export const useRoleAppointmentPrompt =
|
|
238
|
+
(): RoleAppointmentPromptContextValue => {
|
|
239
|
+
const context = useContext(RoleAppointmentPromptContext);
|
|
240
|
+
if (!context) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"useRoleAppointmentPrompt must be used within a RoleAppointmentPromptProvider",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return context;
|
|
246
|
+
};
|
|
@@ -23,6 +23,7 @@ import { AuthProviderProvider, noneAuthProvider } from "@/auth/provider";
|
|
|
23
23
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
24
24
|
import { makeAccount, makeConfig } from "@/test-support/fixtures";
|
|
25
25
|
import type { MessageListCommands } from "./MessageList";
|
|
26
|
+
import { RoleAppointmentPromptProvider } from "./RoleAppointmentPromptProvider";
|
|
26
27
|
import {
|
|
27
28
|
ThreadListInteraction,
|
|
28
29
|
useThreadListSelection,
|
|
@@ -137,16 +138,20 @@ function mountList(options: {
|
|
|
137
138
|
ErrorBannerProvider,
|
|
138
139
|
null,
|
|
139
140
|
createElement(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
RoleAppointmentPromptProvider,
|
|
142
|
+
null,
|
|
143
|
+
createElement(
|
|
144
|
+
ThreadListInteraction,
|
|
145
|
+
{
|
|
146
|
+
selectedMessageId: undefined,
|
|
147
|
+
rows,
|
|
148
|
+
onOpen: () => undefined,
|
|
149
|
+
onDeleteMessages,
|
|
150
|
+
onSelectionVerb,
|
|
151
|
+
commandsRef,
|
|
152
|
+
},
|
|
153
|
+
...rowElements(ids),
|
|
154
|
+
),
|
|
150
155
|
),
|
|
151
156
|
),
|
|
152
157
|
),
|
|
@@ -469,17 +474,21 @@ function mountSelectableList(initialIds: string[]) {
|
|
|
469
474
|
ErrorBannerProvider,
|
|
470
475
|
null,
|
|
471
476
|
createElement(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
477
|
+
RoleAppointmentPromptProvider,
|
|
478
|
+
null,
|
|
479
|
+
createElement(
|
|
480
|
+
ThreadListInteraction,
|
|
481
|
+
{
|
|
482
|
+
selectedMessageId: undefined,
|
|
483
|
+
rows: initialIds.map((id) => row(id)),
|
|
484
|
+
onOpen: () => undefined,
|
|
485
|
+
onDeleteMessages: () => undefined,
|
|
486
|
+
onSelectionVerb: () => undefined,
|
|
487
|
+
commandsRef,
|
|
488
|
+
},
|
|
489
|
+
...rowElements(ids),
|
|
490
|
+
createElement(Probe, { key: "probe" }),
|
|
491
|
+
),
|
|
483
492
|
),
|
|
484
493
|
),
|
|
485
494
|
),
|
|
@@ -285,7 +285,11 @@ export function ThreadListInteraction({
|
|
|
285
285
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
|
|
286
286
|
null,
|
|
287
287
|
);
|
|
288
|
-
const
|
|
288
|
+
const {
|
|
289
|
+
outcome: deleteOutcome,
|
|
290
|
+
trashIsUnconfirmed,
|
|
291
|
+
staleFolderLabel,
|
|
292
|
+
} = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
|
|
289
293
|
|
|
290
294
|
// A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
|
|
291
295
|
// selection every verb opens the wizard, so the keyboard cannot reach a bulk
|
|
@@ -335,12 +339,15 @@ export function ThreadListInteraction({
|
|
|
335
339
|
],
|
|
336
340
|
);
|
|
337
341
|
|
|
338
|
-
const confirmDelete = useCallback(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
const confirmDelete = useCallback(
|
|
343
|
+
(ids: string[]) => {
|
|
344
|
+
if (ids.length === 0) return;
|
|
345
|
+
onDeleteMessages(ids);
|
|
346
|
+
setPendingDelete(null);
|
|
347
|
+
exitSelection();
|
|
348
|
+
},
|
|
349
|
+
[onDeleteMessages, exitSelection],
|
|
350
|
+
);
|
|
344
351
|
|
|
345
352
|
const cancelDelete = useCallback(() => setPendingDelete(null), []);
|
|
346
353
|
|
|
@@ -471,8 +478,11 @@ export function ThreadListInteraction({
|
|
|
471
478
|
</div>
|
|
472
479
|
<DeleteConfirmDialog
|
|
473
480
|
isOpen={confirmOpen}
|
|
474
|
-
|
|
481
|
+
messageIds={pendingDelete?.ids ?? []}
|
|
475
482
|
outcome={deleteOutcome}
|
|
483
|
+
accountId={pendingDelete?.targets[0]?.accountId}
|
|
484
|
+
staleFolderLabel={staleFolderLabel}
|
|
485
|
+
trashIsUnconfirmed={trashIsUnconfirmed}
|
|
476
486
|
isDeleting={isDeleting}
|
|
477
487
|
onConfirm={confirmDelete}
|
|
478
488
|
onCancel={cancelDelete}
|