fix: leave Edit, Move and Delete out for contacts in a read-only address book

The two "Add to group" tests click the entry by its new key, which ends
in "…" because it opens a group picker.
This commit is contained in:
Linus Rath
2026-11-04 05:32:24 +01:00
parent fc7b607ff7
commit 8dfaa39607
5 changed files with 52 additions and 15 deletions
@@ -182,7 +182,7 @@ describe('ContactsApp', () => {
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
@@ -190,11 +190,27 @@ describe('ContactsApp', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => {
const shared = { ...alice, addressBookIds: { 'ab-shared': true } };
renderContacts([shared, bob]);
act(() => {
useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never });
});
fireEvent.contextMenu(screen.getByText('Alice Smith'));
expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull();
expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
fireEvent.contextMenu(screen.getByText('Bob Jones'));
expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument();
});
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
+20 -11
View File
@@ -65,6 +65,8 @@ interface ContactContextMenuProps {
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
/** The contact is in an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
export function ContactContextMenu({
@@ -88,8 +90,10 @@ export function ContactContextMenu({
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
@@ -134,7 +138,7 @@ export function ContactContextMenu({
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
label={tMenu("contacts_bulk_add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{onEdit && !readOnly && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
label={tMenu("contacts_add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
{onMoveTo && !readOnly && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
{!readOnly && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
shortcut="Del"
onClick={handle(onDelete)}
destructive
/>
</>
)}
</ContextMenu>
);
}
+5 -2
View File
@@ -35,6 +35,8 @@ interface ContactDetailProps {
onPrint?: () => void;
isMobile?: boolean;
className?: string;
/** In an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
// Contexts and phone features the contact form offers, shown with the same
@@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
@@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</a>
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
{isMobile ? (
{readOnly ? null : isMobile ? (
<ToolbarButton label={t("form.edit_title")} onClick={onEdit} className="touch-manipulation">
<Pencil />
</ToolbarButton>
@@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
readOnly={readOnly}
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
+4
View File
@@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la
interface ContactListProps {
contacts: ContactCard[];
/** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */
isContactReadOnly?: (contact: ContactCard) => boolean;
selectedContactId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
@@ -144,6 +146,7 @@ interface ContactListProps {
export function ContactList({
contacts,
isContactReadOnly,
selectedContactId,
searchQuery,
onSearchChange,
@@ -788,6 +791,7 @@ export function ContactList({
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
readOnly={isContactReadOnly?.(contextMenu.data) ?? false}
/>
)}
</div>
+5
View File
@@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
// A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete.
const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {})
.some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]);
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
@@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined}
isMobile={isMobile}
readOnly={selectedContact ? isContactReadOnly(selectedContact) : false}
/>
);
}
@@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
) : (
<ContactList
contacts={displayedContacts}
isContactReadOnly={isContactReadOnly}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}