mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user