fix: put address book actions in settings behind a ⋮ menu on the phone, like calendars

This commit is contained in:
Linus Rath
2026-10-28 13:02:19 +01:00
parent ddaf2e24b2
commit 6724b04701
2 changed files with 93 additions and 31 deletions
@@ -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(<AddressBookManagementSettings />);
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(<AddressBookManagementSettings />);
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(<AddressBookManagementSettings />);
@@ -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<string | null>(null);
const [creating, setCreating] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<AddressBook>();
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 (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{plain.map((a) => (
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} disabled={a.disabled} onClick={run(a.run)} />
))}
{plain.length > 0 && destructive.length > 0 && <ContextMenuSeparator />}
{destructive.map((a) => (
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} disabled={a.disabled} destructive onClick={run(a.run)} />
))}
</ContextMenu>
);
};
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 (
<div
key={book.id}
onContextMenu={actions.length > 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() {
</span>
);
})()}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{canRename && (
<IconButton label={t("rename")} onClick={() => setEditingId(book.id)} className="text-muted-foreground">
<Pencil />
</IconButton>
)}
{canSetDefault && (
<IconButton label={t("set_default")} onClick={() => handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground">
<Star />
</IconButton>
)}
{!book.isShared && book.myRights?.mayShare && (
<IconButton label={t("share")} onClick={() => setSharingId(book.id)} className="text-muted-foreground">
<Users />
</IconButton>
)}
{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 && (
<IconButton
label={tContacts("row_actions", { name: book.name })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, book, "end")}
>
<MoreVertical />
</IconButton>
)}
<div className="hidden sm:flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{actions.map((action) => (
<IconButton
label={t("delete")}
onClick={() => 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",
)}
>
<Trash2 />
<action.icon />
</IconButton>
)}
))}
</div>
</div>
);
@@ -285,6 +321,7 @@ export function AddressBookManagementSettings() {
return (
<>
<SettingsSection id="address-books" title={tSettings("manage_title")} description={tSettings("manage_description")}>
{renderBookMenu()}
<div className="space-y-2">
{/* In scoped mode (managing a shared account) hide the user's own
address books and show only the managed account's group. */}