@remit/web-client 0.0.190 → 0.0.192
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/EmptyTrashBar.render.test.ts +133 -0
- package/src/components/mail/EmptyTrashBar.tsx +181 -0
- package/src/components/mail/MailboxPane.tsx +39 -1
- 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/mail/empty-trash-bar.stories.tsx +121 -0
- 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 +21 -5
- package/src/hooks/useCurrentMailboxName.ts +33 -0
- package/src/hooks/useDeleteMessages.ts +41 -5
- package/src/hooks/useDeleteOutcome.ts +37 -7
- package/src/hooks/useEmptyTrash.render.test.ts +194 -0
- package/src/hooks/useEmptyTrash.ts +187 -0
- 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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a refusal and a report are *about* (#847). The mailbox pane never
|
|
3
|
+
* remounts on a route change, so both are scoped: account A's standing refusal
|
|
4
|
+
* must not render over account B's Trash, and a count from a folder that was
|
|
5
|
+
* emptied must not outlive the folder it counted. The empty is also issued
|
|
6
|
+
* against the Trash `/config` names at that moment, so the replay after a
|
|
7
|
+
* repair invalidates the folder it actually emptied.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
14
|
+
import { act, createElement } from "react";
|
|
15
|
+
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
16
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
18
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
19
|
+
import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
|
|
20
|
+
|
|
21
|
+
const ACCOUNT_A = "acc-a";
|
|
22
|
+
const ACCOUNT_B = "acc-b";
|
|
23
|
+
const TRASH_A = "mbx-trash-a";
|
|
24
|
+
const TRASH_B = "mbx-trash-b";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock | undefined;
|
|
28
|
+
let state: EmptyTrashState | undefined;
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
harness?.close();
|
|
32
|
+
harness = undefined;
|
|
33
|
+
http?.restore();
|
|
34
|
+
http = undefined;
|
|
35
|
+
state = undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const CONFIG = {
|
|
39
|
+
accounts: [
|
|
40
|
+
{
|
|
41
|
+
accountId: ACCOUNT_A,
|
|
42
|
+
email: "a@example.com",
|
|
43
|
+
folderAppointments: [
|
|
44
|
+
{ role: "Trash", mailboxId: TRASH_A, source: "Proposed" },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
accountId: ACCOUNT_B,
|
|
49
|
+
email: "b@example.com",
|
|
50
|
+
folderAppointments: [
|
|
51
|
+
{ role: "Trash", mailboxId: TRASH_B, source: "Appointed" },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Fresh every call — a `Response` body can only be read once. */
|
|
58
|
+
const refusal = (): Response =>
|
|
59
|
+
new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
status: 409,
|
|
62
|
+
message: "Trash is not confirmed",
|
|
63
|
+
code: "folder_role_unresolved",
|
|
64
|
+
details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT_A },
|
|
65
|
+
}),
|
|
66
|
+
{ status: 409, headers: { "content-type": "application/json" } },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** Account A refuses the empty; account B allows it. */
|
|
70
|
+
const respond = (path: string): unknown => {
|
|
71
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
72
|
+
if (path.endsWith(`/accounts/${ACCOUNT_A}/trash/empty`)) return refusal();
|
|
73
|
+
if (path.endsWith(`/accounts/${ACCOUNT_B}/trash/empty`))
|
|
74
|
+
return { deletedCount: 7 };
|
|
75
|
+
return {};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const Probe = ({
|
|
79
|
+
accountId,
|
|
80
|
+
mailboxId,
|
|
81
|
+
}: {
|
|
82
|
+
accountId: string;
|
|
83
|
+
mailboxId: string;
|
|
84
|
+
}) => {
|
|
85
|
+
state = useEmptyTrash({ accountId, mailboxId });
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const tree = (accountId: string, mailboxId: string) =>
|
|
90
|
+
createElement(
|
|
91
|
+
QueryClientProvider,
|
|
92
|
+
{ client: harness?.queryClient as never },
|
|
93
|
+
createElement(
|
|
94
|
+
ErrorBannerProvider,
|
|
95
|
+
null,
|
|
96
|
+
createElement(
|
|
97
|
+
RoleAppointmentPromptProvider,
|
|
98
|
+
null,
|
|
99
|
+
createElement(Probe, { accountId, mailboxId }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
105
|
+
if (!harness) throw new Error("nothing mounted");
|
|
106
|
+
for (let round = 0; round < 40; round += 1) {
|
|
107
|
+
await harness.flush();
|
|
108
|
+
await harness.wait(0);
|
|
109
|
+
if (done()) return;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const mount = async (accountId: string, mailboxId: string): Promise<void> => {
|
|
114
|
+
http = mockFetch((call) => respond(call.path));
|
|
115
|
+
harness = createDomHarness();
|
|
116
|
+
await harness.renderAsync(tree(accountId, mailboxId));
|
|
117
|
+
await settle(() => state !== undefined);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The same mounted pane, now looking at another folder — never a remount. */
|
|
121
|
+
const openInstead = async (
|
|
122
|
+
accountId: string,
|
|
123
|
+
mailboxId: string,
|
|
124
|
+
): Promise<void> => {
|
|
125
|
+
if (!harness) throw new Error("nothing mounted");
|
|
126
|
+
await harness.renderAsync(tree(accountId, mailboxId));
|
|
127
|
+
await settle();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const press = async (done: () => boolean): Promise<void> => {
|
|
131
|
+
await act(async () => {
|
|
132
|
+
state?.emptyTrash();
|
|
133
|
+
});
|
|
134
|
+
await settle(done);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("useEmptyTrash scope", () => {
|
|
138
|
+
it("drops a refusal raised on one mailbox when another is opened", async () => {
|
|
139
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
140
|
+
await press(() => state?.refusal !== undefined);
|
|
141
|
+
assert.equal(state?.refusal?.reason, "unconfirmed");
|
|
142
|
+
assert.equal(state?.refusal?.accountId, ACCOUNT_A);
|
|
143
|
+
|
|
144
|
+
await openInstead(ACCOUNT_B, TRASH_B);
|
|
145
|
+
assert.equal(state?.refusal, undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("drops a report of what was emptied when another mailbox is opened", async () => {
|
|
149
|
+
await mount(ACCOUNT_B, TRASH_B);
|
|
150
|
+
await press(() => state?.deletedCount !== undefined);
|
|
151
|
+
assert.equal(state?.deletedCount, 7);
|
|
152
|
+
|
|
153
|
+
await openInstead(ACCOUNT_A, TRASH_A);
|
|
154
|
+
assert.equal(state?.deletedCount, undefined);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("issues the empty against the account the pane is on", async () => {
|
|
158
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
159
|
+
await press(() => state?.refusal !== undefined);
|
|
160
|
+
await openInstead(ACCOUNT_B, TRASH_B);
|
|
161
|
+
await press(() => state?.deletedCount !== undefined);
|
|
162
|
+
|
|
163
|
+
const empties = (http?.calls ?? []).filter((call) =>
|
|
164
|
+
call.path.endsWith("/trash/empty"),
|
|
165
|
+
);
|
|
166
|
+
assert.equal(empties.length, 2);
|
|
167
|
+
assert.ok(empties[0].path.endsWith(`/accounts/${ACCOUNT_A}/trash/empty`));
|
|
168
|
+
assert.ok(empties[1].path.endsWith(`/accounts/${ACCOUNT_B}/trash/empty`));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("drops the refusal once the account's Trash resolution changes", async () => {
|
|
172
|
+
await mount(ACCOUNT_A, TRASH_A);
|
|
173
|
+
await press(() => state?.refusal !== undefined);
|
|
174
|
+
assert.ok(state?.refusal);
|
|
175
|
+
|
|
176
|
+
// A repair made anywhere else — the settings pane, another tab — lands
|
|
177
|
+
// here as a new `/config`, and the refusal it answered is spent.
|
|
178
|
+
await act(async () => {
|
|
179
|
+
harness?.queryClient.setQueryData(configOperationsGetConfigQueryKey(), {
|
|
180
|
+
accounts: [
|
|
181
|
+
{
|
|
182
|
+
...CONFIG.accounts[0],
|
|
183
|
+
folderAppointments: [
|
|
184
|
+
{ role: "Trash", mailboxId: TRASH_A, source: "Appointed" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
CONFIG.accounts[1],
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
await settle(() => state?.refusal === undefined);
|
|
192
|
+
assert.equal(state?.refusal, undefined);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emptying an account's Trash, and the refusal the server answers it with.
|
|
3
|
+
*
|
|
4
|
+
* The press always reaches the server (#847): the client's `/config` is a read
|
|
5
|
+
* that can be stale, so a folder-role refusal is the server's to make, and the
|
|
6
|
+
* 409 is the only authority the surface listens to. On that refusal the strip
|
|
7
|
+
* states it in place and the repair opens the appointment prompt, which replays
|
|
8
|
+
* the empty once a folder is confirmed (#887 D16 item 1).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
configOperationsGetConfigQueryKey,
|
|
12
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
13
|
+
trashOperationsEmptyTrashMutation,
|
|
14
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
+
import type { ConfigOperationsGetConfigResponse } from "@remit/api-http-client/types.gen.ts";
|
|
16
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
17
|
+
import { useCallback, useRef, useState } from "react";
|
|
18
|
+
import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
19
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
20
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
21
|
+
import {
|
|
22
|
+
type FolderRoleRefusal,
|
|
23
|
+
isFolderRoleRefusal,
|
|
24
|
+
} from "@/components/ui/folder-role-refusal";
|
|
25
|
+
import {
|
|
26
|
+
invalidateThreadListQueries,
|
|
27
|
+
threadListCacheKeys,
|
|
28
|
+
} from "@/lib/thread-list-cache";
|
|
29
|
+
import { useTrashByAccount } from "./useArchiveMailbox";
|
|
30
|
+
|
|
31
|
+
interface UseEmptyTrashOptions {
|
|
32
|
+
accountId: string | undefined;
|
|
33
|
+
/** The open mailbox the strip is mounted over. */
|
|
34
|
+
mailboxId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EmptyTrashState {
|
|
38
|
+
emptyTrash: () => void;
|
|
39
|
+
isEmptying: boolean;
|
|
40
|
+
/** What the last finished run reported, straight from the service. */
|
|
41
|
+
deletedCount: number | undefined;
|
|
42
|
+
refusal: FolderRoleRefusal | undefined;
|
|
43
|
+
repair: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a report or a refusal is about. Both are facts about one account's
|
|
48
|
+
* Trash as it resolved at the time, so neither may survive the mailbox or the
|
|
49
|
+
* account changing under a pane that never remounts — nor a resolution
|
|
50
|
+
* repaired elsewhere, which is the "or the resolution changes" the refusal
|
|
51
|
+
* persists until.
|
|
52
|
+
*/
|
|
53
|
+
const scopeOf = (
|
|
54
|
+
accountId: string | undefined,
|
|
55
|
+
mailboxId: string,
|
|
56
|
+
trashMailboxId: string | undefined,
|
|
57
|
+
source: string | undefined,
|
|
58
|
+
): string =>
|
|
59
|
+
`${accountId ?? ""}|${mailboxId}|${trashMailboxId ?? ""}|${source ?? ""}`;
|
|
60
|
+
|
|
61
|
+
interface EmptyTrashRun {
|
|
62
|
+
scope: string;
|
|
63
|
+
deletedCount?: number;
|
|
64
|
+
refusal?: FolderRoleRefusal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface EmptyTrashContext {
|
|
68
|
+
scope: string;
|
|
69
|
+
/** The folder the empty was issued against, whose listing it invalidates. */
|
|
70
|
+
listMailboxId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const useEmptyTrash = ({
|
|
74
|
+
accountId,
|
|
75
|
+
mailboxId,
|
|
76
|
+
}: UseEmptyTrashOptions): EmptyTrashState => {
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
const { pushError } = useErrorBanners();
|
|
79
|
+
const { requestAppointment } = useRoleAppointmentPrompt();
|
|
80
|
+
const { trashByAccount } = useTrashByAccount();
|
|
81
|
+
|
|
82
|
+
const trash = accountId ? trashByAccount.get(accountId) : undefined;
|
|
83
|
+
const scope = scopeOf(accountId, mailboxId, trash?.mailboxId, trash?.source);
|
|
84
|
+
|
|
85
|
+
const [run, setRun] = useState<EmptyTrashRun>({ scope });
|
|
86
|
+
if (run.scope !== scope) setRun({ scope });
|
|
87
|
+
const current = run.scope === scope ? run : undefined;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The Trash as `/config` reads *now*, straight from the cache. After a
|
|
91
|
+
* repair the appointment names a different folder, and the replay must
|
|
92
|
+
* invalidate that folder's listing rather than the one this pane opened —
|
|
93
|
+
* a render has not necessarily flushed by the time the replay is called.
|
|
94
|
+
*/
|
|
95
|
+
const listMailboxIdNow = useCallback((): string => {
|
|
96
|
+
const config = queryClient.getQueryData<ConfigOperationsGetConfigResponse>(
|
|
97
|
+
configOperationsGetConfigQueryKey(),
|
|
98
|
+
);
|
|
99
|
+
const account = config?.accounts.find((one) => one.accountId === accountId);
|
|
100
|
+
const appointed = account?.folderAppointments.find(
|
|
101
|
+
(one) => one.role === "Trash",
|
|
102
|
+
)?.mailboxId;
|
|
103
|
+
return appointed ?? mailboxId;
|
|
104
|
+
}, [queryClient, accountId, mailboxId]);
|
|
105
|
+
|
|
106
|
+
// What the run in flight is about, for the same reason: a press on one
|
|
107
|
+
// folder must not read as a press on the next one the pane opens.
|
|
108
|
+
const inFlight = useRef<string>(undefined);
|
|
109
|
+
|
|
110
|
+
const { mutateAsync, isPending } = useMutation({
|
|
111
|
+
...trashOperationsEmptyTrashMutation(),
|
|
112
|
+
onMutate: (): EmptyTrashContext => {
|
|
113
|
+
inFlight.current = scope;
|
|
114
|
+
return { scope, listMailboxId: listMailboxIdNow() };
|
|
115
|
+
},
|
|
116
|
+
onSuccess: (data, _variables, context) => {
|
|
117
|
+
if (context.scope !== scope) return;
|
|
118
|
+
// The service's count, never a local tally. A second press re-marks the
|
|
119
|
+
// same rows and reports N again — that is what the folder still holds,
|
|
120
|
+
// and reporting 0 over an expunge is the failure #887 is about.
|
|
121
|
+
setRun({ scope, deletedCount: data.deletedCount });
|
|
122
|
+
},
|
|
123
|
+
onError: (error, _variables, context) => {
|
|
124
|
+
const refused = isFolderRoleRefusal(error);
|
|
125
|
+
if (!refused) {
|
|
126
|
+
pushError({
|
|
127
|
+
title: "Couldn't empty Trash",
|
|
128
|
+
detail: formatErrorDetail(error),
|
|
129
|
+
error,
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// A refusal that outlived what it was about states somebody else's
|
|
134
|
+
// problem over this folder.
|
|
135
|
+
if (context?.scope !== scope) return;
|
|
136
|
+
setRun({ scope, refusal: refused });
|
|
137
|
+
},
|
|
138
|
+
onSettled: (_data, _error, _variables, context) => {
|
|
139
|
+
if (inFlight.current === context?.scope) inFlight.current = undefined;
|
|
140
|
+
invalidateThreadListQueries(
|
|
141
|
+
queryClient,
|
|
142
|
+
threadListCacheKeys([context?.listMailboxId ?? mailboxId]),
|
|
143
|
+
);
|
|
144
|
+
if (!accountId) return;
|
|
145
|
+
queryClient.invalidateQueries({
|
|
146
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
147
|
+
path: { accountId },
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// `onError` above has already stated the failure; the rejection reaching the
|
|
154
|
+
// caller would only report it a second time.
|
|
155
|
+
const issue = useCallback((): Promise<void> => {
|
|
156
|
+
if (!accountId) return Promise.resolve();
|
|
157
|
+
setRun({ scope });
|
|
158
|
+
return mutateAsync({ path: { accountId } }).then(
|
|
159
|
+
() => {},
|
|
160
|
+
() => {},
|
|
161
|
+
);
|
|
162
|
+
}, [accountId, scope, mutateAsync]);
|
|
163
|
+
|
|
164
|
+
const emptyTrash = useCallback(() => {
|
|
165
|
+
void issue();
|
|
166
|
+
}, [issue]);
|
|
167
|
+
|
|
168
|
+
const repair = useCallback(() => {
|
|
169
|
+
const refusal = current?.refusal;
|
|
170
|
+
if (!refusal) return;
|
|
171
|
+
requestAppointment({
|
|
172
|
+
accountId: refusal.accountId,
|
|
173
|
+
role: refusal.role,
|
|
174
|
+
reason: refusal.reason,
|
|
175
|
+
action: { kind: "emptyTrash" },
|
|
176
|
+
onAppointed: issue,
|
|
177
|
+
});
|
|
178
|
+
}, [current, requestAppointment, issue]);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
emptyTrash,
|
|
182
|
+
isEmptying: isPending && inFlight.current === scope,
|
|
183
|
+
deletedCount: current?.deletedCount,
|
|
184
|
+
refusal: current?.refusal,
|
|
185
|
+
repair,
|
|
186
|
+
};
|
|
187
|
+
};
|
package/src/lib/format.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import type { DeleteTarget } from "./format.js";
|
|
3
|
+
import type { DeleteTarget, TrashResolution } from "./format.js";
|
|
4
4
|
import {
|
|
5
5
|
deleteConfirmationCopy,
|
|
6
6
|
deleteOutcomeFor,
|
|
@@ -166,9 +166,9 @@ describe("deleteConfirmationCopy", () => {
|
|
|
166
166
|
* the error path.
|
|
167
167
|
*/
|
|
168
168
|
describe("deleteOutcomeFor", () => {
|
|
169
|
-
const trashByAccount = new Map([
|
|
170
|
-
["acct-1", "mbx-trash"],
|
|
171
|
-
["acct-2", undefined],
|
|
169
|
+
const trashByAccount = new Map<string, TrashResolution>([
|
|
170
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Appointed" }],
|
|
171
|
+
["acct-2", { mailboxId: undefined, source: "None" }],
|
|
172
172
|
]);
|
|
173
173
|
const settled = { trashByAccount, hasAppointments: true, isError: false };
|
|
174
174
|
// No default for the account: a default parameter is applied to an explicit
|
|
@@ -234,9 +234,9 @@ describe("deleteOutcomeFor", () => {
|
|
|
234
234
|
assert.strictEqual(
|
|
235
235
|
deleteOutcomeFor({
|
|
236
236
|
...settled,
|
|
237
|
-
trashByAccount: new Map([
|
|
238
|
-
["acct-1", "mbx-trash"],
|
|
239
|
-
["acct-2", "mbx-other-trash"],
|
|
237
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
238
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Appointed" }],
|
|
239
|
+
["acct-2", { mailboxId: "mbx-other-trash", source: "Appointed" }],
|
|
240
240
|
]),
|
|
241
241
|
targets: [target("mbx-trash", "acct-2")],
|
|
242
242
|
}),
|
|
@@ -306,6 +306,67 @@ describe("deleteOutcomeFor", () => {
|
|
|
306
306
|
"unknown",
|
|
307
307
|
);
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
test("a Trash the account lost is a repair, not a missing appointment", () => {
|
|
311
|
+
assert.strictEqual(
|
|
312
|
+
deleteOutcomeFor({
|
|
313
|
+
...settled,
|
|
314
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
315
|
+
[
|
|
316
|
+
"acct-1",
|
|
317
|
+
{
|
|
318
|
+
mailboxId: "mbx-fallback",
|
|
319
|
+
source: "Stale",
|
|
320
|
+
staleFolderPath: "INBOX/Prullenbak",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
]),
|
|
324
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
325
|
+
}),
|
|
326
|
+
"staleTrash",
|
|
327
|
+
"the folder the user chose is gone; a fallback is not their choice",
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a Trash matched only by name still takes an ordinary delete", () => {
|
|
332
|
+
assert.strictEqual(
|
|
333
|
+
deleteOutcomeFor({
|
|
334
|
+
...settled,
|
|
335
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
336
|
+
["acct-1", { mailboxId: "mbx-trash", source: "Proposed" }],
|
|
337
|
+
]),
|
|
338
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
339
|
+
}),
|
|
340
|
+
"trash",
|
|
341
|
+
"only Empty Trash demands a confirmed appointment (D4)",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("never answers `unconfirmed`, whatever the rows say", () => {
|
|
346
|
+
const sources: TrashResolution["source"][] = [
|
|
347
|
+
"Appointed",
|
|
348
|
+
"Flagged",
|
|
349
|
+
"Reserved",
|
|
350
|
+
"Proposed",
|
|
351
|
+
"Stale",
|
|
352
|
+
"None",
|
|
353
|
+
];
|
|
354
|
+
for (const source of sources) {
|
|
355
|
+
for (const mailboxId of ["mbx-trash", "mbx-inbox", undefined]) {
|
|
356
|
+
assert.notStrictEqual(
|
|
357
|
+
deleteOutcomeFor({
|
|
358
|
+
...settled,
|
|
359
|
+
trashByAccount: new Map<string, TrashResolution>([
|
|
360
|
+
["acct-1", { mailboxId, source }],
|
|
361
|
+
]),
|
|
362
|
+
targets: [target("mbx-inbox", "acct-1")],
|
|
363
|
+
}),
|
|
364
|
+
"unconfirmed",
|
|
365
|
+
"the targets of a delete say nothing about a whole folder",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
309
370
|
});
|
|
310
371
|
|
|
311
372
|
describe("deleteConfirmationCopy — the refusal", () => {
|
|
@@ -324,11 +385,14 @@ describe("deleteConfirmationCopy — the refusal", () => {
|
|
|
324
385
|
assert.ok(!copy.description.includes("restore"));
|
|
325
386
|
});
|
|
326
387
|
|
|
327
|
-
test("
|
|
388
|
+
test("answers a missing Trash where the refusal happened", () => {
|
|
328
389
|
const copy = deleteConfirmationCopy(3, "noTrash");
|
|
329
|
-
assert.strictEqual(copy.title, "Can't delete 3 messages");
|
|
330
|
-
assert.
|
|
331
|
-
|
|
390
|
+
assert.strictEqual(copy.title, "Can't delete 3 messages yet");
|
|
391
|
+
assert.strictEqual(
|
|
392
|
+
copy.description,
|
|
393
|
+
"No folder on this account is set as Trash, so there is nowhere to move the mail. Nothing has been deleted.",
|
|
394
|
+
);
|
|
395
|
+
assert.strictEqual(copy.confirmLabel, "Pick a Trash folder");
|
|
332
396
|
});
|
|
333
397
|
|
|
334
398
|
test("never promises a restore when no Trash is appointed", () => {
|
|
@@ -336,4 +400,62 @@ describe("deleteConfirmationCopy — the refusal", () => {
|
|
|
336
400
|
assert.ok(!copy.title.includes("Move"));
|
|
337
401
|
assert.ok(!copy.description.includes("restore"));
|
|
338
402
|
});
|
|
403
|
+
|
|
404
|
+
test("names the folder that vanished, and drops the clause without one", () => {
|
|
405
|
+
const named = deleteConfirmationCopy(3, "staleTrash", {
|
|
406
|
+
staleFolderLabel: "INBOX/Prullenbak",
|
|
407
|
+
});
|
|
408
|
+
assert.strictEqual(named.title, "Can't delete 3 messages yet");
|
|
409
|
+
assert.strictEqual(
|
|
410
|
+
named.description,
|
|
411
|
+
"The folder you set as this account's Trash — INBOX/Prullenbak — is gone from the mail server. Nothing has been deleted.",
|
|
412
|
+
);
|
|
413
|
+
assert.strictEqual(named.confirmLabel, "Pick another folder");
|
|
414
|
+
assert.strictEqual(
|
|
415
|
+
deleteConfirmationCopy(3, "staleTrash").description,
|
|
416
|
+
"The folder you set as this account's Trash is gone from the mail server. Nothing has been deleted.",
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("names the guess and the irreversibility before an Empty Trash", () => {
|
|
421
|
+
const copy = deleteConfirmationCopy(0, "unconfirmed", {
|
|
422
|
+
trashFolderLabel: "Deleted Messages",
|
|
423
|
+
});
|
|
424
|
+
assert.strictEqual(copy.title, "Confirm this account's Trash folder");
|
|
425
|
+
assert.strictEqual(
|
|
426
|
+
copy.description,
|
|
427
|
+
"reader files this account's deleted mail in Deleted Messages because of its name — nobody confirmed it. Emptying a folder erases everything in it from the mail server, and that cannot be restored. Nothing has been emptied.",
|
|
428
|
+
);
|
|
429
|
+
assert.strictEqual(copy.confirmLabel, "Confirm the folder");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("keeps today's words for an expunge inside a confirmed Trash", () => {
|
|
433
|
+
assert.deepStrictEqual(deleteConfirmationCopy(2, "permanent"), {
|
|
434
|
+
title: "Permanently delete 2 messages?",
|
|
435
|
+
description:
|
|
436
|
+
"They are erased from the mail server and cannot be restored.",
|
|
437
|
+
confirmLabel: "Delete permanently",
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("names the folder before an expunge inside a Trash nobody confirmed", () => {
|
|
442
|
+
const copy = deleteConfirmationCopy(2, "permanent", {
|
|
443
|
+
trashFolderLabel: "Deleted Messages",
|
|
444
|
+
trashIsUnconfirmed: true,
|
|
445
|
+
});
|
|
446
|
+
assert.strictEqual(copy.title, "Permanently delete 2 messages?");
|
|
447
|
+
assert.strictEqual(
|
|
448
|
+
copy.description,
|
|
449
|
+
"They are in Deleted Messages, which reader treats as this account's Trash because of its name — nobody confirmed it. They are erased from the mail server and cannot be restored.",
|
|
450
|
+
);
|
|
451
|
+
assert.strictEqual(copy.confirmLabel, "Delete permanently");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("drops the name clause when the caller holds no folder name", () => {
|
|
455
|
+
const copy = deleteConfirmationCopy(2, "permanent", {
|
|
456
|
+
trashIsUnconfirmed: true,
|
|
457
|
+
});
|
|
458
|
+
assert.match(copy.description, /because of its name — nobody confirmed it/);
|
|
459
|
+
assert.ok(!copy.description.includes("undefined"));
|
|
460
|
+
});
|
|
339
461
|
});
|