452 lines
17 KiB
JavaScript
452 lines
17 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
import { searchRabbits, getRabbitAncestors } from '../api'
|
|
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
|
import { useMediaQuery } from '../hooks/useMediaQuery'
|
|
import {
|
|
ALL_SLOTS,
|
|
GEN1,
|
|
GEN2,
|
|
GEN3,
|
|
createInitialPedigreeState,
|
|
resolveEffectiveNode,
|
|
buildPedigreePayload,
|
|
buildPedigreeSummary,
|
|
serializeManualAncestor,
|
|
parseManualAncestor,
|
|
formatManualAncestorDisplay,
|
|
} from '../lib/pedigree'
|
|
|
|
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: false }
|
|
|
|
// Upper bound on the popup's height (its tallest state is the manual-entry
|
|
// form). Used to decide drop direction synchronously on open, before the popup
|
|
// mounts, so the very first click is positioned correctly.
|
|
const POPUP_MAX_HEIGHT = 360
|
|
|
|
// Viewport space at the bottom covered by fixed/sticky chrome (the Add Rabbit
|
|
// action bar, plus the bottom nav on phones), so a popup that would open
|
|
// underneath it flips upward instead.
|
|
function bottomChromeHeight() {
|
|
return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
|
|
}
|
|
|
|
// The 3-column tree needs ~900px of content width; below that (phones,
|
|
// tablets, small laptops with the sidebar) the same slots render as a stacked,
|
|
// indented list instead of a sideways-scrolling tree.
|
|
const WIDE_TREE_QUERY = '(min-width: 1280px)'
|
|
|
|
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
|
|
|
|
function LockIcon() {
|
|
return (
|
|
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 text-text-secondary" fill="currentColor" aria-label="Locked">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 2a4 4 0 0 0-4 4v2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V6a4 4 0 0 0-4-4Zm2 6V6a2 2 0 1 0-4 0v2h4Z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
|
// is the separate "Unknown" checkbox. They collapse into one stored string
|
|
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
|
|
function manualFormFromParsed(parsed) {
|
|
const isKnownDate = parsed?.dob && parsed.dob !== 'Unknown'
|
|
return {
|
|
...EMPTY_MANUAL_FORM,
|
|
...parsed,
|
|
weight_lbs: String(parsed?.weight_lbs ?? '0'),
|
|
dob: isKnownDate ? parsed.dob : '',
|
|
dobUnknown: !isKnownDate,
|
|
}
|
|
}
|
|
|
|
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade }) {
|
|
const node = resolveEffectiveNode(path, state, cascadeCache)
|
|
const [open, setOpen] = useState(false)
|
|
const [mode, setMode] = useState('search') // 'search' | 'manual'
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState([])
|
|
const [manualForm, setManualForm] = useState(EMPTY_MANUAL_FORM)
|
|
const containerRef = useRef(null)
|
|
const inputRef = useRef(null)
|
|
const triggerRef = useRef(null)
|
|
// Open the popup above the trigger when there isn't room below it (e.g. nodes
|
|
// near the bottom of the window) so the tall manual form doesn't get cut off.
|
|
const [dropUp, setDropUp] = useState(false)
|
|
|
|
// Decide drop direction from the trigger's position in the viewport. Called
|
|
// synchronously on open (the trigger already exists in the DOM) so the first
|
|
// click lands in the right place rather than flipping on the next open.
|
|
function updateDropUp() {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
|
|
const spaceAbove = rect.top
|
|
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
|
}
|
|
|
|
function close() {
|
|
setOpen(false)
|
|
triggerRef.current?.focus()
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onClickOutside(e) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', onClickOutside)
|
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open || mode !== 'search') return
|
|
const handle = setTimeout(() => {
|
|
searchRabbits({ q: query }).then(setResults).catch(() => setResults([]))
|
|
}, 200)
|
|
return () => clearTimeout(handle)
|
|
}, [query, open, mode])
|
|
|
|
if (node.locked) {
|
|
const text = node.loading
|
|
? 'Loading…'
|
|
: node.mode === 'unknown'
|
|
? 'Unknown'
|
|
: node.mode === 'manual'
|
|
? formatManualAncestorDisplay(node.name)
|
|
: node.name
|
|
return (
|
|
<div
|
|
data-testid={`pedigree-node-${slot}`}
|
|
data-locked="true"
|
|
className="flex h-16 w-full items-center justify-between gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2"
|
|
>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<p className="truncate font-body text-xs text-text-secondary">{label}</p>
|
|
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
|
|
</div>
|
|
<LockIcon />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function selectRabbit(rabbit) {
|
|
setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name })
|
|
if (slot === 'sire' || slot === 'dam') fetchCascade(rabbit.id)
|
|
setOpen(false)
|
|
setQuery('')
|
|
}
|
|
|
|
const canSaveManual =
|
|
manualForm.name.trim() && manualForm.farm.trim() && manualForm.ear_number.trim()
|
|
|
|
function setManualField(field, fieldValue) {
|
|
setManualForm((prev) => ({ ...prev, [field]: fieldValue }))
|
|
}
|
|
|
|
function saveManual() {
|
|
if (!canSaveManual) return
|
|
const dob = manualForm.dobUnknown ? 'Unknown' : manualForm.dob
|
|
setSlotState(slot, { mode: 'manual', id: null, name: serializeManualAncestor({ ...manualForm, dob }) })
|
|
setOpen(false)
|
|
}
|
|
|
|
function markUnknown() {
|
|
setSlotState(slot, { mode: 'unknown', id: null, name: null })
|
|
setOpen(false)
|
|
}
|
|
|
|
const displayText =
|
|
node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name
|
|
|
|
return (
|
|
<div ref={containerRef} data-testid={`pedigree-node-${slot}`} className="relative w-full min-w-[140px]">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={() => {
|
|
if (!open) updateDropUp()
|
|
setOpen((v) => !v)
|
|
if (node.mode === 'manual') {
|
|
setMode('manual')
|
|
setManualForm(manualFormFromParsed(parseManualAncestor(node.name)))
|
|
} else {
|
|
setMode('search')
|
|
setManualForm(EMPTY_MANUAL_FORM)
|
|
}
|
|
}}
|
|
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
|
|
>
|
|
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
|
|
<span
|
|
className={`w-full truncate font-body text-sm ${node.mode === 'unknown' ? 'text-text-secondary' : 'text-text-primary'}`}
|
|
>
|
|
{displayText}
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
className={`absolute left-0 z-30 w-full min-w-[16rem] max-w-sm rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
|
|
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
}`}
|
|
>
|
|
<div className="mb-2 flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('search')}
|
|
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
|
>
|
|
Search existing
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('manual')}
|
|
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
|
>
|
|
Enter manually
|
|
</button>
|
|
</div>
|
|
|
|
{mode === 'search' && (
|
|
<div>
|
|
<input
|
|
ref={inputRef}
|
|
autoFocus
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
|
placeholder="Search by name…"
|
|
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
|
/>
|
|
<ul className="mt-1 max-h-40 overflow-auto">
|
|
{results.map((rabbit, index) => (
|
|
<li key={rabbit.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectRabbit(rabbit)}
|
|
onKeyDown={(e) =>
|
|
handleResultKeyDown(e, {
|
|
containerEl: containerRef.current,
|
|
index,
|
|
inputEl: inputRef.current,
|
|
close,
|
|
})
|
|
}
|
|
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
|
|
>
|
|
{rabbit.name}
|
|
{rabbit.farm ? ` (${rabbit.farm})` : ''}
|
|
</button>
|
|
</li>
|
|
))}
|
|
{query && results.length === 0 && (
|
|
<li className="px-2 py-1 text-sm text-text-secondary">No matches</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'manual' && (
|
|
<div className="space-y-1.5">
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={manualForm.farm}
|
|
onChange={(e) => setManualField('farm', e.target.value)}
|
|
placeholder="Farm Name *"
|
|
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.name}
|
|
onChange={(e) => setManualField('name', e.target.value)}
|
|
placeholder="Rabbit Name *"
|
|
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={manualForm.ear_number}
|
|
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
|
placeholder="Ear Number *"
|
|
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
<div>
|
|
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={manualForm.weight_lbs}
|
|
onChange={(e) => setManualField('weight_lbs', e.target.value)}
|
|
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block px-0.5 font-body text-xs text-text-secondary">Date of Birth</label>
|
|
<input
|
|
type="date"
|
|
value={manualForm.dob}
|
|
disabled={manualForm.dobUnknown}
|
|
onChange={(e) => setManualField('dob', e.target.value)}
|
|
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={manualForm.dobUnknown}
|
|
onChange={(e) =>
|
|
setManualForm((prev) => ({
|
|
...prev,
|
|
dobUnknown: e.target.checked,
|
|
dob: e.target.checked ? '' : prev.dob,
|
|
}))
|
|
}
|
|
className="accent-accent"
|
|
/>
|
|
Unknown
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={saveManual}
|
|
disabled={!canSaveManual}
|
|
className="w-full rounded bg-accent px-2 py-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={markUnknown}
|
|
className="mt-2 w-full rounded px-2 py-2 text-left text-sm text-text-secondary hover:bg-surface-sunken md:py-1 md:text-xs"
|
|
>
|
|
Mark as Unknown
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }) {
|
|
return (
|
|
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
|
|
{entries.map(({ path, slot, label }) => (
|
|
<AncestorSlot
|
|
key={slot}
|
|
path={path}
|
|
slot={slot}
|
|
label={label}
|
|
state={state}
|
|
setSlotState={setSlotState}
|
|
cascadeCache={cascadeCache}
|
|
fetchCascade={fetchCascade}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// One ancestor plus, indented beneath it, its own sire/dam branches — the
|
|
// stacked (narrow-screen) rendering of the same slots the wide tree shows.
|
|
function StackedBranch({ path, slotProps }) {
|
|
const entry = SLOT_BY_PATH.get(path.join('.'))
|
|
const childPaths = [
|
|
[...path, 'sire'],
|
|
[...path, 'dam'],
|
|
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<AncestorSlot path={entry.path} slot={entry.slot} label={entry.label} {...slotProps} />
|
|
{childPaths.length > 0 && (
|
|
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
|
|
{childPaths.map((childPath) => (
|
|
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PedigreeTree({
|
|
subjectName = 'New Rabbit',
|
|
initialState,
|
|
initialCascade,
|
|
onChange,
|
|
}) {
|
|
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
|
|
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
|
|
// Ids with a fetch already started, kept outside state so fetchCascade never
|
|
// sets state synchronously (it's called from a mount effect below). An id is
|
|
// never removed: on failure the cache stores null, and we don't retry.
|
|
const cascadeFetchStarted = useRef(new Set())
|
|
const wide = useMediaQuery(WIDE_TREE_QUERY)
|
|
|
|
function setSlotState(slot, value) {
|
|
setState((prev) => ({ ...prev, [slot]: value }))
|
|
}
|
|
|
|
function fetchCascade(id) {
|
|
if (cascadeCache.has(id) || cascadeFetchStarted.current.has(id)) return
|
|
cascadeFetchStarted.current.add(id)
|
|
getRabbitAncestors(id)
|
|
.then((tree) => setCascadeCache((prev) => new Map(prev).set(id, tree)))
|
|
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
|
|
}
|
|
|
|
useEffect(() => {
|
|
onChange?.(buildPedigreePayload(state, cascadeCache), buildPedigreeSummary(state, cascadeCache))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state, cascadeCache])
|
|
|
|
// If the initial state (e.g. editing an existing rabbit) already has a
|
|
// linked sire/dam, make sure its cascade subtree gets fetched — initialCascade
|
|
// only covers what was already available synchronously at mount.
|
|
useEffect(() => {
|
|
for (const { slot } of GEN1) {
|
|
const node = state[slot]
|
|
if (node.mode === 'linked' && node.id != null) fetchCascade(node.id)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
if (!wide) {
|
|
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{GEN1.map(({ path, slot }) => (
|
|
<section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
|
|
<h3 className="mb-2 font-display text-base font-bold text-text-primary">
|
|
{slot === 'sire' ? "Sire's side" : "Dam's side"}
|
|
</h3>
|
|
<StackedBranch path={path} slotProps={slotProps} />
|
|
</section>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex w-full items-center gap-6 p-4 pb-[300px]">
|
|
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
|
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
|
</div>
|
|
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PedigreeTree
|