Select contacts, and empty an address book
Raised on #174 as the other half of a migration -- import, notice something is wrong, empty the book, correct the export, import again -- and tracked as #277. The gap turned out to be wider than the ask. Contacts had no multi-select at all: the only delete in the module was the cross on a single card's pane, one card and one confirmation at a time. `destroyCards` has taken a list and batched it against maxObjectsInSet since #218, and nothing in the UI ever handed it more than one id. So "empty this address book" was missing, and so was "delete these fourteen". The list now has checkboxes, on hover the way the message list's are, and always on a touchscreen where there is no hover to reveal them. Shift-click takes the run between two rows. The search box gives way to a selection bar rather than sitting beside it, because what the count promises is what the search left on screen. A selection is cleared when the book being shown changes, since carrying it across would leave a count describing rows that are no longer there and a Delete aimed at them. Emptying a book is in the book's own menu, beside the import and export that moved there in #226, and separate from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of the reason it is its own entry. The part that is not a deletion, and the reason this is not one destroy over everything in the book: 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, so a card with a second home is patched out of this one and left alone. That is reported separately afterwards, because it would otherwise look like contacts that refused to go. `destroyCards` now answers with what the server confirmed rather than throwing on the first refusal. A refusal that took half a selection with it still deleted the other half, and an error saying only that it failed sends somebody looking for contacts that are already gone. Both callers report the count and the reason apart. Emptying a shared book is deliberately not offered: the cards live in the owner's account and this client has no path to write there. One bug found by driving the built app rather than by any test, and worth recording because of where it hid. The range a shift-click covers was 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 store assertion still passed, because nothing was wrong below the component. The anchor is read before the updater now, and the contacts view has its first component tests: ten of them, six of which fail if the measurement moves back inside. Twelve new strings, in all nine catalogues, so nothing new falls back to English.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} 件は他のアドレス帳にもあるため、このアドレス帳から外しただけです" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} контакта также были в других адресных книгах и удалены только из этой" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} контакта також були в інших адресних книгах і вилучені лише з цієї" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} 位也在其他通讯录中,仅从此通讯录移除" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, Record<string, unknown>>; destroy?: Id[] }
|
||||
|
||||
function server(opts: { max?: number; notDestroyed?: Record<string, unknown> } = {}) {
|
||||
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, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "ContactCard/set") {
|
||||
const update = args.update as Record<string, Record<string, unknown>> | 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<Id, ContactCard> });
|
||||
|
||||
/** 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<Id, ContactCard> });
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,21 @@ interface ContactsState {
|
||||
filterCards(cards: ContactCard[], text: string): ContactCard[];
|
||||
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
|
||||
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
|
||||
destroyCards(ids: Id[]): Promise<void>;
|
||||
/**
|
||||
* 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<Id>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
@@ -464,12 +478,12 @@ export const useContacts = create<ContactsState>((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<SetResponse>("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<ContactsState>((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<Id, unknown> = {};
|
||||
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<Id, unknown> = {};
|
||||
for (const [kind, id] of part) if (kind === "update") partUpdate[id] = update[id];
|
||||
const res = await client.call<SetResponse<ContactCard>>("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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Eraser size={16} />}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<Trash2 size={16} />}
|
||||
|
||||
@@ -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<Partial<ContactCard> | 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<Record<string, true>>({});
|
||||
const lastPicked = useRef<string | null>(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 <div className="p-16"><Empty icon={<Users size={40} />} title={translate("Contacts are not available")}>{translate("This account does not have the JMAP contacts capability.")}</Empty></div>;
|
||||
@@ -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 (
|
||||
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
||||
|
||||
<section className="contacts-list">
|
||||
<div className="list-search row">
|
||||
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={16} className="muted" />
|
||||
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => 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. */
|
||||
<div className="list-search row contacts-selbar">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="contact-check"
|
||||
checked={pickedIds.length === list.length}
|
||||
ref={(el) => { 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")}
|
||||
/>
|
||||
<span className="grow">{plural(pickedIds.length, { one: "{n} selected", other: "{n} selected" })}</span>
|
||||
<button className="icon-btn" title={translate("Delete")} onClick={() => void deletePicked()}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn" title={translate("Clear selection")} onClick={clearPicked}><X size={19} /></button>
|
||||
</div>
|
||||
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
|
||||
</div>
|
||||
<div className="contacts-scroll">
|
||||
) : (
|
||||
<div className="list-search row">
|
||||
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
||||
<Search size={16} className="muted" />
|
||||
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`contacts-scroll ${pickedIds.length ? "has-selection" : ""}`}>
|
||||
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
|
||||
<Empty icon={<Users size={36} />} title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("Add a contact or import a vCard file.")}</Empty>
|
||||
) : 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 (
|
||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""} ${picked[c.id] ? "picked" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
|
||||
{!readOnly && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="contact-check"
|
||||
checked={Boolean(picked[c.id])}
|
||||
onClick={(ev) => { ev.stopPropagation(); tick(c.id, !picked[c.id], ev.shiftKey); }}
|
||||
onChange={() => {}}
|
||||
aria-label={translate("Select")}
|
||||
/>
|
||||
)}
|
||||
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> {translate("· group")}</span> : null}</div>
|
||||
@@ -234,7 +334,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
|
||||
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
|
||||
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { await contacts.destroyCards([c.id]); toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
|
||||
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<div className="contact-hero">
|
||||
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
|
||||
|
||||
@@ -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(<ContactsView />); });
|
||||
};
|
||||
const boxes = () => [...host.querySelectorAll<HTMLInputElement>(".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<Id, ContactCard>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user