diff --git a/FEATURES.md b/FEATURES.md index d0df80b..a5403ae 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -711,6 +711,12 @@ JMAP Contacts and JSContact. company, job title, any number of emails, phones and addresses with types, birthday, website and notes. - **Groups** as a card kind, with members picked from the book. +- **Select and delete in bulk** — tick rows in the list, shift-click for a run, + and delete the lot; or **Empty address book** from the book's own menu, which + is the operation a migration asks for when an import needs doing again. A card + filed in two books is only ever removed from the one being emptied, since + deleting it would empty a book nobody asked about, and what is reported + afterwards is what the server confirmed rather than what was asked for. - **Letter index** down the list, with `#` for everything that does not start with a letter. - **Search** across name, address, organisation and notes, in one book or all. diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index b4219da..c647c04 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1322,6 +1322,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Dies kann nicht rückgängig gemacht werden.", + "Some could not be deleted: {error}": "Einige konnten nicht gelöscht werden: {error}", + "It was not deleted": "Der Kontakt wurde nicht gelöscht", + "Empty address book": "Dieses Adressbuch leeren", + "There is nothing in it to delete": "Es ist nichts darin zum Löschen", + "Empty “{name}”?": "„{name}“ leeren?", + "Delete them": "Alle löschen", + "Nothing was deleted": "Es wurde nichts gelöscht", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1373,5 +1382,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} Nachricht als gelesen markiert", other: "{n} Nachrichten als gelesen markiert" }, "in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" }, "Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "{n} Kontakt löschen?", other: "{n} Kontakte löschen?" }, + "Deleted {n} contacts": { one: "{n} Kontakt gelöscht", other: "{n} Kontakte gelöscht" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "{n} Kontakt wird gelöscht. Dies kann nicht rückgängig gemacht werden.", other: "{n} Kontakte werden gelöscht. Dies kann nicht rückgängig gemacht werden." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} Kontakt war auch in einem anderen Adressbuch und wurde nur aus diesem entfernt", other: "{n} Kontakte waren auch in anderen Adressbüchern und wurden nur aus diesem entfernt" }, }, }; diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3accc67..f0ffcd1 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1295,6 +1295,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Esto no se puede deshacer.", + "Some could not be deleted: {error}": "Algunos no se pudieron eliminar: {error}", + "It was not deleted": "No se ha eliminado", + "Empty address book": "Vaciar esta libreta de direcciones", + "There is nothing in it to delete": "No hay nada dentro que eliminar", + "Empty “{name}”?": "¿Vaciar «{name}»?", + "Delete them": "Eliminarlos", + "Nothing was deleted": "No se ha eliminado nada", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1346,5 +1355,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} mensaje marcado como leído", other: "{n} mensajes marcados como leídos" }, "in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" }, "Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "¿Eliminar {n} contacto?", other: "¿Eliminar {n} contactos?" }, + "Deleted {n} contacts": { one: "Se ha eliminado {n} contacto", other: "Se han eliminado {n} contactos" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "Se eliminará {n} contacto. Esto no se puede deshacer.", other: "Se eliminarán {n} contactos. Esto no se puede deshacer." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} contacto también estaba en otra libreta de direcciones y solo se ha quitado de esta", other: "{n} contactos también estaban en otras libretas de direcciones y solo se han quitado de esta" }, }, }; diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 54ed3a5..c63778c 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1300,6 +1300,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Cette action est irréversible.", + "Some could not be deleted: {error}": "Certains n’ont pas pu être supprimés : {error}", + "It was not deleted": "Il n’a pas été supprimé", + "Empty address book": "Vider ce carnet d’adresses", + "There is nothing in it to delete": "Il n’y a rien à supprimer dedans", + "Empty “{name}”?": "Vider « {name} » ?", + "Delete them": "Les supprimer", + "Nothing was deleted": "Rien n’a été supprimé", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1351,5 +1360,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} message marqué comme lu", other: "{n} messages marqués comme lus" }, "in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" }, "Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "Supprimer {n} contact ?", other: "Supprimer {n} contacts ?" }, + "Deleted {n} contacts": { one: "{n} contact supprimé", other: "{n} contacts supprimés" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact sera supprimé. Cette action est irréversible.", other: "{n} contacts seront supprimés. Cette action est irréversible." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} contact se trouvait aussi dans un autre carnet d’adresses et n’a été retiré que de celui-ci", other: "{n} contacts se trouvaient aussi dans d’autres carnets d’adresses et n’ont été retirés que de celui-ci" }, }, }; diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index bebbb23..1ece5ef 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1303,6 +1303,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "この操作は取り消せません。", + "Some could not be deleted: {error}": "一部を削除できませんでした: {error}", + "It was not deleted": "削除されませんでした", + "Empty address book": "このアドレス帳を空にする", + "There is nothing in it to delete": "削除するものがありません", + "Empty “{name}”?": "「{name}」を空にしますか?", + "Delete them": "削除する", + "Nothing was deleted": "何も削除されませんでした", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1359,5 +1368,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" }, "in {n} folders": { other: "{n} 個のフォルダーで" }, "Deleted {n} messages": { other: "{n} 通のメールを削除しました" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { other: "{n} 件の連絡先を削除しますか?" }, + "Deleted {n} contacts": { other: "{n} 件の連絡先を削除しました" }, + "{n} contacts will be deleted. This cannot be undone.": { other: "{n} 件の連絡先が削除されます。この操作は取り消せません。" }, + "{n} were also in other address books and were only removed from this one": { other: "{n} 件は他のアドレス帳にもあるため、このアドレス帳から外しただけです" }, }, }; diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 69b483d..05b0789 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1291,6 +1291,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Dit kan niet ongedaan worden gemaakt.", + "Some could not be deleted: {error}": "Sommige konden niet worden verwijderd: {error}", + "It was not deleted": "Het is niet verwijderd", + "Empty address book": "Dit adresboek leegmaken", + "There is nothing in it to delete": "Er staat niets in om te verwijderen", + "Empty “{name}”?": "„{name}” leegmaken?", + "Delete them": "Verwijderen", + "Nothing was deleted": "Er is niets verwijderd", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1342,5 +1351,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} bericht als gelezen gemarkeerd", other: "{n} berichten als gelezen gemarkeerd" }, "in {n} folders": { one: "in {n} map", other: "in {n} mappen" }, "Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "{n} contact verwijderen?", other: "{n} contacten verwijderen?" }, + "Deleted {n} contacts": { one: "{n} contact verwijderd", other: "{n} contacten verwijderd" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "{n} contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt.", other: "{n} contacten worden verwijderd. Dit kan niet ongedaan worden gemaakt." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} contact stond ook in een ander adresboek en is alleen uit dit adresboek verwijderd", other: "{n} contacten stonden ook in andere adresboeken en zijn alleen uit dit adresboek verwijderd" }, }, }; diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index c7c8a6d..7c11054 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1298,6 +1298,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Isso não pode ser desfeito.", + "Some could not be deleted: {error}": "Alguns não puderam ser excluídos: {error}", + "It was not deleted": "Não foi excluído", + "Empty address book": "Esvaziar este catálogo de endereços", + "There is nothing in it to delete": "Não há nada nele para excluir", + "Empty “{name}”?": "Esvaziar “{name}”?", + "Delete them": "Excluir todos", + "Nothing was deleted": "Nada foi excluído", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1349,5 +1358,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} mensagem marcada como lida", other: "{n} mensagens marcadas como lidas" }, "in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" }, "Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "Excluir {n} contato?", other: "Excluir {n} contatos?" }, + "Deleted {n} contacts": { one: "{n} contato excluído", other: "{n} contatos excluídos" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "{n} contato será excluído. Isso não pode ser desfeito.", other: "{n} contatos serão excluídos. Isso não pode ser desfeito." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} contato também estava em outro catálogo de endereços e foi removido apenas deste", other: "{n} contatos também estavam em outros catálogos de endereços e foram removidos apenas deste" }, }, }; diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index ac953b7..0f03961 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1297,6 +1297,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Это действие нельзя отменить.", + "Some could not be deleted: {error}": "Некоторые не удалось удалить: {error}", + "It was not deleted": "Контакт не был удалён", + "Empty address book": "Очистить эту адресную книгу", + "There is nothing in it to delete": "В ней нечего удалять", + "Empty “{name}”?": "Очистить «{name}»?", + "Delete them": "Удалить их", + "Nothing was deleted": "Ничего не удалено", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1356,5 +1365,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" }, "in {n} folders": { one: "в {n} папке", few: "в {n} папках", many: "в {n} папках", other: "в {n} папках" }, "Deleted {n} messages": { one: "Удалено {n} письмо", few: "Удалено {n} письма", many: "Удалено {n} писем", other: "Удалено {n} письма" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "Удалить {n} контакт?", few: "Удалить {n} контакта?", many: "Удалить {n} контактов?", other: "Удалить {n} контакта?" }, + "Deleted {n} contacts": { one: "Удалён {n} контакт", few: "Удалено {n} контакта", many: "Удалено {n} контактов", other: "Удалено {n} контакта" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "Будет удалён {n} контакт. Это действие нельзя отменить.", few: "Будет удалено {n} контакта. Это действие нельзя отменить.", many: "Будет удалено {n} контактов. Это действие нельзя отменить.", other: "Будет удалено {n} контакта. Это действие нельзя отменить." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} контакт также был в другой адресной книге и удалён только из этой", few: "{n} контакта также были в других адресных книгах и удалены только из этой", many: "{n} контактов также были в других адресных книгах и удалены только из этой", other: "{n} контакта также были в других адресных книгах и удалены только из этой" }, }, }; diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 17f0554..860bdad 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1291,6 +1291,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "Цю дію не можна скасувати.", + "Some could not be deleted: {error}": "Деякі не вдалося видалити: {error}", + "It was not deleted": "Контакт не було видалено", + "Empty address book": "Очистити цю адресну книгу", + "There is nothing in it to delete": "У ній немає чого видаляти", + "Empty “{name}”?": "Очистити «{name}»?", + "Delete them": "Видалити їх", + "Nothing was deleted": "Нічого не видалено", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1348,5 +1357,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" }, "in {n} folders": { one: "у {n} теці", few: "у {n} теках", many: "у {n} теках", other: "у {n} теках" }, "Deleted {n} messages": { one: "Видалено {n} лист", few: "Видалено {n} листи", many: "Видалено {n} листів", other: "Видалено {n} листа" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { one: "Видалити {n} контакт?", few: "Видалити {n} контакти?", many: "Видалити {n} контактів?", other: "Видалити {n} контакта?" }, + "Deleted {n} contacts": { one: "Видалено {n} контакт", few: "Видалено {n} контакти", many: "Видалено {n} контактів", other: "Видалено {n} контакта" }, + "{n} contacts will be deleted. This cannot be undone.": { one: "Буде видалено {n} контакт. Цю дію не можна скасувати.", few: "Буде видалено {n} контакти. Цю дію не можна скасувати.", many: "Буде видалено {n} контактів. Цю дію не можна скасувати.", other: "Буде видалено {n} контакта. Цю дію не можна скасувати." }, + "{n} were also in other address books and were only removed from this one": { one: "{n} контакт також був в іншій адресній книзі й вилучений лише з цієї", few: "{n} контакти також були в інших адресних книгах і вилучені лише з цієї", many: "{n} контактів також були в інших адресних книгах і вилучені лише з цієї", other: "{n} контакта також були в інших адресних книгах і вилучені лише з цієї" }, }, }; diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index c592cdd..869eff7 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1302,6 +1302,15 @@ export const catalog: Catalog = { // Sentences that lib/ and store/ were building in English, and the two // swipe labels that reach t() through a variable and so were invisible // to a scan for t("literal"). See #259. + // ── Emptying an address book, and deleting a selection (#277) ── + "This cannot be undone.": "此操作无法撤销。", + "Some could not be deleted: {error}": "部分无法删除:{error}", + "It was not deleted": "未被删除", + "Empty address book": "清空此通讯录", + "There is nothing in it to delete": "其中没有可删除的内容", + "Empty “{name}”?": "清空“{name}”?", + "Delete them": "删除", + "Nothing was deleted": "未删除任何内容", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── @@ -1358,5 +1367,10 @@ export const catalog: Catalog = { "Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" }, "in {n} folders": { other: "在 {n} 个文件夹中" }, "Deleted {n} messages": { other: "已删除 {n} 封邮件" }, + // ── Emptying an address book, and deleting a selection (#277) ── + "Delete {n} contacts?": { other: "删除 {n} 位联系人?" }, + "Deleted {n} contacts": { other: "已删除 {n} 位联系人" }, + "{n} contacts will be deleted. This cannot be undone.": { other: "将删除 {n} 位联系人。此操作无法撤销。" }, + "{n} were also in other address books and were only removed from this one": { other: "其中 {n} 位也在其他通讯录中,仅从此通讯录移除" }, }, }; diff --git a/web/src/store/__tests__/empty-address-book.test.ts b/web/src/store/__tests__/empty-address-book.test.ts new file mode 100644 index 0000000..61b88c2 --- /dev/null +++ b/web/src/store/__tests__/empty-address-book.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useContacts } from "@/store/contacts"; +import type { ContactCard, Id, JmapSession } from "@/jmap/types"; + +/* + * Emptying an address book, and deleting a selection of contacts. + * + * Asked for on #174 as the other half of a migration: import, notice something + * is wrong, empty the book, correct the export, import again. Until now the + * only way to delete a contact was one card at a time from its own pane, and + * the only way to empty a book was to delete the book and build it again -- + * losing its name, its sharing and its default status (#277). + * + * The part worth testing hardest is the one that is not a deletion. A card + * filed in two books belongs to both, and `ContactCard/set destroy` takes it + * away from both at once. Emptying one book must not empty another. + */ + +const MAX = 500; + +interface SetArgs { update?: Record>; destroy?: Id[] } + +function server(opts: { max?: number; notDestroyed?: Record } = {}) { + const sets: SetArgs[] = []; + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "ContactCard/set") { + const update = args.update as Record> | undefined; + const destroy = args.destroy as Id[] | undefined; + sets.push({ update, destroy }); + const n = Object.keys(update ?? {}).length + (destroy?.length ?? 0); + if (opts.max != null && n > opts.max) { + return ["error", { type: "requestTooLarge", description: "too many objects" }, id]; + } + const notDestroyed = opts.notDestroyed ?? {}; + return [name, { + accountId: "a1", oldState: "1", newState: "2", + updated: Object.fromEntries(Object.keys(update ?? {}).map((k) => [k, null])), + destroyed: (destroy ?? []).filter((d) => !(d in notDestroyed)), + notDestroyed, notUpdated: {}, + }, id]; + } + // Everything else, `loadAll`'s query and get included, answers empty. + return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id]; + }); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return sets; +} + +/** A card, filed in the books named. */ +const card = (id: string, ...books: string[]) => ({ + id, uid: `uid-${id}`, name: { full: id }, emails: {}, + addressBookIds: Object.fromEntries(books.map((b) => [b, true])), +}) as unknown as ContactCard; + +const stateWith = (...cards: ContactCard[]) => + useContacts.setState({ cards: Object.fromEntries(cards.map((c) => [c.id, c])) as Record }); + +/** The ids a call destroyed, and the ids it patched, across every call made. */ +const destroyedIn = (sets: SetArgs[]) => sets.flatMap((s) => s.destroy ?? []); +const updatedIn = (sets: SetArgs[]) => sets.flatMap((s) => Object.keys(s.update ?? {})); + +beforeEach(() => { + client.session = { + capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.contacts]: {} }, + accounts: {}, primaryAccounts: {}, state: "s1", + } as unknown as JmapSession; + useContacts.setState({ accountId: "a1", available: true, books: {}, cards: {} as Record }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("emptying an address book", () => { + it("deletes what is filed only there", async () => { + const sets = server(); + stateWith(card("c1", "book1"), card("c2", "book1")); + const r = await useContacts.getState().emptyBook("book1"); + expect(r).toMatchObject({ destroyed: 2, unfiled: 0 }); + expect(destroyedIn(sets).sort()).toEqual(["c1", "c2"]); + }); + + it("leaves the other books alone", async () => { + const sets = server(); + stateWith(card("c1", "book1"), card("c2", "book2")); + await useContacts.getState().emptyBook("book1"); + expect(destroyedIn(sets)).toEqual(["c1"]); + }); + + it("removes a card filed in two books from this one, rather than deleting it", async () => { + // The whole reason this is not one `destroy` over everything in the book. + const sets = server(); + stateWith(card("c1", "book1", "book2")); + const r = await useContacts.getState().emptyBook("book1"); + expect(r).toMatchObject({ destroyed: 0, unfiled: 1 }); + expect(destroyedIn(sets)).toEqual([]); + expect(sets[0]!.update).toEqual({ c1: { "addressBookIds/book1": null } }); + }); + + it("reports the two outcomes apart, since only one of them is a deletion", async () => { + server(); + stateWith(card("c1", "book1"), card("c2", "book1", "book2"), card("c3", "book1")); + const r = await useContacts.getState().emptyBook("book1"); + expect(r).toMatchObject({ destroyed: 2, unfiled: 1 }); + }); + + it("does nothing at all to an empty book", async () => { + const sets = server(); + stateWith(card("c1", "book2")); + const r = await useContacts.getState().emptyBook("book1"); + expect(r).toMatchObject({ destroyed: 0, unfiled: 0 }); + expect(sets).toHaveLength(0); + }); + + it("splits a book bigger than the server will take in one call", async () => { + // Refused whole over the ceiling, the way Stalwart refuses it -- so a book + // of 1200 that went in one call would delete nothing at all. + const sets = server({ max: MAX }); + stateWith(...Array.from({ length: 1200 }, (_, i) => card(`c${i}`, "book1"))); + const r = await useContacts.getState().emptyBook("book1"); + expect(r.destroyed).toBe(1200); + expect(sets.map((s) => (s.destroy?.length ?? 0) + Object.keys(s.update ?? {}).length)).toEqual([500, 500, 200]); + }); + + it("counts destroys and patches against one budget, the way the server does", async () => { + const sets = server({ max: MAX }); + stateWith( + ...Array.from({ length: 300 }, (_, i) => card(`d${i}`, "book1")), + ...Array.from({ length: 300 }, (_, i) => card(`u${i}`, "book1", "book2")), + ); + const r = await useContacts.getState().emptyBook("book1"); + expect(r).toMatchObject({ destroyed: 300, unfiled: 300 }); + // 600 objects over a ceiling of 500 is two calls, not two calls of 300 + // that each look small enough on their own. + expect(sets).toHaveLength(2); + }); + + it("reports a refusal rather than throwing, and keeps the count that got through", async () => { + const sets = server({ notDestroyed: { c2: { type: "forbidden", description: "not yours" } } }); + stateWith(card("c1", "book1"), card("c2", "book1")); + const r = await useContacts.getState().emptyBook("book1"); + expect(r.destroyed).toBe(1); + expect(r.refused).toMatchObject({ type: "forbidden" }); + expect(destroyedIn(sets).sort()).toEqual(["c1", "c2"]); + }); + + it("does not patch a card it is deleting", async () => { + const sets = server(); + stateWith(card("c1", "book1")); + await useContacts.getState().emptyBook("book1"); + expect(updatedIn(sets)).toEqual([]); + }); +}); + +describe("deleting a selection of contacts", () => { + it("answers with what the server destroyed rather than what was asked", async () => { + server({ notDestroyed: { c2: { type: "forbidden" } } }); + stateWith(card("c1", "book1"), card("c2", "book1")); + const r = await useContacts.getState().destroyCards(["c1", "c2"]); + expect(r.destroyed).toBe(1); + expect(r.refused).toMatchObject({ type: "forbidden" }); + }); + + it("does not throw on a refusal, because half of it still went", async () => { + // Throwing loses the count, and an error saying only that it failed sends + // somebody looking for contacts that are already gone. + server({ notDestroyed: { c1: { type: "forbidden" } } }); + stateWith(card("c1", "book1")); + await expect(useContacts.getState().destroyCards(["c1"])).resolves.toMatchObject({ destroyed: 0 }); + }); + + it("takes off the local list only what actually went", async () => { + server({ notDestroyed: { c2: { type: "forbidden" } } }); + stateWith(card("c1", "book1"), card("c2", "book1")); + await useContacts.getState().destroyCards(["c1", "c2"]); + expect(Object.keys(useContacts.getState().cards)).toEqual(["c2"]); + }); +}); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index 45530b9..94257a7 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -202,7 +202,21 @@ interface ContactsState { filterCards(cards: ContactCard[], text: string): ContactCard[]; createCard(card: Partial, addressBookId: Id): Promise; updateCard(id: Id, patch: Record): Promise; - destroyCards(ids: Id[]): Promise; + /** + * Delete cards outright, reporting what the server actually destroyed rather + * than what was asked for. Nothing is thrown for a refusal -- a partial one + * has a count worth telling somebody about, and `refused` says why the rest + * did not go. + */ + destroyCards(ids: Id[]): Promise<{ destroyed: number; refused?: SetError }>; + /** + * Empty an address book: everything filed in it, gone. + * + * `unfiled` is the part that is not a deletion. A card filed in two books is + * only *this* book's to remove, so it is taken out of this one and left + * alone in the other -- destroying it would empty a book nobody asked about. + */ + emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>; createBook(name: string): Promise; updateBook(id: Id, patch: Partial): Promise; destroyBook(id: Id): Promise; @@ -464,12 +478,12 @@ export const useContacts = create((set, get) => ({ async destroyCards(ids) { const accountId = get().accountId!; const gone: Id[] = []; - let failed: SetError | undefined; + let refused: SetError | undefined; try { for (const part of chunk(ids, client.maxObjectsInSet)) { const res = await client.call("ContactCard/set", { accountId, destroy: part }); gone.push(...(res.destroyed ?? [])); - failed ??= Object.values(res.notDestroyed ?? {})[0]; + refused ??= Object.values(res.notDestroyed ?? {})[0]; } } finally { if (gone.length) { @@ -480,7 +494,57 @@ export const useContacts = create((set, get) => ({ }); } } - if (failed) throw new Error(setErrorMessage(failed)); + /* Answered rather than thrown. A refusal that took half the selection with + it still deleted the other half, and an error that says only "it failed" + sends somebody looking for contacts that are already gone. */ + return { destroyed: gone.length, refused }; + }, + + async emptyBook(bookId) { + const accountId = get().accountId!; + const inBook = Object.values(get().cards).filter((c) => c.addressBookIds?.[bookId]); + /* + * Two different acts, decided per card. + * + * A card filed only here is deleted. A card filed here *and* somewhere else + * is removed from this book and left where it also lives -- emptying one + * book must not empty another, and `ContactCard/set destroy` does not know + * the difference: it takes the card away from every book at once. + */ + const destroy: Id[] = []; + const update: Record = {}; + for (const c of inBook) { + if (Object.keys(c.addressBookIds ?? {}).length > 1) update[c.id] = { [`addressBookIds/${bookId}`]: null }; + else destroy.push(c.id); + } + + const gone: Id[] = []; + let unfiled = 0; + let refused: SetError | undefined; + /* One budget for both, the way `writeCards` shares one: Stalwart counts + every object in a `/set` against `maxObjectsInSet` together. */ + const work = [ + ...destroy.map((id) => ["destroy", id] as const), + ...Object.keys(update).map((id) => ["update", id] as const), + ]; + try { + for (const part of chunk(work, client.maxObjectsInSet)) { + const partDestroy = part.filter(([kind]) => kind === "destroy").map(([, id]) => id); + const partUpdate: Record = {}; + for (const [kind, id] of part) if (kind === "update") partUpdate[id] = update[id]; + const res = await client.call>("ContactCard/set", { + accountId, + ...(partDestroy.length ? { destroy: partDestroy } : {}), + ...(Object.keys(partUpdate).length ? { update: partUpdate } : {}), + }); + gone.push(...(res.destroyed ?? [])); + unfiled += Object.keys(res.updated ?? {}).length; + refused ??= Object.values(res.notDestroyed ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0]; + } + } finally { + await get().loadAll(); + } + return { destroyed: gone.length, unfiled, refused }; }, async createBook(name) { diff --git a/web/src/styles/app.css b/web/src/styles/app.css index da5b423..6293c0c 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1203,6 +1203,13 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .contact-row.active { background: var(--selected-bg); } .contact-row .c-name { font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .contact-row .c-email { color: var(--fg-muted); font-size: .85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.contact-row.picked { background: var(--selected-bg); } +/* Out of the way until wanted, and never on a touchscreen, where there is no + hover to reveal it -- the same rule the message list's checkbox follows. */ +.contact-check { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; flex: 0 0 auto; opacity: 0; transition: opacity .1s; cursor: pointer; } +.contact-row:hover .contact-check, .contact-row.picked .contact-check, .contacts-scroll.has-selection .contact-check { opacity: 1; } +.contacts-selbar .contact-check { opacity: 1; } +.contacts-selbar { gap: 12px; padding-left: 14px; } .contact-letter { position: sticky; top: 0; background: var(--bg-sunken); padding: 4px 14px; font-size: .78em; font-weight: 700; color: var(--fg-muted); letter-spacing: .05em; z-index: 1; } .contact-detail { overflow-y: auto; padding: 28px 32px 64px; } .contact-hero { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; } @@ -1576,6 +1583,7 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } } @media (hover: none) { .msg-row .msg-check { opacity: 1; } + .contact-row .contact-check { opacity: 1; } .msg-row .msg-actions { display: none !important; } .msg-row:hover .msg-meta .msg-date { display: inline; } .nav-item .nav-more, .cal-list-item .nav-more { opacity: 1; } diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index 3ef0591..5833e61 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { Book, BookOpen, Download, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react"; +import { Book, BookOpen, Download, Eraser, MoreVertical, Pencil, Plus, RefreshCw, Share2, Trash2, Upload, UserMinus, Users, X } from "lucide-react"; import { useContacts } from "@/store/contacts"; +import { setErrorMessage } from "@/jmap/client"; import { useSession } from "@/store/session"; import { useSettings } from "@/store/settings"; import type { AddressBook } from "@/jmap/types"; @@ -278,6 +279,51 @@ export function ContactsSidebar() { /> )} + {/* + The operation a migration actually asks for: import, notice + something is wrong, empty the book, correct the export, import + again. Offered on your own books only -- emptying somebody else's + is a write to their account, which this client cannot make. + + Kept apart from Delete, which takes the book with it. A default + book cannot be deleted and can perfectly well be emptied, which + is most of why this is worth having as its own entry. + */} + } + label={t("Empty address book")} + onClick={async () => { + const n = Object.values(contacts.cards).filter((c) => c.addressBookIds?.[menuBook.id]).length; + if (!n) { toast.error(t("There is nothing in it to delete")); return; } + if (!(await confirmDialog({ + title: t("Empty “{name}”?", { name: menuBook.name }), + message: plural(n, { + one: "{n} contact will be deleted. This cannot be undone.", + other: "{n} contacts will be deleted. This cannot be undone.", + }), + confirmLabel: t("Delete them"), + danger: true, + }))) return; + try { + const { destroyed, unfiled, refused } = await contacts.emptyBook(menuBook.id); + if (destroyed) toast.success(plural(destroyed, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })); + /* Said out loud, because it is the one part of emptying a + book that is not a deletion and would otherwise look like + contacts that refused to go. */ + if (unfiled) { + toast.show(plural(unfiled, { + one: "{n} was also in another address book and was only removed from this one", + other: "{n} were also in other address books and were only removed from this one", + }), { duration: 9000 }); + } + if (refused) toast.error(t("Some could not be deleted: {error}", { error: setErrorMessage(refused) })); + else if (!destroyed && !unfiled) toast.error(t("Nothing was deleted")); + } catch (err) { + toast.error((err as Error).message); + } + }} + /> } diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 260e8a9..dc64d12 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "wouter"; -import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users } from "lucide-react"; +import { ArrowLeft, Building2, Cake, Calendar as CalIcon, Download, Globe, Mail, MapPin, Pencil, Phone, Pin, Plus, Search, StickyNote, Trash2, Users, X } from "lucide-react"; import { useContacts } from "@/store/contacts"; +import { setErrorMessage } from "@/jmap/client"; import { useCompose } from "@/store/compose"; import type { ContactCard } from "@/jmap/types"; import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts"; @@ -24,12 +25,31 @@ export function ContactsView({ id }: { id?: string }) { const bookId = sel.bookId; const [editing, setEditing] = useState | null>(null); const openCompose = useCompose((s) => s.open); + /* + * Ticked rows, and the last one ticked so a shift-click has something to + * reach back to. Kept here rather than in the store: this is the only list + * of contacts there is, and nothing outside this view acts on a selection. + * + * Only ever your own cards. Deleting somebody else's contact is a write to + * their account, which is not a thing this client can do -- see `readOnly`. + */ + const [picked, setPicked] = useState>({}); + const lastPicked = useRef(null); + const readOnly = Boolean(sel.accountId); useEffect(() => { if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [contacts.available, contacts.loaded]); + /* A selection belongs to the book it was made in. Carrying it across to + another book would leave a count on screen describing rows that are no + longer there, and a Delete button aimed at them. */ + useEffect(() => { + setPicked({}); + lastPicked.current = null; + }, [bookId, sel.accountId]); + useEffect(() => { const onNew = () => setEditing({}); /* @@ -88,6 +108,10 @@ export function ContactsView({ id }: { id?: string }) { } return out; }, [list]); + /* Ticked *and* on screen. A selection outlives a search box being typed + into, and deleting rows that scrolled out of view is not what the count + on the bar promised. */ + const pickedIds = useMemo(() => list.filter((c) => picked[c.id]).map((c) => c.id), [list, picked]); if (!contacts.available) { return
} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}
; @@ -169,18 +193,84 @@ export function ContactsView({ id }: { id?: string }) { } }; + /* Ticking a box, with shift reaching back to the last one ticked. The range + is taken from `list`, so it is the rows as they are grouped and sorted on + screen rather than the order the store happens to hold them in. */ + const tick = (cardId: string, on: boolean, range: boolean) => { + /* The anchor is read here and not inside the updater below. React runs an + updater when it gets round to rendering, by which time the ref has + already been moved to this row -- so the range would be measured from + the row that ended it and collapse to that one row. */ + const anchor = range ? lastPicked.current : null; + const a = anchor ? list.findIndex((c) => c.id === anchor) : -1; + const b = list.findIndex((c) => c.id === cardId); + const ids = a >= 0 && b >= 0 + ? list.slice(Math.min(a, b), Math.max(a, b) + 1).map((c) => c.id) + : [cardId]; + setPicked((prev) => { + const next = { ...prev }; + for (const i of ids) { if (on) next[i] = true; else delete next[i]; } + return next; + }); + lastPicked.current = cardId; + }; + + const clearPicked = () => { setPicked({}); lastPicked.current = null; }; + + const deletePicked = async () => { + const n = pickedIds.length; + if (!n) return; + if (!(await confirmDialog({ + title: plural(n, { one: "Delete {n} contact?", other: "Delete {n} contacts?" }), + message: translate("This cannot be undone."), + confirmLabel: translate("Delete"), + danger: true, + }))) return; + try { + /* What the server confirmed, not what was asked. A refusal that took + half of them still deleted the other half, and saying "it failed" + sends you looking for contacts that are already gone. */ + const { destroyed, refused } = await contacts.destroyCards(pickedIds); + clearPicked(); + if (destroyed) toast.success(plural(destroyed, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })); + if (refused) toast.error(translate("Some could not be deleted: {error}", { error: setErrorMessage(refused) })); + if (destroyed && id && pickedIds.includes(id)) navigate("/contacts"); + } catch (err) { + toast.error((err as Error).message); + } + }; + return (
-
-
- - setQ(e.target.value)} /> + {pickedIds.length ? ( + /* The search box gives way rather than sitting alongside: what the + bar counts is what the search left on screen, so leaving the box + where it is invites narrowing the list under your own selection. */ +
+ { if (el) el.indeterminate = pickedIds.length > 0 && pickedIds.length < list.length; }} + onChange={(e) => { if (e.target.checked) { setPicked(Object.fromEntries(list.map((c) => [c.id, true as const]))); } else clearPicked(); }} + aria-label={translate("Select all")} + /> + {plural(pickedIds.length, { one: "{n} selected", other: "{n} selected" })} + +
- -
-
+ ) : ( +
+
+ + setQ(e.target.value)} /> +
+ +
+ )} +
{contacts.loading && !contacts.loaded ? : !list.length ? ( } title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("Add a contact or import a vCard file.")} ) : groups.map((g) => ( @@ -190,7 +280,17 @@ export function ContactsView({ id }: { id?: string }) { const email = contactEmails(c)[0]?.email; const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null; return ( -
navigate(`/contacts/${c.id}`)}> +
navigate(`/contacts/${c.id}`)}> + {!readOnly && ( + { ev.stopPropagation(); tick(c.id, !picked[c.id], ev.shiftKey); }} + onChange={() => {}} + aria-label={translate("Select")} + /> + )} {photo ? : c.kind === "group" ? : contactDisplayName(c).slice(0, 1).toUpperCase()}
{contactDisplayName(c)}{c.kind === "group" ? {translate("· group")} : null}
@@ -234,7 +334,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con - +
{photo ? : c.kind === "group" ? : name.slice(0, 1).toUpperCase()} diff --git a/web/src/views/contacts/__tests__/contacts-selection.test.tsx b/web/src/views/contacts/__tests__/contacts-selection.test.tsx new file mode 100644 index 0000000..5c27b2d --- /dev/null +++ b/web/src/views/contacts/__tests__/contacts-selection.test.tsx @@ -0,0 +1,142 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ContactsView } from "../ContactsView"; +import { useContacts } from "@/store/contacts"; +import type { ContactCard, Id } from "@/jmap/types"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Ticking rows in the contacts list. + * + * Written after the browser caught what the store tests could not: the range a + * shift-click covers was being measured inside the `setPicked` updater, which + * React runs when it gets round to rendering -- by which time the anchor ref + * has already been moved to the row that *ended* the range. Every shift-click + * selected exactly one row, and every assertion about the store still passed, + * because nothing was wrong below the component. + */ + +const card = (id: string, full: string) => ({ + id, uid: `uid-${id}`, name: { full }, emails: {}, kind: "individual", + addressBookIds: { book1: true }, +}) as unknown as ContactCard; + +/** Six people, in the order the list sorts them. */ +const PEOPLE = ["Ada", "Bea", "Cal", "Dev", "Eve", "Fay"].map((n, i) => card(`c${i}`, `${n} Person`)); + +describe("selecting contacts in the list", () => { + let host: HTMLDivElement; + let root: Root; + + const render = async () => { + await act(async () => { root.render(); }); + }; + const boxes = () => [...host.querySelectorAll(".contact-row .contact-check")]; + const click = async (el: Element, shiftKey = false) => { + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey })); + }); + }; + const ticked = () => boxes().filter((b) => b.checked).length; + + beforeEach(async () => { + /* jsdom has no matchMedia, and the layout asks whether the window is + narrow before it draws anything. */ + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, media: query, onchange: null, + addEventListener: () => {}, removeEventListener: () => {}, + addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, + })); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + useContacts.setState({ + accountId: "a1", available: true, loaded: true, loading: false, + books: {}, sharedCards: {}, + cards: Object.fromEntries(PEOPLE.map((c) => [c.id, c])) as Record, + selection: { accountId: null, bookId: "all" }, + }); + await render(); + }); + + afterEach(async () => { + await act(async () => { root.unmount(); }); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("puts a checkbox on every row", () => { + expect(boxes()).toHaveLength(PEOPLE.length); + expect(ticked()).toBe(0); + }); + + it("ticks one row on a plain click", async () => { + await click(boxes()[0]!); + expect(ticked()).toBe(1); + }); + + it("takes the whole run on a shift-click", async () => { + // The regression. This was 2 before the anchor moved out of the updater. + await click(boxes()[0]!); + await click(boxes()[5]!, true); + expect(ticked()).toBe(6); + }); + + it("reaches backwards as readily as forwards", async () => { + await click(boxes()[4]!); + await click(boxes()[1]!, true); + expect(ticked()).toBe(4); + }); + + it("unticks a run when the row shift-clicked was already ticked", async () => { + await click(boxes()[0]!); + await click(boxes()[5]!, true); + await click(boxes()[2]!, true); + // Rows 2..5 come off, 0 and 1 stay. + expect(ticked()).toBe(2); + }); + + it("treats a shift-click with nothing ticked yet as an ordinary one", async () => { + await click(boxes()[3]!, true); + expect(ticked()).toBe(1); + }); + + it("moves the anchor to the row last clicked", async () => { + await click(boxes()[0]!); + await click(boxes()[2]!); + await click(boxes()[4]!, true); + // From 2, not from 0: rows 2,3,4 plus the 0 already ticked. + expect(ticked()).toBe(4); + }); + + it("shows a selection bar instead of the search box, counting what is ticked", async () => { + expect(host.querySelector(".contacts-selbar")).toBeNull(); + await click(boxes()[0]!); + await click(boxes()[2]!, true); + expect(host.querySelector(".contacts-selbar")?.textContent).toContain("3"); + expect(host.querySelector(".search-input")).toBeNull(); + }); + + it("does not open the contact it just ticked", async () => { + // The checkbox sits inside the row, whose own click navigates. + await click(boxes()[0]!); + expect(host.querySelector(".contact-row.picked")).not.toBeNull(); + expect(ticked()).toBe(1); + }); + + it("clears the selection when the book being shown changes", async () => { + await click(boxes()[0]!); + await click(boxes()[3]!, true); + expect(ticked()).toBe(4); + await act(async () => { + useContacts.setState({ selection: { accountId: null, bookId: "book1" } }); + }); + // A count describing rows from another book, with Delete aimed at them, + // is the thing this avoids. + expect(ticked()).toBe(0); + expect(host.querySelector(".contacts-selbar")).toBeNull(); + }); +});