mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: allow setting an address book as default (#924)
This commit is contained in:
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactsSidebar } from '../contacts-sidebar';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl + next/navigation are mocked globally in vitest.setup (t returns the key).
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
@@ -63,3 +63,51 @@ describe('ContactsSidebar — compose to group', () => {
|
||||
expect(onComposeGroup).toHaveBeenCalledWith('g1', 'cc');
|
||||
});
|
||||
});
|
||||
|
||||
const books: AddressBook[] = [
|
||||
{ id: 'ab-1', name: 'Personal', isDefault: true },
|
||||
{ id: 'ab-2', name: 'Work' },
|
||||
{ id: 'ab-3', name: 'Team', isShared: true, accountId: 'account-2', accountName: 'Team account' },
|
||||
];
|
||||
|
||||
function renderBooksSidebar(onSetDefaultAddressBook = vi.fn()) {
|
||||
render(
|
||||
<ContactsSidebar
|
||||
groups={[]}
|
||||
individuals={[]}
|
||||
addressBooks={books}
|
||||
activeCategory="all"
|
||||
onSelectCategory={vi.fn()}
|
||||
onCreateGroup={vi.fn()}
|
||||
onCreateContact={vi.fn()}
|
||||
onSetDefaultAddressBook={onSetDefaultAddressBook}
|
||||
/>,
|
||||
);
|
||||
return onSetDefaultAddressBook;
|
||||
}
|
||||
|
||||
describe('ContactsSidebar - set default address book', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('offers "Set as default" for an owned, non-default book', () => {
|
||||
const onSetDefaultAddressBook = renderBooksSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Work'));
|
||||
|
||||
fireEvent.click(screen.getByText('address_books.set_default'));
|
||||
expect(onSetDefaultAddressBook).toHaveBeenCalledWith(expect.objectContaining({ id: 'ab-2' }));
|
||||
});
|
||||
|
||||
it('hides it for the book that is already the default', () => {
|
||||
renderBooksSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Personal'));
|
||||
|
||||
expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides it for a book shared from another account', () => {
|
||||
renderBooksSidebar();
|
||||
fireEvent.contextMenu(screen.getByText('Team'));
|
||||
|
||||
expect(screen.queryByText('address_books.set_default')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,6 +359,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||
return defaultAddressBookId;
|
||||
}
|
||||
// New contacts land in the account's default book unless told otherwise -
|
||||
// preselect it so the form shows where the contact will actually go.
|
||||
if (!contact) {
|
||||
const ownDefault = addressBooks?.find(b => b.isDefault && !b.isShared);
|
||||
if (ownDefault) return ownDefault.id;
|
||||
}
|
||||
return "";
|
||||
}, [contact, defaultAddressBookId, addressBooks]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
@@ -98,6 +98,7 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
setDefaultAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -950,6 +951,14 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onSetDefaultAddressBook={client ? async (book) => {
|
||||
try {
|
||||
await setDefaultAddressBook(client, book);
|
||||
toast.success(t("address_books.default_updated"));
|
||||
} catch {
|
||||
toast.error(t("address_books.set_default_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
handleCreateNew();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
@@ -31,6 +31,7 @@ interface ContactsSidebarProps {
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||
onSetDefaultAddressBook?: (addressBook: AddressBook) => void;
|
||||
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
@@ -101,6 +102,7 @@ export function ContactsSidebar({
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onShareAddressBook,
|
||||
onSetDefaultAddressBook,
|
||||
onCreateContactInBook,
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
@@ -111,6 +113,7 @@ export function ContactsSidebar({
|
||||
const router = useRouter();
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu: bookContextMenu, openContextMenu: openBookContextMenu, closeContextMenu: closeBookContextMenu, menuRef: bookMenuRef } = useContextMenu<AddressBook>();
|
||||
const hasBookMenu = !!(onRenameAddressBook || onShareAddressBook || onSetDefaultAddressBook || onCreateContactInBook || onDeleteAddressBook);
|
||||
const { contextMenu: keywordContextMenu, openContextMenu: openKeywordContextMenu, closeContextMenu: closeKeywordContextMenu, menuRef: keywordMenuRef } = useContextMenu<string>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
@@ -383,7 +386,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -402,7 +405,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -449,7 +452,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -593,7 +596,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={hasBookMenu ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -601,13 +604,16 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||
{bookContextMenu.data && hasBookMenu && (() => {
|
||||
const book = bookContextMenu.data;
|
||||
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||
// 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 = onSetDefaultAddressBook && !book.isShared && !book.isDefault;
|
||||
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||
const showSeparator = (canCreate || canRename || canShare || canSetDefault) && canDelete;
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
@@ -645,6 +651,16 @@ export function ContactsSidebar({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<ContextMenuItem
|
||||
icon={Star}
|
||||
label={t("address_books.set_default")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onSetDefaultAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, BookPlus, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { Book, BookPlus, Pencil, Share2, Star, Tag, Users } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
@@ -74,7 +74,7 @@ export function AddressBookManagementSettings() {
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
@@ -116,6 +116,19 @@ export function AddressBookManagementSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (book: AddressBook) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await setDefaultAddressBook(client, book);
|
||||
toast.success(t("default_updated"));
|
||||
} catch {
|
||||
toast.error(t("set_default_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Group: personal first, then by shared account
|
||||
const personal = addressBooks.filter((b) => !b.isShared);
|
||||
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
|
||||
@@ -141,6 +154,9 @@ 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;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -182,6 +198,17 @@ export function AddressBookManagementSettings() {
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetDefault(book)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
title={t("set_default")}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!book.isShared && book.myRights?.mayShare && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user