Paginate address book contacts
This commit is contained in:
@@ -80,6 +80,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
|
count_contacts,
|
||||||
delete_address_book,
|
delete_address_book,
|
||||||
delete_address_list,
|
delete_address_list,
|
||||||
delete_address_list_entry,
|
delete_address_list_entry,
|
||||||
@@ -430,16 +431,41 @@ def api_restore_address_book(
|
|||||||
@router.get("/contacts", response_model=ContactListResponse)
|
@router.get("/contacts", response_model=ContactListResponse)
|
||||||
def api_list_contacts(
|
def api_list_contacts(
|
||||||
address_book_id: str | None = Query(default=None),
|
address_book_id: str | None = Query(default=None),
|
||||||
|
address_list_id: str | None = Query(default=None),
|
||||||
query: str | None = Query(default=None),
|
query: str | None = Query(default=None),
|
||||||
limit: int = Query(default=200, ge=1, le=500),
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
include_deleted: bool = Query(default=False),
|
include_deleted: bool = Query(default=False),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "addresses:contact:read")
|
_require_scope(principal, "addresses:contact:read")
|
||||||
try:
|
try:
|
||||||
contacts = list_contacts(session, principal, address_book_id=address_book_id, query=query, limit=limit, include_deleted=include_deleted)
|
contacts = list_contacts(
|
||||||
return ContactListResponse(contacts=[_contact_response(contact) for contact in contacts])
|
session,
|
||||||
|
principal,
|
||||||
|
address_book_id=address_book_id,
|
||||||
|
address_list_id=address_list_id,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
total = count_contacts(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_book_id=address_book_id,
|
||||||
|
address_list_id=address_list_id,
|
||||||
|
query=query,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
return ContactListResponse(
|
||||||
|
contacts=[_contact_response(contact) for contact in contacts],
|
||||||
|
total=total,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
has_more=offset + len(contacts) < total,
|
||||||
|
)
|
||||||
except AddressBookError as exc:
|
except AddressBookError as exc:
|
||||||
raise _error(exc) from exc
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,10 @@ class ContactResponse(BaseModel):
|
|||||||
|
|
||||||
class ContactListResponse(BaseModel):
|
class ContactListResponse(BaseModel):
|
||||||
contacts: list[ContactResponse]
|
contacts: list[ContactResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
class AddressLookupResponse(BaseModel):
|
class AddressLookupResponse(BaseModel):
|
||||||
|
|||||||
@@ -2421,14 +2421,79 @@ def list_contacts(
|
|||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
*,
|
*,
|
||||||
address_book_id: str | None = None,
|
address_book_id: str | None = None,
|
||||||
|
address_list_id: str | None = None,
|
||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
offset: int = 0,
|
||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
) -> list[Contact]:
|
) -> list[Contact]:
|
||||||
|
contact_query = _filtered_contact_query(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_book_id=address_book_id,
|
||||||
|
address_list_id=address_list_id,
|
||||||
|
query=query,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
contact_query.order_by(Contact.display_name.asc(), Contact.id.asc())
|
||||||
|
.offset(max(0, offset))
|
||||||
|
.limit(max(1, min(limit, 500)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_contacts(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
address_book_id: str | None = None,
|
||||||
|
address_list_id: str | None = None,
|
||||||
|
query: str | None = None,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> int:
|
||||||
|
contact_query = _filtered_contact_query(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_book_id=address_book_id,
|
||||||
|
address_list_id=address_list_id,
|
||||||
|
query=query,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
contact_query.order_by(None)
|
||||||
|
.with_entities(func.count(func.distinct(Contact.id)))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filtered_contact_query(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
address_book_id: str | None,
|
||||||
|
address_list_id: str | None,
|
||||||
|
query: str | None,
|
||||||
|
include_deleted: bool,
|
||||||
|
):
|
||||||
contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
|
contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted)
|
||||||
if address_book_id:
|
if address_book_id:
|
||||||
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted)
|
get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted)
|
||||||
contact_query = contact_query.filter(Contact.address_book_id == address_book_id)
|
contact_query = contact_query.filter(Contact.address_book_id == address_book_id)
|
||||||
|
if address_list_id:
|
||||||
|
address_list = get_visible_address_list(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
address_list_id,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
)
|
||||||
|
if address_book_id and address_list.address_book_id != address_book_id:
|
||||||
|
raise AddressBookError("Address list does not belong to the selected address book.")
|
||||||
|
contact_query = contact_query.join(
|
||||||
|
AddressListEntry,
|
||||||
|
AddressListEntry.contact_id == Contact.id,
|
||||||
|
).filter(AddressListEntry.address_list_id == address_list_id)
|
||||||
normalized_query = _trim(query)
|
normalized_query = _trim(query)
|
||||||
if normalized_query:
|
if normalized_query:
|
||||||
pattern = f"%{normalized_query.lower()}%"
|
pattern = f"%{normalized_query.lower()}%"
|
||||||
@@ -2439,7 +2504,7 @@ def list_contacts(
|
|||||||
func.lower(ContactEmail.email).like(pattern),
|
func.lower(ContactEmail.email).like(pattern),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()).limit(max(1, min(limit, 500))).all()
|
return contact_query.distinct()
|
||||||
|
|
||||||
|
|
||||||
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:
|
def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from govoplan_addresses.backend.service import (
|
|||||||
create_carddav_sync_source,
|
create_carddav_sync_source,
|
||||||
create_contact,
|
create_contact,
|
||||||
create_sync_source,
|
create_sync_source,
|
||||||
|
count_contacts,
|
||||||
delete_address_list_entry,
|
delete_address_list_entry,
|
||||||
delete_contact,
|
delete_contact,
|
||||||
delete_sync_source,
|
delete_sync_source,
|
||||||
@@ -252,6 +253,50 @@ class AddressServiceTest(unittest.TestCase):
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
self.assertEqual([item.id for item in list_contacts(self.session, self.principal, address_book_id=book.id)], [contact.id])
|
||||||
|
|
||||||
|
def test_contact_windows_report_exact_totals(self) -> None:
|
||||||
|
book = create_address_book(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
AddressBookCreateRequest(scope_type="user", name="Paged"),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
contacts = [
|
||||||
|
create_contact(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
book.id,
|
||||||
|
ContactCreateRequest(
|
||||||
|
display_name=name,
|
||||||
|
emails=[ContactEmailPayload(email=f"{name.lower()}@example.local")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name in ("Ada", "Barbara", "Claude", "Dorothy", "Edsger")
|
||||||
|
]
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
page = list_contacts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
address_book_id=book.id,
|
||||||
|
limit=2,
|
||||||
|
offset=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([contact.id for contact in page], [contacts[2].id, contacts[3].id])
|
||||||
|
self.assertEqual(
|
||||||
|
count_contacts(self.session, self.principal, address_book_id=book.id),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
count_contacts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
address_book_id=book.id,
|
||||||
|
query="example.local",
|
||||||
|
),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
def test_vcard_import_and_export_preserves_common_fields(self) -> None:
|
def test_vcard_import_and_export_preserves_common_fields(self) -> None:
|
||||||
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
|
book = create_address_book(self.session, self.principal, AddressBookCreateRequest(scope_type="user", name="Imported"))
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -470,6 +515,27 @@ END:VCARD
|
|||||||
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
|
self.assertEqual(entries[0].contact_email.email, "ada.private@example.local")
|
||||||
self.assertEqual(entries[1].target_kind, "postal_address")
|
self.assertEqual(entries[1].target_kind, "postal_address")
|
||||||
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
|
self.assertEqual(entries[1].contact_postal_address.locality, "Berlin")
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
item.id
|
||||||
|
for item in list_contacts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
address_book_id=book.id,
|
||||||
|
address_list_id=address_list.id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[contact.id],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
count_contacts(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
address_book_id=book.id,
|
||||||
|
address_list_id=address_list.id,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(ValueError, "same address book"):
|
with self.assertRaisesRegex(ValueError, "same address book"):
|
||||||
create_address_list_entry(
|
create_address_list_entry(
|
||||||
|
|||||||
@@ -168,8 +168,12 @@ type AddressListEntryListResponse = {
|
|||||||
entries: AddressListEntry[];
|
entries: AddressListEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContactListResponse = {
|
export type ContactListResponse = {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
has_more: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AddressBookWriteTargetsResponse = {
|
type AddressBookWriteTargetsResponse = {
|
||||||
@@ -588,11 +592,15 @@ export function resolveAddressSyncConflict(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;query?: string | null;limit?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
|
export function listContactsPage(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<ContactListResponse> {
|
||||||
const response = await apiFetch<ContactListResponse>(
|
return apiFetch<ContactListResponse>(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, query: options.query, limit: options.limit, include_deleted: options.includeDeleted ? "true" : null })}`
|
`/api/v1/addresses/contacts${queryString({ address_book_id: options.addressBookId, address_list_id: options.addressListId, query: options.query, limit: options.limit, offset: options.offset, include_deleted: options.includeDeleted ? "true" : null })}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listContacts(settings: ApiSettings, options: {addressBookId?: string | null;addressListId?: string | null;query?: string | null;limit?: number;offset?: number;includeDeleted?: boolean;} = {}): Promise<Contact[]> {
|
||||||
|
const response = await listContactsPage(settings, options);
|
||||||
return response.contacts;
|
return response.contacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
DataGridPaginationBar,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
ExplorerTree,
|
ExplorerTree,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
listAddressSyncSources,
|
listAddressSyncSources,
|
||||||
listAddressSyncTombstones,
|
listAddressSyncTombstones,
|
||||||
listContacts,
|
listContacts,
|
||||||
|
listContactsPage,
|
||||||
previewAddressSyncSource,
|
previewAddressSyncSource,
|
||||||
restoreAddressBook,
|
restoreAddressBook,
|
||||||
restoreAddressList,
|
restoreAddressList,
|
||||||
@@ -621,6 +623,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
const [addressListEntries, setAddressListEntries] = useState<AddressListEntry[]>([]);
|
||||||
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
|
const [syncSources, setSyncSources] = useState<AddressSyncSource[]>([]);
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [contactTotal, setContactTotal] = useState(0);
|
||||||
|
const [contactPage, setContactPage] = useState(1);
|
||||||
|
const [contactPageSize, setContactPageSize] = useState(50);
|
||||||
const [selectedBookId, setSelectedBookId] = useState("");
|
const [selectedBookId, setSelectedBookId] = useState("");
|
||||||
const [selectedListId, setSelectedListId] = useState("");
|
const [selectedListId, setSelectedListId] = useState("");
|
||||||
const [selectedContactId, setSelectedContactId] = useState("");
|
const [selectedContactId, setSelectedContactId] = useState("");
|
||||||
@@ -718,10 +723,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
|
const addressTreeNodes = useMemo(() => buildAddressTree(books, addressLists), [addressLists, books]);
|
||||||
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
|
const selectedListContactIds = useMemo(() => listEntryContactIds(addressListEntries), [addressListEntries]);
|
||||||
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
|
const selectedListEntryKeys = useMemo(() => new Set(addressListEntries.map(addressListEntryKey)), [addressListEntries]);
|
||||||
const visibleContacts = useMemo(
|
const visibleContacts = contacts;
|
||||||
() => selectedList ? contacts.filter((contact) => selectedListContactIds.has(contact.id)) : contacts,
|
|
||||||
[contacts, selectedList, selectedListContactIds]
|
|
||||||
);
|
|
||||||
const memberCandidateContacts = useMemo(() => {
|
const memberCandidateContacts = useMemo(() => {
|
||||||
const normalizedQuery = memberQuery.trim().toLowerCase();
|
const normalizedQuery = memberQuery.trim().toLowerCase();
|
||||||
return memberCandidates
|
return memberCandidates
|
||||||
@@ -985,10 +987,25 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
const refreshContacts = useCallback(async (bookId: string, search: string) => {
|
const refreshContacts = useCallback(async (bookId: string, search: string) => {
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
setContacts([]);
|
setContacts([]);
|
||||||
|
setContactTotal(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setContacts(await listContacts(settings, { addressBookId: bookId, query: search, limit: 500, includeDeleted: showArchived }));
|
const response = await listContactsPage(settings, {
|
||||||
}, [settings, showArchived]);
|
addressBookId: bookId,
|
||||||
|
addressListId: selectedListId || undefined,
|
||||||
|
query: search,
|
||||||
|
limit: contactPageSize,
|
||||||
|
offset: (contactPage - 1) * contactPageSize,
|
||||||
|
includeDeleted: showArchived
|
||||||
|
});
|
||||||
|
const pageCount = Math.max(1, Math.ceil(response.total / contactPageSize));
|
||||||
|
if (contactPage > pageCount) {
|
||||||
|
setContactPage(pageCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setContacts(response.contacts);
|
||||||
|
setContactTotal(response.total);
|
||||||
|
}, [contactPage, contactPageSize, selectedListId, settings, showArchived]);
|
||||||
|
|
||||||
const refreshListEntries = useCallback(async (listId: string) => {
|
const refreshListEntries = useCallback(async (listId: string) => {
|
||||||
if (!listId) {
|
if (!listId) {
|
||||||
@@ -1163,12 +1180,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
|
|
||||||
function openTreeNode(node: AddressTreeNode) {
|
function openTreeNode(node: AddressTreeNode) {
|
||||||
if (node.kind === "book" && node.book) {
|
if (node.kind === "book" && node.book) {
|
||||||
|
setContactPage(1);
|
||||||
setSelectedBookId(node.book.id);
|
setSelectedBookId(node.book.id);
|
||||||
setSelectedListId("");
|
setSelectedListId("");
|
||||||
setSelectedContactId("");
|
setSelectedContactId("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.kind === "list" && node.list) {
|
if (node.kind === "list" && node.list) {
|
||||||
|
setContactPage(1);
|
||||||
setSelectedBookId(node.list.address_book_id);
|
setSelectedBookId(node.list.address_book_id);
|
||||||
setSelectedListId(node.list.id);
|
setSelectedListId(node.list.id);
|
||||||
setSelectedContactId("");
|
setSelectedContactId("");
|
||||||
@@ -1984,7 +2003,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
label="Show archived"
|
label="Show archived"
|
||||||
checked={showArchived}
|
checked={showArchived}
|
||||||
disabled={Boolean(toggleArchiveReason)}
|
disabled={Boolean(toggleArchiveReason)}
|
||||||
onChange={() => setShowArchived((current) => !current)}
|
onChange={() => {
|
||||||
|
setContactPage(1);
|
||||||
|
setShowArchived((current) => !current);
|
||||||
|
}}
|
||||||
help="Include archived address books, lists, and contacts."
|
help="Include archived address books, lists, and contacts."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2018,7 +2040,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
<div>
|
<div>
|
||||||
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
<h2>{selectedList ? selectedList.name : selectedBook ? selectedBook.name : "Contacts"}</h2>
|
||||||
<p>
|
<p>
|
||||||
{visibleContacts.length} contact{visibleContacts.length === 1 ? "" : "s"}
|
{contactTotal} contact{contactTotal === 1 ? "" : "s"}
|
||||||
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
{selectedList ? " in selected list" : selectedBook ? " in selected book" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2030,7 +2052,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="address-contact-toolbar">
|
<div className="address-contact-toolbar">
|
||||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search contacts" />
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => {
|
||||||
|
setContactPage(1);
|
||||||
|
setQuery(event.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Search contacts" />
|
||||||
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
{selectedBook?.read_only && <StatusBadge status="read-only" />}
|
||||||
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -2042,6 +2070,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
|||||||
</SelectionList>
|
</SelectionList>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<DataGridPaginationBar
|
||||||
|
page={contactPage}
|
||||||
|
pageSize={contactPageSize}
|
||||||
|
totalRows={contactTotal}
|
||||||
|
pageSizeOptions={[25, 50, 100, 200]}
|
||||||
|
disabled={loading || saving || !selectedBook}
|
||||||
|
className="address-contact-pagination"
|
||||||
|
ariaLabel="Contact pagination"
|
||||||
|
onPageChange={setContactPage}
|
||||||
|
onPageSizeChange={(pageSize) => {
|
||||||
|
setContactPageSize(pageSize);
|
||||||
|
setContactPage(1);
|
||||||
|
}} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="address-detail-panel" aria-label="Contact detail">
|
<section className="address-detail-panel" aria-label="Contact detail">
|
||||||
|
|||||||
@@ -308,6 +308,11 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.address-contact-pagination {
|
||||||
|
border-top: var(--border-line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.address-contact-selection-list {
|
.address-contact-selection-list {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user