Merge pull request #278 from Coffey-Labs/contacts-bulk-delete
Select contacts, and empty an address book
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,
|
company, job title, any number of emails, phones and addresses with types,
|
||||||
birthday, website and notes.
|
birthday, website and notes.
|
||||||
- **Groups** as a card kind, with members picked from the book.
|
- **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
|
- **Letter index** down the list, with `#` for everything that does not start
|
||||||
with a letter.
|
with a letter.
|
||||||
- **Search** across name, address, organisation and notes, in one book or all.
|
- **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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── 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" },
|
"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" },
|
"in {n} folders": { one: "in {n} Ordner", other: "in {n} Ordnern" },
|
||||||
"Deleted {n} messages": { one: "{n} Nachricht gelöscht", other: "{n} Nachrichten gelöscht" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── 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" },
|
"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" },
|
"in {n} folders": { one: "en {n} carpeta", other: "en {n} carpetas" },
|
||||||
"Deleted {n} messages": { one: "{n} mensaje eliminado", other: "{n} mensajes eliminados" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── 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" },
|
"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" },
|
"in {n} folders": { one: "dans {n} dossier", other: "dans {n} dossiers" },
|
||||||
"Deleted {n} messages": { one: "{n} message supprimé", other: "{n} messages supprimés" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
@@ -1359,5 +1368,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
|
"Marked {n} messages as read": { other: "{n} 通のメールを既読にしました" },
|
||||||
"in {n} folders": { other: "{n} 個のフォルダーで" },
|
"in {n} folders": { other: "{n} 個のフォルダーで" },
|
||||||
"Deleted {n} messages": { 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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── 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" },
|
"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" },
|
"in {n} folders": { one: "in {n} map", other: "in {n} mappen" },
|
||||||
"Deleted {n} messages": { one: "{n} bericht verwijderd", other: "{n} berichten verwijderd" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── 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" },
|
"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" },
|
"in {n} folders": { one: "em {n} pasta", other: "em {n} pastas" },
|
||||||
"Deleted {n} messages": { one: "{n} mensagem excluída", other: "{n} mensagens excluídas" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
@@ -1356,5 +1365,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
|
"Marked {n} messages as read": { one: "{n} письмо отмечено как прочитанное", few: "{n} письма отмечены как прочитанные", many: "{n} писем отмечены как прочитанные", other: "{n} письма отмечены как прочитанные" },
|
||||||
"in {n} folders": { 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} письма" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
@@ -1348,5 +1357,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
|
"Marked {n} messages as read": { one: "{n} лист позначено як прочитаний", few: "{n} листи позначено як прочитані", many: "{n} листів позначено як прочитані", other: "{n} листа позначено як прочитані" },
|
||||||
"in {n} folders": { 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} листа" },
|
"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
|
// Sentences that lib/ and store/ were building in English, and the two
|
||||||
// swipe labels that reach t() through a variable and so were invisible
|
// swipe labels that reach t() through a variable and so were invisible
|
||||||
// to a scan for t("literal"). See #259.
|
// 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: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
@@ -1358,5 +1367,10 @@ export const catalog: Catalog = {
|
|||||||
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
|
"Marked {n} messages as read": { other: "已将 {n} 封邮件标为已读" },
|
||||||
"in {n} folders": { other: "在 {n} 个文件夹中" },
|
"in {n} folders": { other: "在 {n} 个文件夹中" },
|
||||||
"Deleted {n} messages": { 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[];
|
filterCards(cards: ContactCard[], text: string): ContactCard[];
|
||||||
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
|
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
|
||||||
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
|
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>;
|
createBook(name: string): Promise<Id>;
|
||||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||||
destroyBook(id: Id): Promise<void>;
|
destroyBook(id: Id): Promise<void>;
|
||||||
@@ -464,12 +478,12 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
async destroyCards(ids) {
|
async destroyCards(ids) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const gone: Id[] = [];
|
const gone: Id[] = [];
|
||||||
let failed: SetError | undefined;
|
let refused: SetError | undefined;
|
||||||
try {
|
try {
|
||||||
for (const part of chunk(ids, client.maxObjectsInSet)) {
|
for (const part of chunk(ids, client.maxObjectsInSet)) {
|
||||||
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: part });
|
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: part });
|
||||||
gone.push(...(res.destroyed ?? []));
|
gone.push(...(res.destroyed ?? []));
|
||||||
failed ??= Object.values(res.notDestroyed ?? {})[0];
|
refused ??= Object.values(res.notDestroyed ?? {})[0];
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (gone.length) {
|
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) {
|
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.active { background: var(--selected-bg); }
|
||||||
.contact-row .c-name { font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.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 .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-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-detail { overflow-y: auto; padding: 28px 32px 64px; }
|
||||||
.contact-hero { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
|
.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) {
|
@media (hover: none) {
|
||||||
.msg-row .msg-check { opacity: 1; }
|
.msg-row .msg-check { opacity: 1; }
|
||||||
|
.contact-row .contact-check { opacity: 1; }
|
||||||
.msg-row .msg-actions { display: none !important; }
|
.msg-row .msg-actions { display: none !important; }
|
||||||
.msg-row:hover .msg-meta .msg-date { display: inline; }
|
.msg-row:hover .msg-meta .msg-date { display: inline; }
|
||||||
.nav-item .nav-more, .cal-list-item .nav-more { opacity: 1; }
|
.nav-item .nav-more, .cal-list-item .nav-more { opacity: 1; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { useContacts } from "@/store/contacts";
|
||||||
|
import { setErrorMessage } from "@/jmap/client";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import type { AddressBook } from "@/jmap/types";
|
import type { AddressBook } from "@/jmap/types";
|
||||||
@@ -278,6 +279,51 @@ export function ContactsSidebar() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MenuSep />
|
<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
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon={<Trash2 size={16} />}
|
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 { 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 { useContacts } from "@/store/contacts";
|
||||||
|
import { setErrorMessage } from "@/jmap/client";
|
||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import type { ContactCard } from "@/jmap/types";
|
import type { ContactCard } from "@/jmap/types";
|
||||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
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 bookId = sel.bookId;
|
||||||
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
|
const [editing, setEditing] = useState<Partial<ContactCard> | null>(null);
|
||||||
const openCompose = useCompose((s) => s.open);
|
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(() => {
|
useEffect(() => {
|
||||||
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
|
if (contacts.available && !contacts.loaded && !contacts.loading) void contacts.loadAll();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contacts.available, contacts.loaded]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onNew = () => setEditing({});
|
const onNew = () => setEditing({});
|
||||||
/*
|
/*
|
||||||
@@ -88,6 +108,10 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [list]);
|
}, [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) {
|
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>;
|
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 (
|
return (
|
||||||
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
<div className={`contacts-layout ${selected || editing ? "detail" : ""}`}>
|
||||||
|
|
||||||
<section className="contacts-list">
|
<section className="contacts-list">
|
||||||
<div className="list-search row">
|
{pickedIds.length ? (
|
||||||
<div className="search-input" style={{ flex: 1, height: 38, background: "var(--bg-sunken)", borderRadius: 999, display: "flex", alignItems: "center", gap: 8, padding: "0 12px" }}>
|
/* The search box gives way rather than sitting alongside: what the
|
||||||
<Search size={16} className="muted" />
|
bar counts is what the search left on screen, so leaving the box
|
||||||
<input style={{ flex: 1, border: 0, background: "transparent", outline: "none" }} placeholder={translate("Search contacts")} value={q} onChange={(e) => setQ(e.target.value)} />
|
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>
|
</div>
|
||||||
<button className="icon-btn" title={translate("New contact")} onClick={() => setEditing({})}><Plus size={20} /></button>
|
) : (
|
||||||
</div>
|
<div className="list-search row">
|
||||||
<div className="contacts-scroll">
|
<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 ? (
|
{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>
|
<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) => (
|
) : groups.map((g) => (
|
||||||
@@ -190,7 +280,17 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
const email = contactEmails(c)[0]?.email;
|
const email = contactEmails(c)[0]?.email;
|
||||||
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
const photo = contacts.accountId ? contactPhoto(c, contacts.accountId) : null;
|
||||||
return (
|
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>
|
<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="grow" style={{ minWidth: 0 }}>
|
||||||
<div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> {translate("· group")}</span> : null}</div>
|
<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" />
|
<span className="spacer" />
|
||||||
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
|
<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" 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>
|
||||||
<div className="contact-hero">
|
<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>
|
<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