diff --git a/components/settings/__tests__/address-book-management-settings.test.tsx b/components/settings/__tests__/address-book-management-settings.test.tsx index 1fd7554ab..ece39d7fe 100644 --- a/components/settings/__tests__/address-book-management-settings.test.tsx +++ b/components/settings/__tests__/address-book-management-settings.test.tsx @@ -62,6 +62,31 @@ describe('address books in Settings', () => { }); }); +// Phone: four icons next to an address book's name left it a few letters. +// The actions are in a named ⋮ menu (right-click too), as for calendars. +describe('address book row menu', () => { + it('offers the row actions from a named ⋮ menu, Delete last and only where it applies', async () => { + render(); + const more = screen.getAllByRole('button', { name: 'row_actions' }); + expect(more).toHaveLength(2); + + fireEvent.click(more[1]); // Club, not the default book + const menu = await screen.findByRole('menu'); + const items = Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent); + expect(items).toEqual(['rename', 'set_default', 'share', 'delete']); + + fireEvent.click(screen.getByRole('menuitem', { name: 'delete' })); + expect(await screen.findByRole('alertdialog', { name: 'delete_title' })).toBeTruthy(); + }); + + it('hides the row icons on a phone, where the menu is', () => { + render(); + const group = screen.getAllByRole('button', { name: 'rename' })[0].parentElement as HTMLElement; + expect(group.className).toMatch(/(^| )hidden( |$)/); + expect(group.className).toContain('sm:flex'); + }); +}); + describe('categories in Settings', () => { it('deletes a category from its contacts, and Undo puts it back on the same ones', async () => { render(); diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx index a9f78de91..dbefb97e6 100644 --- a/components/settings/address-book-management-settings.tsx +++ b/components/settings/address-book-management-settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { Book, BookPlus, Pencil, Share2, Star, Tag, Trash2, Users } from "@/components/icons"; +import { Book, BookPlus, MoreVertical, Pencil, Share2, Star, Tag, Trash2, Users } from "@/components/icons"; import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { useManagedAccountStore } from "@/stores/managed-account-store"; @@ -16,6 +16,8 @@ import { PromptDialog } from "@/components/ui/prompt-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useDeleteCategory } from "@/hooks/use-delete-category"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; function AddressBookEditRow({ initial, @@ -86,6 +88,7 @@ export function AddressBookManagementSettings() { const [sharingId, setSharingId] = useState(null); const [creating, setCreating] = useState(false); const [isLoading, setIsLoading] = useState(false); + const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); useEffect(() => { if (client && addressBooks.length === 0) { @@ -178,6 +181,44 @@ export function AddressBookManagementSettings() { else sharedGroups.set(key, { accountName: book.accountName || book.accountId, books: [book] }); } + // One action list per address book (R6), as for calendars: the row's icons + // on wider screens, the ⋮ and right-click menu everywhere. + const bookActions = (book: AddressBook) => { + const canRename = !book.isShared || book.myRights?.mayWrite !== false; + // The default only applies to books the account owns, and re-setting the + // current default is a no-op, so hide it in both cases. + const canSetDefault = !!client && !book.isShared && !book.isDefault; + // The default book is where new contacts go, so it stays; a shared one + // only when its owner lets this account delete it. + const canDelete = !!client && !book.isDefault && book.myRights?.mayDelete !== false; + const actions: Array<{ id: string; label: string; icon: typeof Pencil; run: () => void; disabled?: boolean; destructive?: boolean }> = []; + if (canRename) actions.push({ id: "rename", label: t("rename"), icon: Pencil, run: () => setEditingId(book.id) }); + if (canSetDefault) actions.push({ id: "default", label: t("set_default"), icon: Star, run: () => void handleSetDefault(book), disabled: isLoading }); + if (!book.isShared && book.myRights?.mayShare) actions.push({ id: "share", label: t("share"), icon: Users, run: () => setSharingId(book.id) }); + if (canDelete) actions.push({ id: "delete", label: t("delete"), icon: Trash2, run: () => void handleDelete(book), disabled: isLoading, destructive: true }); + return actions; + }; + + const renderBookMenu = () => { + const book = contextMenu.data; + if (!book) return null; + const run = (fn: () => void) => () => { closeContextMenu(); fn(); }; + const actions = bookActions(book); + const plain = actions.filter((a) => !a.destructive); + const destructive = actions.filter((a) => a.destructive); + return ( + + {plain.map((a) => ( + + ))} + {plain.length > 0 && destructive.length > 0 && } + {destructive.map((a) => ( + + ))} + + ); + }; + const renderBook = (book: AddressBook) => { if (editingId === book.id) { return ( @@ -191,17 +232,12 @@ export function AddressBookManagementSettings() { ); } - const canRename = !book.isShared || book.myRights?.mayWrite !== false; - // The default only applies to books the account owns, and re-setting the - // current default is a no-op, so hide it in both cases. - const canSetDefault = !!client && !book.isShared && !book.isDefault; - // The default book is where new contacts go, so it stays; a shared one - // only when its owner lets this account delete it. - const canDelete = !!client && !book.isDefault && book.myRights?.mayDelete !== false; + const actions = bookActions(book); return (
0 ? (e) => openContextMenu(e, book) : undefined} className={cn( "flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group" )} @@ -228,32 +264,32 @@ export function AddressBookManagementSettings() { ); })()} -
- {canRename && ( - setEditingId(book.id)} className="text-muted-foreground"> - - - )} - {canSetDefault && ( - handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground"> - - - )} - {!book.isShared && book.myRights?.mayShare && ( - setSharingId(book.id)} className="text-muted-foreground"> - - - )} - {canDelete && ( + {/* Phone: four icons next to the name left it a few letters, so the + actions sit behind a ⋮ there, as for calendars. */} + {actions.length > 0 && ( + openContextMenuFrom(e.currentTarget, book, "end")} + > + + + )} +
+ {actions.map((action) => ( void handleDelete(book)} - disabled={isLoading} - className="text-muted-foreground hover:text-destructive hover:bg-destructive/10" + key={action.id} + label={action.label} + onClick={action.run} + disabled={action.disabled} + className={cn( + "text-muted-foreground", + action.destructive && "hover:text-destructive hover:bg-destructive/10", + )} > - + - )} + ))}
); @@ -285,6 +321,7 @@ export function AddressBookManagementSettings() { return ( <> + {renderBookMenu()}
{/* In scoped mode (managing a shared account) hide the user's own address books and show only the managed account's group. */}