Person page: server-side search; stop loading the whole tree
The person page fetched the entire tree on every open — all persons (to build a
name map + power the relative pickers) and all events (to find partnership
events). On a 2k-person tree that's a ~230KB person list + ~600KB event list per
view. Now it loads only what the page shows:
Frontend:
- The relationship & spouse pickers use the backend's fuzzy pg_trgm search
(debounced, typo-tolerant) instead of substring-filtering a preloaded array —
better search, and no need to preload every person. PersonCombobox gained an
`onSearch` server mode (client `people` mode still works).
- The page drops the all-persons and all-events fetches; it resolves just this
person's relatives' names via GET /persons?ids=..., and reads partnership
events from the per-person events endpoint.
Backend:
- GET /trees/{id}/persons?ids=a,b,c — batch by id (privacy-filtered, names
batched), for relative-name display.
- list_events_for_person (member path) now also returns the person's partnership
events, so the page needn't scan every event in the tree.
Adversarial review (frontend logic + backend/privacy) found no issues. Suite 105
passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
|
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
|
||||||
@@ -41,9 +41,18 @@ async def list_persons(
|
|||||||
current: CurrentUser,
|
current: CurrentUser,
|
||||||
deleted: bool = False,
|
deleted: bool = False,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
|
ids: str | None = None,
|
||||||
) -> list[PersonRead]:
|
) -> list[PersonRead]:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
if q:
|
if ids is not None:
|
||||||
|
try:
|
||||||
|
id_list = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid ids") from exc
|
||||||
|
persons = await person_service.list_persons_by_ids(
|
||||||
|
session, viewer_id=current.id, tree=tree, ids=id_list
|
||||||
|
)
|
||||||
|
elif q:
|
||||||
persons = await person_service.search_persons(
|
persons = await person_service.search_persons(
|
||||||
session, viewer_id=current.id, tree=tree, query=q
|
session, viewer_id=current.id, tree=tree, query=q
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ engine. Every event has exactly one subject — a Person or a partnership."""
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import RelationshipType
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.person import Person
|
from app.models.person import Person
|
||||||
from app.models.place import Place
|
from app.models.place import Place
|
||||||
@@ -124,12 +125,30 @@ async def list_events_for_person(
|
|||||||
return await public_view_service.list_public_person_events(
|
return await public_view_service.list_public_person_events(
|
||||||
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
)
|
)
|
||||||
|
# Member view: this person's own events PLUS their partnership events (which
|
||||||
|
# live on the relationship and show on both partners). Returning both here
|
||||||
|
# means the person page doesn't have to load every event in the tree.
|
||||||
|
partner_rel_ids = (
|
||||||
|
select(Relationship.id)
|
||||||
|
.where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.type == RelationshipType.partnership,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.where(
|
.where(
|
||||||
Event.tree_id == tree.id,
|
Event.tree_id == tree.id,
|
||||||
Event.person_id == person_id,
|
|
||||||
Event.deleted_at.is_(None),
|
Event.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Event.person_id == person_id,
|
||||||
|
Event.relationship_id.in_(partner_rel_ids),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -404,6 +404,47 @@ async def list_persons(
|
|||||||
return visible
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
async def list_persons_by_ids(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, ids: list[uuid.UUID]
|
||||||
|
) -> list[Person]:
|
||||||
|
"""Just the named persons (privacy-filtered, names batched). Lets a page show
|
||||||
|
the names of someone's relatives without loading the whole tree."""
|
||||||
|
role = await privacy.get_membership_role(session, viewer_id, tree.id)
|
||||||
|
if role is None and not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
persons = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id.in_(ids),
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
if role is not None:
|
||||||
|
await _attach_primary_names(session, persons)
|
||||||
|
return persons
|
||||||
|
visible: list[Person] = []
|
||||||
|
full: list[Person] = []
|
||||||
|
for person in persons:
|
||||||
|
vis = await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
continue
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
full.append(person)
|
||||||
|
visible.append(person)
|
||||||
|
await _attach_primary_names(session, full)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
async def search_persons(
|
async def search_persons(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
|
||||||
) -> list[Person]:
|
) -> list[Person]:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Backing the trimmed person-page fetch: batch persons by id (for relative-name
|
||||||
|
display) and partnership events on the per-person events endpoint (so the page
|
||||||
|
doesn't load every event in the tree)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, h):
|
||||||
|
return (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_persons_by_ids(client):
|
||||||
|
h = auth(await register(client, "ids@ex.com"))
|
||||||
|
tid = await _tree(client, h)
|
||||||
|
a = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Aaa"}, headers=h)).json()["id"]
|
||||||
|
b = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Bbb"}, headers=h)).json()["id"]
|
||||||
|
c = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Ccc"}, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": f"{a},{c}"}, headers=h)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert {p["id"] for p in r.json()} == {a, c} # only the requested, not b
|
||||||
|
assert all(p["primary_name"] for p in r.json()) # names resolved
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": "nope"}, headers=h)
|
||||||
|
).status_code == 422
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons", params={"ids": ""}, headers=h)
|
||||||
|
).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_events_include_partnership(client):
|
||||||
|
h = auth(await register(client, "pev@ex.com"))
|
||||||
|
tid = await _tree(client, h)
|
||||||
|
p1 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P1"}, headers=h)).json()["id"]
|
||||||
|
p2 = (await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "P2"}, headers=h)).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": p1, "date_value": "1900"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
rel = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "partnership", "person_from_id": p1, "person_to_id": p2},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "marriage", "relationship_id": rel, "date_value": "1925"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
# P1's events: own birth + the partnership marriage, in one call.
|
||||||
|
e1 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p1}/events", headers=h)).json()}
|
||||||
|
assert {"birth", "marriage"} <= e1
|
||||||
|
# The marriage shows on BOTH partners' pages.
|
||||||
|
e2 = {e["event_type"] for e in (await client.get(f"/api/v1/trees/{tid}/persons/{p2}/events", headers=h)).json()}
|
||||||
|
assert "marriage" in e2
|
||||||
@@ -135,7 +135,6 @@ export default function PersonDetailPage() {
|
|||||||
const [evType, setEvType] = useState("birth");
|
const [evType, setEvType] = useState("birth");
|
||||||
const [evTypeOther, setEvTypeOther] = useState("");
|
const [evTypeOther, setEvTypeOther] = useState("");
|
||||||
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
|
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
|
||||||
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
|
|
||||||
const [dateQual, setDateQual] = useState("exact");
|
const [dateQual, setDateQual] = useState("exact");
|
||||||
const [dateDay, setDateDay] = useState("");
|
const [dateDay, setDateDay] = useState("");
|
||||||
const [dateMonth, setDateMonth] = useState("");
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
@@ -189,8 +188,9 @@ export default function PersonDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPerson(p.data ?? null);
|
setPerson(p.data ?? null);
|
||||||
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
|
// Person-scoped fetches only — the page no longer pulls the whole tree.
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
// /persons/{id}/events now includes this person's partnership events too.
|
||||||
|
const [nm, mine, tr, ev, rl, src, cit, med] = await Promise.all([
|
||||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
}),
|
}),
|
||||||
@@ -204,22 +204,49 @@ export default function PersonDetailPage() {
|
|||||||
}),
|
}),
|
||||||
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
||||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
|
||||||
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
setPeople(all.data ?? []);
|
|
||||||
setNames(nm.data ?? []);
|
setNames(nm.data ?? []);
|
||||||
setMe(mine.data ?? null);
|
setMe(mine.data ?? null);
|
||||||
setTree(tr.data ?? null);
|
setTree(tr.data ?? null);
|
||||||
setEvents(ev.data ?? []);
|
setEvents(ev.data ?? []);
|
||||||
setAllEvents(evAll.data ?? []);
|
|
||||||
setMedia(med.data ?? []);
|
setMedia(med.data ?? []);
|
||||||
setRels(rl.data ?? []);
|
setRels(rl.data ?? []);
|
||||||
setSources(src.data ?? []);
|
setSources(src.data ?? []);
|
||||||
setCitations(cit.data ?? []);
|
setCitations(cit.data ?? []);
|
||||||
|
// Resolve the names of just this person's relatives (for display), by id —
|
||||||
|
// not the whole tree. The relationship/spouse pickers search on demand.
|
||||||
|
const relList = rl.data ?? [];
|
||||||
|
const relatedIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
relList
|
||||||
|
.flatMap((r) => [r.person_from_id, r.person_to_id])
|
||||||
|
.filter((id): id is string => !!id && id !== personId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (relatedIds.length) {
|
||||||
|
const rel = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { ids: relatedIds.join(",") } },
|
||||||
|
});
|
||||||
|
setPeople(rel.data ?? []);
|
||||||
|
} else {
|
||||||
|
setPeople([]);
|
||||||
|
}
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId, personId]);
|
}, [router, treeId, personId]);
|
||||||
|
|
||||||
|
// Server-side fuzzy search for the relative/spouse pickers — avoids loading
|
||||||
|
// every person just to search.
|
||||||
|
const searchPeople = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
const r = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { q: query } },
|
||||||
|
});
|
||||||
|
return (r.data ?? []).filter((pp) => pp.id !== personId);
|
||||||
|
},
|
||||||
|
[treeId, personId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -233,7 +260,6 @@ export default function PersonDetailPage() {
|
|||||||
return (id: string) => m.get(id) ?? "source";
|
return (id: string) => m.get(id) ?? "source";
|
||||||
}, [sources]);
|
}, [sources]);
|
||||||
|
|
||||||
const others = people.filter((p) => p.id !== personId);
|
|
||||||
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
||||||
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
||||||
const partners = rels.filter((r) => r.type === "partnership");
|
const partners = rels.filter((r) => r.type === "partnership");
|
||||||
@@ -241,22 +267,18 @@ export default function PersonDetailPage() {
|
|||||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
const personCites = citations.filter((c) => c.person_id === personId);
|
||||||
|
|
||||||
// Partnership events live on the relationship and show on both partners.
|
// Partnership events live on the relationship and show on both partners; the
|
||||||
|
// /persons/{id}/events endpoint now returns them alongside personal events.
|
||||||
const myPartnerRels = rels.filter(
|
const myPartnerRels = rels.filter(
|
||||||
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||||
);
|
);
|
||||||
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
|
|
||||||
const relEvents = allEvents.filter(
|
|
||||||
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
|
|
||||||
);
|
|
||||||
const spouseOfRelEvent = (relId: string | null | undefined) => {
|
const spouseOfRelEvent = (relId: string | null | undefined) => {
|
||||||
const r = myPartnerRels.find((x) => x.id === relId);
|
const r = myPartnerRels.find((x) => x.id === relId);
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||||
};
|
};
|
||||||
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
|
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
|
||||||
// Personal events + this person's partnership events, shown together.
|
const shownEvents = events;
|
||||||
const shownEvents = [...events, ...relEvents];
|
|
||||||
|
|
||||||
async function addEvent(e: React.FormEvent) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1090,7 +1112,7 @@ export default function PersonDetailPage() {
|
|||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
|
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
|
||||||
<PersonCombobox
|
<PersonCombobox
|
||||||
people={others}
|
onSearch={searchPeople}
|
||||||
value={evSpouse}
|
value={evSpouse}
|
||||||
onChange={setEvSpouse}
|
onChange={setEvSpouse}
|
||||||
placeholder="Search for a spouse…"
|
placeholder="Search for a spouse…"
|
||||||
@@ -1158,9 +1180,6 @@ export default function PersonDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{others.length === 0 ? (
|
|
||||||
<p className="text-sm text-[var(--muted)]">Add more people to the tree to link them.</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
|
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-[var(--muted)]">Add</span>
|
<span className="text-sm text-[var(--muted)]">Add</span>
|
||||||
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
|
<select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
|
||||||
@@ -1170,7 +1189,7 @@ export default function PersonDetailPage() {
|
|||||||
<option value="sibling">sibling</option>
|
<option value="sibling">sibling</option>
|
||||||
</select>
|
</select>
|
||||||
<PersonCombobox
|
<PersonCombobox
|
||||||
people={others}
|
onSearch={searchPeople}
|
||||||
value={relOther}
|
value={relOther}
|
||||||
onChange={setRelOther}
|
onChange={setRelOther}
|
||||||
onCreate={createRelativeAndGo}
|
onCreate={createRelativeAndGo}
|
||||||
@@ -1187,7 +1206,6 @@ export default function PersonDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<Button type="submit">Link</Button>
|
<Button type="submit">Link</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
|
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type-to-filter person picker. Shows a text input; as you type, a dropdown
|
* A type-to-pick person picker. Two modes:
|
||||||
* of matching people appears. Selecting one sets `value` (a person id) and
|
* - client (`people`): filter a preloaded list in the browser.
|
||||||
* fills the input with their name. Replaces a plain <select> when the list is
|
* - server (`onSearch`): query the backend (debounced) as you type — the
|
||||||
* long enough that scanning it by hand is painful.
|
* preferred mode for large trees, so the page doesn't
|
||||||
|
* have to preload every person just to search.
|
||||||
|
* Selecting one sets `value` (a person id) and fills the input with their name.
|
||||||
*/
|
*/
|
||||||
export function PersonCombobox({
|
export function PersonCombobox({
|
||||||
people,
|
people,
|
||||||
|
onSearch,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onCreate,
|
onCreate,
|
||||||
placeholder = "Search for a person…",
|
placeholder = "Search for a person…",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
people: Person[];
|
people?: Person[];
|
||||||
|
onSearch?: (q: string) => Promise<Person[]>;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
/** When set, the dropdown offers a "Create '<typed name>'" action. */
|
/** When set, the dropdown offers a "Create '<typed name>'" action. */
|
||||||
@@ -30,21 +34,27 @@ export function PersonCombobox({
|
|||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [results, setResults] = useState<Person[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Names we've seen (from the list or search results), so a selected value
|
||||||
|
// keeps displaying its name even in server mode.
|
||||||
|
const known = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
const nameOf = useMemo(
|
const remember = useCallback((ps: Person[] | undefined) => {
|
||||||
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])),
|
for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed");
|
||||||
[people],
|
}, []);
|
||||||
);
|
useEffect(() => {
|
||||||
|
remember(people);
|
||||||
|
}, [people, remember]);
|
||||||
|
|
||||||
|
const nameOf = useCallback((id: string) => known.current.get(id) ?? "", []);
|
||||||
|
|
||||||
// Keep the input text in sync when the selection changes externally
|
// Keep the input text in sync when the selection changes externally
|
||||||
// (e.g. cleared to "" after a successful add).
|
// (e.g. cleared to "" after a successful add).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!value) setQuery("");
|
||||||
setQuery("");
|
else if (!open) setQuery(nameOf(value));
|
||||||
} else if (!open) {
|
|
||||||
setQuery(nameOf.get(value) ?? "");
|
|
||||||
}
|
|
||||||
}, [value, open, nameOf]);
|
}, [value, open, nameOf]);
|
||||||
|
|
||||||
// Close on outside click.
|
// Close on outside click.
|
||||||
@@ -56,17 +66,48 @@ export function PersonCombobox({
|
|||||||
return () => document.removeEventListener("mousedown", onDoc);
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Server search, debounced. Stale responses are dropped via `cancelled`.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onSearch) return;
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
let cancelled = false;
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r = await onSearch(q);
|
||||||
|
if (cancelled) return;
|
||||||
|
remember(r);
|
||||||
|
setResults(r);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}, 160);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [query, onSearch, remember]);
|
||||||
|
|
||||||
const matches = useMemo(() => {
|
const matches = useMemo(() => {
|
||||||
|
if (onSearch) return results.slice(0, 10);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const pool = q
|
const pool = q
|
||||||
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
|
? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
|
||||||
: people;
|
: (people ?? []);
|
||||||
return pool.slice(0, 10);
|
return pool.slice(0, 10);
|
||||||
}, [query, people]);
|
}, [query, results, people, onSearch]);
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
|
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
|
||||||
|
|
||||||
|
const showDropdown =
|
||||||
|
open && (matches.length > 0 || loading || (onCreate && query.trim()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className="relative">
|
<div ref={wrapRef} className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -80,8 +121,11 @@ export function PersonCombobox({
|
|||||||
if (value) onChange(""); // typing invalidates the prior pick
|
if (value) onChange(""); // typing invalidates the prior pick
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{open && (matches.length > 0 || (onCreate && query.trim())) && (
|
{showDropdown && (
|
||||||
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||||
|
{loading && matches.length === 0 && (
|
||||||
|
<li className="px-3 py-2 text-sm text-[var(--muted)]">Searching…</li>
|
||||||
|
)}
|
||||||
{matches.map((p) => (
|
{matches.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Vendored
+1
@@ -2719,6 +2719,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
q?: string | null;
|
q?: string | null;
|
||||||
|
ids?: string | null;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -851,6 +851,22 @@
|
|||||||
],
|
],
|
||||||
"title": "Q"
|
"title": "Q"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ids",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Ids"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
Reference in New Issue
Block a user