|
24 | 24 | } from './lib/resolver-client'; |
25 | 25 | import type { |
26 | 26 | FieldMapping, |
| 27 | + PersonNormRecord, |
27 | 28 | PersonCandidate, |
28 | 29 | PersonApplyResult, |
29 | 30 | OrgCandidate, |
|
54 | 55 | let personError = $state<string | null>(null); |
55 | 56 | let personResult = $state<PersonApplyResult | null>(null); |
56 | 57 | let personBusy = $state(false); |
| 58 | + let personNameInput = $state(''); |
57 | 59 | let personSearchQuery = $state(''); |
58 | 60 | let personSearchResults = $state<PersonCandidate[]>([]); |
59 | 61 | let personSearching = $state(false); |
|
84 | 86 | current && mapping ? normalizePersonRecord(current.fields as Record<string, unknown>, mapping) : null, |
85 | 87 | ); |
86 | 88 | const source = $derived(selectedSet ? `record-set:${selectedSet.name}` : 'person-db-resolver'); |
| 89 | + // The person actions (candidates/create/match) use the OPERATOR-EDITED |
| 90 | + // name, not the raw mapped column — record.name stays visible in |
| 91 | + // RecordCard as "here's what the CSV said," personNameInput is what |
| 92 | + // actually gets written. Falls back to the mapped name if cleared. |
| 93 | + const personRecord = $derived( |
| 94 | + record ? { ...record, name: personNameInput.trim() || record.name } : null, |
| 95 | + ); |
87 | 96 |
|
88 | 97 | function onActiveRecordSetChange(e: Event) { |
89 | 98 | const detail = (e as CustomEvent).detail as { record_set_id?: string } | undefined; |
|
199 | 208 | obsSaved = false; |
200 | 209 | } |
201 | 210 |
|
202 | | - // Load person candidates whenever the current record changes. |
| 211 | + // Load person candidates whenever the current record changes. Also resets |
| 212 | + // the editable name input to the mapped column's value for the new row. |
| 213 | + // |
| 214 | + // IMPORTANT: this effect must only read `record`/`current` — NOT |
| 215 | + // `personRecord`/`personNameInput`, even transitively. An earlier version |
| 216 | + // called loadPersonCandidates() here, which synchronously read the |
| 217 | + // personRecord derived (itself reading personNameInput) before its first |
| 218 | + // await — that read got tracked as a dependency of THIS effect, so every |
| 219 | + // keystroke in the name field re-triggered the row-change effect, which |
| 220 | + // immediately reset the field back to the mapped value. Un-editable input. |
203 | 221 | $effect(() => { |
204 | 222 | const rid = current?.row_id; |
205 | 223 | const rec = record; |
206 | 224 | if (!rid || !rec || !rec.name) { |
207 | 225 | personCandidates = []; |
208 | 226 | return; |
209 | 227 | } |
210 | | - void loadPersonCandidates(); |
| 228 | + personNameInput = rec.name; |
| 229 | + void loadPersonCandidatesFor(rec); |
211 | 230 | }); |
212 | 231 |
|
213 | | - async function loadPersonCandidates() { |
214 | | - if (!record || !record.name) return; |
| 232 | + async function loadPersonCandidatesFor(rec: PersonNormRecord) { |
| 233 | + if (!rec.name) return; |
215 | 234 | loadingPerson = true; |
216 | 235 | personError = null; |
217 | 236 | try { |
218 | | - personCandidates = await fetchPersonCandidates(record, client); |
| 237 | + personCandidates = await fetchPersonCandidates(rec, client); |
219 | 238 | } catch (err) { |
220 | 239 | personError = err instanceof Error ? err.message : String(err); |
221 | 240 | personCandidates = []; |
|
224 | 243 | } |
225 | 244 | } |
226 | 245 |
|
| 246 | + // Called from the name input's onchange (a DOM event handler, not a |
| 247 | + // reactive effect) — safe to read personRecord here. |
| 248 | + async function loadPersonCandidates() { |
| 249 | + if (!personRecord) return; |
| 250 | + await loadPersonCandidatesFor(personRecord); |
| 251 | + } |
| 252 | +
|
227 | 253 | // Independent of person state — per the "independent decisions" design |
228 | 254 | // (person and org are mutually independent OR interdependent, operator's |
229 | 255 | // choice), the org section is always live, not gated behind personResult. |
| 256 | + // Same "don't transitively read the input you just wrote" rule as above. |
230 | 257 | $effect(() => { |
231 | 258 | const rec = record; |
232 | 259 | if (!rec) { |
233 | 260 | orgCandidates = []; |
234 | 261 | return; |
235 | 262 | } |
236 | 263 | orgNameInput = rec.org_name ?? ''; |
237 | | - void loadOrgCandidates(); |
| 264 | + void loadOrgCandidatesFor(rec.org_name ?? ''); |
238 | 265 | }); |
239 | 266 |
|
240 | | - async function loadOrgCandidates() { |
241 | | - const name = orgNameInput.trim(); |
242 | | - if (!name) { |
| 267 | + async function loadOrgCandidatesFor(name: string) { |
| 268 | + const trimmed = name.trim(); |
| 269 | + if (!trimmed) { |
243 | 270 | orgCandidates = []; |
244 | 271 | return; |
245 | 272 | } |
246 | 273 | loadingOrg = true; |
247 | 274 | orgError = null; |
248 | 275 | try { |
249 | | - orgCandidates = await fetchOrgCandidates(name, client); |
| 276 | + orgCandidates = await fetchOrgCandidates(trimmed, client); |
250 | 277 | } catch (err) { |
251 | 278 | orgError = err instanceof Error ? err.message : String(err); |
252 | 279 | orgCandidates = []; |
|
255 | 282 | } |
256 | 283 | } |
257 | 284 |
|
| 285 | + // Called from the org-name input's onchange — safe to read orgNameInput here. |
| 286 | + async function loadOrgCandidates() { |
| 287 | + await loadOrgCandidatesFor(orgNameInput); |
| 288 | + } |
| 289 | +
|
258 | 290 | async function doMatchPerson(c: PersonCandidate) { |
259 | | - if (!record) return; |
| 291 | + if (!personRecord) return; |
260 | 292 | personBusy = true; |
261 | 293 | personError = null; |
262 | 294 | try { |
263 | | - personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record, client, source }); |
| 295 | + personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record: personRecord, client, source }); |
264 | 296 | } catch (err) { |
265 | 297 | personError = err instanceof Error ? err.message : String(err); |
266 | 298 | } finally { |
|
269 | 301 | } |
270 | 302 |
|
271 | 303 | async function doCreatePerson() { |
272 | | - if (!record) return; |
| 304 | + if (!personRecord || !personRecord.name) return; |
273 | 305 | personBusy = true; |
274 | 306 | personError = null; |
275 | 307 | try { |
276 | | - personResult = await applyPerson({ action: 'create', record, client, source }); |
| 308 | + personResult = await applyPerson({ action: 'create', record: personRecord, client, source }); |
277 | 309 | } catch (err) { |
278 | 310 | personError = err instanceof Error ? err.message : String(err); |
279 | 311 | } finally { |
|
378 | 410 |
|
379 | 411 | async function doAddObservation() { |
380 | 412 | if (!personResult) return; |
381 | | - const predicate = obsPredicate.trim(); |
| 413 | + // Only the value is required — predicate defaults to a generic 'note' |
| 414 | + // so the button isn't dead just because the operator only typed a value. |
| 415 | + const predicate = obsPredicate.trim() || 'note'; |
382 | 416 | const value = obsValue.trim(); |
383 | | - if (!predicate || !value) return; |
| 417 | + if (!value) return; |
384 | 418 | obsBusy = true; |
385 | 419 | obsError = null; |
386 | 420 | obsSaved = false; |
|
458 | 492 | {#if personError}<div class="pdr-error">candidates: {personError}</div>{/if} |
459 | 493 |
|
460 | 494 | {#if !personResult && !personSkipped} |
| 495 | + <label class="pdr-org-name-row"> |
| 496 | + <span>person name</span> |
| 497 | + <input type="text" bind:value={personNameInput} onchange={() => void loadPersonCandidates()} placeholder="Person name" /> |
| 498 | + </label> |
461 | 499 | <PersonCandidateList candidates={personCandidates} busy={personBusy} onMatch={doMatchPerson} /> |
462 | 500 | <div class="pdr-create"> |
463 | | - <button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !record.name} onclick={doCreatePerson}> |
| 501 | + <button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !personRecord?.name} onclick={doCreatePerson}> |
464 | 502 | + create new person from this record |
465 | 503 | </button> |
466 | 504 | <button type="button" class="pdr-btn" disabled={personBusy} onclick={doSkipPerson}> |
|
500 | 538 | {personResult.created ? '✓ created' : '✓ matched'} <strong>{personResult.name}</strong> |
501 | 539 | </div> |
502 | 540 | <div class="pdr-add-obs"> |
503 | | - <label><span>predicate</span><input type="text" bind:value={obsPredicate} placeholder="e.g. confirmed_via_email" /></label> |
| 541 | + <label><span>predicate (optional)</span><input type="text" bind:value={obsPredicate} placeholder="defaults to 'note'" /></label> |
504 | 542 | <label><span>value</span><input type="text" bind:value={obsValue} placeholder="e.g. confirmed 2026-07-07" /></label> |
505 | | - <button type="button" class="pdr-btn" disabled={obsBusy || !obsPredicate.trim() || !obsValue.trim()} onclick={doAddObservation}> |
| 543 | + <button type="button" class="pdr-btn" disabled={obsBusy || !obsValue.trim()} onclick={doAddObservation}> |
506 | 544 | + add observation |
507 | 545 | </button> |
508 | 546 | {#if obsSaved}<span class="pdr-stamp-ok">✓ saved</span>{/if} |
|
0 commit comments