app/components/admin/kb-form-fields.tsx
333 lines and 62 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| isGap | 14 to 14 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| reason | 15 to 15 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| shown | 16 to 16 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| fromInput | 27 to 30 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| Field | 32 to 43 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| Checks | 45 to 61 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:49> | 49 to 58 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:54> | 54 to 54 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:54> | 54 to 54 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| MaterialsEditor | 63 to 121 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| set | 64 to 65 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:65> | 65 to 65 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| cell | 66 to 78 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:75> | 75 to 75 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:81> | 81 to 115 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:86> | 86 to 86 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:88> | 88 to 92 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:100> | 100 to 100 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:102> | 102 to 106 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:107> | 107 to 107 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:111> | 111 to 111 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:111> | 111 to 111 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:116> | 116 to 116 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| EquipmentEditor | 123 to 159 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| asObject | 124 to 124 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| set | 125 to 126 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:126> | 126 to 126 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:129> | 129 to 153 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:135> | 135 to 135 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:139> | 139 to 139 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:141> | 141 to 145 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:148> | 148 to 148 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:148> | 148 to 148 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:154> | 154 to 154 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| FieldInput | 162 to 325 | 26 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| list | 180 to 180 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:193> | 193 to 193 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:199> | 199 to 199 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:210> | 210 to 213 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:211> | 211 to 211 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:220> | 220 to 220 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:232> | 232 to 232 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:240> | 240 to 240 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:247> | 247 to 247 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:249> | 249 to 253 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:261> | 261 to 265 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:263> | 263 to 263 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:272> | 272 to 272 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:282> | 282 to 282 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:283> | 283 to 283 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:283> | 283 to 283 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:292> | 292 to 292 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:299> | 299 to 299 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:300> | 300 to 300 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:300> | 300 to 300 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:309> | 309 to 309 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:315> | 315 to 315 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:321> | 321 to 321 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| ReferencePicker | 331 to 346 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:336> | 336 to 336 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:338> | 338 to 342 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:343> | 343 to 343 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/kb/form.server.ts, app/kb/gap-labels.mjs. Imported by app/components/admin/kb-form-steps.tsx, app/components/admin/kb-form.tsx.
Source
import type { ReactNode } from "react";
import { Button } from "capsomer/react/button";
import { cellAnchor, fieldAnchor } from "~/kb/gap-labels.mjs";
import type { Choice, FormField, KbForm } from "~/kb/form.server";
/* The form editor's field inputs (kb-form.tsx): one input per field, the materials and equipment lists, and the reference picker. */
export type Values = Record<string, unknown>;
export type Material = Record<string, unknown> & { name?: string; reagent?: string };
export type EquipmentEntry = string | (Record<string, unknown> & { name?: string; equipment?: string });
const isGap = (value: unknown): value is string => typeof value === "string" && value.startsWith("MISSING:");
const reason = (value: unknown) => (isGap(value) ? value.replace(/^MISSING:\s*/, "") : null);
const shown = (value: unknown) => (value === undefined || value === null || isGap(value) ? "" : String(value));
export const UNITS = ["µl", "ml", "l", "mg", "g", "µg", "ng", "mM", "M", "%", "U", "tubes", "plates"];
export const DURATIONS = ["seconds", "minutes", "hours", "days"];
/** An input's id from a field key, so a Needs info link lands on its input. */
const fieldId = fieldAnchor;
/**
* A value set from an input: what was typed, or, for an input left empty, the gap the field held, so a field nobody has
* an answer for yet keeps its reason rather than becoming blank.
*/
function fromInput(typed: string, original: unknown): unknown {
if (typed !== "") return typed;
return isGap(original) ? original : undefined;
}
function Field({ field, children, wide }: { field: FormField; children: ReactNode; wide?: boolean }) {
const gap = field.gap;
return (
<div className={`cap-field kb-field${wide ? " kb-field-wide" : ""}${gap ? " kb-field-gap" : ""}`}>
<label className="cap-field-label" htmlFor={fieldId(field.key)}>
{field.label}
</label>
{children}
{gap ? <p className="cap-field-help kb-gap-reason">Needs info: {gap}</p> : field.help ? <p className="cap-field-help">{field.help}</p> : null}
</div>
);
}
function Checks({ id, options, picked, onChange, label }: { id: string; options: Choice[]; picked: string[]; onChange: (next: string[]) => void; label: string }) {
return (
<fieldset className="kb-checks" id={id}>
<legend className="cap-sr-only">{label}</legend>
{options.map((o) => (
<label key={o.value} className="kb-check">
<input
type="checkbox"
checked={picked.includes(o.value)}
onChange={(e) => onChange(e.target.checked ? [...picked, o.value] : picked.filter((v) => v !== o.value))}
/>{" "}
{o.label}
</label>
))}
</fieldset>
);
}
function MaterialsEditor({ id, rows, reagents, solutions, onChange }: { id: string; rows: Material[]; reagents: Choice[]; solutions: Choice[]; onChange: (rows: Material[]) => void }) {
const set = (i: number, key: string, typed: string) =>
onChange(rows.map((row, j) => (j === i ? { ...row, [key]: fromInput(typed, row[key]) } : row)));
const cell = (row: Material, i: number, key: string, label: string) => (
<label className="kb-cell">
<span className="cap-field-label">{label}</span>
<input
id={row.name ? cellAnchor("materials", String(row.name), key) : undefined}
className="cap-input"
value={shown(row[key])}
placeholder={reason(row[key]) ?? ""}
aria-invalid={isGap(row[key]) || undefined}
onChange={(e) => set(i, key, e.target.value)}
/>
</label>
);
return (
<div className="kb-rows" id={id}>
{rows.map((row, i) => (
<div className="kb-row" key={i}>
{cell(row, i, "name", "Name")}
<label className="kb-cell">
<span className="cap-field-label">Registry reagent</span>
<select className="cap-input" value={String(row.reagent ?? "")} onChange={(e) => set(i, "reagent", e.target.value)}>
<option value="">None</option>
{reagents.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</label>
{cell(row, i, "amount", "Amount")}
{cell(row, i, "final", "Final")}
{solutions.length > 0 || row.solution ? (
<label className="kb-cell">
<span className="cap-field-label">Made as</span>
<select className="cap-input" value={String(row.solution ?? "")} onChange={(e) => set(i, "solution", e.target.value)}>
<option value="">Not a solution made in the lab</option>
{solutions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
{row.solution && !solutions.some((o) => o.value === row.solution) ? <option value={String(row.solution)}>A solution not in the solutions list</option> : null}
</select>
</label>
) : null}
<Button type="button" variant="quiet" size="sm" onClick={() => onChange(rows.filter((_, j) => j !== i))} aria-label={`Remove ${shown(row.name) || "this row"}`}>
Remove
</Button>
</div>
))}
<Button type="button" size="sm" onClick={() => onChange([...rows, { name: "" }])}>
Add a reagent
</Button>
</div>
);
}
function EquipmentEditor({ id, rows, items, onChange }: { id: string; rows: EquipmentEntry[]; items: Choice[]; onChange: (rows: EquipmentEntry[]) => void }) {
const asObject = (row: EquipmentEntry) => (typeof row === "string" ? { name: row } : row);
const set = (i: number, key: string, typed: string) =>
onChange(rows.map((row, j) => (j === i ? { ...asObject(row), [key]: typed === "" ? undefined : typed } : row)));
return (
<div className="kb-rows" id={id}>
{rows.map((row, i) => {
const r = asObject(row);
return (
<div className="kb-row" key={i}>
<label className="kb-cell">
<span className="cap-field-label">Name</span>
<input className="cap-input" value={shown(r.name)} onChange={(e) => set(i, "name", e.target.value)} />
</label>
<label className="kb-cell">
<span className="cap-field-label">Registry item</span>
<select className="cap-input" value={String(r.equipment ?? "")} onChange={(e) => set(i, "equipment", e.target.value)}>
<option value="">None</option>
{items.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<Button type="button" variant="quiet" size="sm" onClick={() => onChange(rows.filter((_, j) => j !== i))} aria-label={`Remove ${shown(r.name) || "this row"}`}>
Remove
</Button>
</div>
);
})}
<Button type="button" size="sm" onClick={() => onChange([...rows, { name: "" }])}>
Add equipment
</Button>
</div>
);
}
/** One field's input, by its kind. */
export function FieldInput({
field,
value,
original,
yaml,
form,
onValue,
onYaml,
}: {
field: FormField;
value: unknown;
original: unknown;
yaml: string;
form: KbForm;
onValue: (value: unknown) => void;
onYaml: (text: string) => void;
}) {
const id = fieldId(field.key);
const list = (v: unknown) => (Array.isArray(v) ? v : []);
switch (field.kind) {
case "readonly":
return (
<Field field={field}>
<p id={id} className="kb-readonly">
<code>{field.yaml || "none"}</code>
</p>
</Field>
);
case "text":
return (
<Field field={field}>
<input id={id} className="cap-input" value={shown(value)} placeholder={reason(original) ?? ""} onChange={(e) => onValue(fromInput(e.target.value, original))} />
</Field>
);
case "textarea":
return (
<Field field={field} wide>
<textarea id={id} className="cap-input" rows={3} value={shown(value)} onChange={(e) => onValue(fromInput(e.target.value, original))} />
</Field>
);
case "lines":
return (
<Field field={field} wide>
<textarea
id={id}
className="cap-input"
rows={3}
value={isGap(value) ? "" : list(value).join("\n")}
onChange={(e) => {
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
onValue(lines.length ? lines : isGap(original) ? original : undefined);
}}
/>
</Field>
);
case "date":
return (
<Field field={field}>
<input id={id} type="date" className="cap-input" value={/^\d{4}-\d{2}-\d{2}$/.test(shown(value)) ? shown(value) : ""} onChange={(e) => onValue(fromInput(e.target.value, original))} />
</Field>
);
case "number":
return (
<Field field={field}>
<input
id={id}
type="number"
min={0}
className="cap-input"
value={typeof value === "number" ? value : ""}
onChange={(e) => onValue(e.target.value === "" ? (isGap(original) ? original : undefined) : Number(e.target.value))}
/>
</Field>
);
case "checkbox":
return (
<div className="cap-field kb-field">
<label className="kb-check" htmlFor={id}>
<input id={id} type="checkbox" checked={value === true} onChange={(e) => onValue(e.target.checked ? true : original === undefined ? undefined : false)} /> {field.label}
</label>
</div>
);
case "select":
return (
<Field field={field}>
<select id={id} className="cap-input" value={isGap(value) ? "" : shown(value)} onChange={(e) => onValue(fromInput(e.target.value, original))}>
<option value="">Choose one</option>
{(field.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
);
case "choice":
return (
<Field field={field}>
<div id={id} role="radiogroup" aria-label={field.label} className="kb-checks">
{(field.options ?? []).map((o) => (
<label key={o.value} className="kb-check">
<input type="radio" name={`choice-${field.key}`} checked={value === o.value} onChange={() => onValue(o.value)} /> {o.label}
</label>
))}
</div>
</Field>
);
case "multi":
return (
<Field field={field} wide>
<Checks id={id} label={field.label} options={field.options ?? []} picked={list(value).map(String)} onChange={(next) => onValue(next.length ? next : isGap(original) ? original : undefined)} />
</Field>
);
case "primers":
return (
<Field field={field} wide>
<Checks
id={id}
label={field.label}
options={form.registry.primer}
picked={list(value).map((p) => String((p as { primer?: string }).primer))}
onChange={(next) => onValue(next.length ? next.map((primer) => ({ primer })) : typeof original === "string" ? original : undefined)}
/>
</Field>
);
case "strains": {
const notApplicable = value === "not applicable";
return (
<Field field={field} wide>
<label className="kb-check">
<input type="checkbox" checked={notApplicable} onChange={(e) => onValue(e.target.checked ? "not applicable" : isGap(original) ? original : undefined)} /> Not applicable
</label>
{notApplicable ? null : (
<Checks
id={id}
label={field.label}
options={form.registry.strain}
picked={list(value).map((s) => String((s as { strain?: string }).strain))}
onChange={(next) => onValue(next.length ? next.map((strain) => ({ strain })) : isGap(original) ? original : undefined)}
/>
)}
</Field>
);
}
case "materials":
return (
<Field field={field} wide>
<MaterialsEditor id={id} rows={list(value) as Material[]} reagents={form.registry.reagent} solutions={form.references.solutions} onChange={(rows) => onValue(rows)} />
</Field>
);
case "equipment":
return (
<Field field={field} wide>
<EquipmentEditor id={id} rows={list(value) as EquipmentEntry[]} items={form.registry.equipment} onChange={(rows) => onValue(rows)} />
</Field>
);
default:
return (
<Field field={{ ...field, help: field.help ?? "YAML, as in the file." }} wide>
<textarea id={id} className="cap-input kb-yaml" rows={Math.min(12, Math.max(2, yaml.split("\n").length))} value={yaml} onChange={(e) => onYaml(e.target.value)} />
</Field>
);
}
}
/**
* A note that names something (a troubleshooting row, a calculator) is picked by name. A calculator's preset values
* after its id are kept as written.
*/
export function ReferencePicker({ label, choices, empty, text, onChange }: { label: string; choices: Choice[]; empty: string; text: string; onChange: (text: string) => void }) {
const [first = "", ...rest] = text.trim().split(/\s+/).filter(Boolean);
const id = first.includes("=") ? "" : first;
const extra = first.includes("=") ? [first, ...rest] : rest;
return (
<select className="cap-input" aria-label={label} value={id} onChange={(e) => onChange([e.target.value, ...extra].filter(Boolean).join(" "))}>
<option value="">{empty}</option>
{choices.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
{id && !choices.some((o) => o.value === id) ? <option value={id}>Not found; pick one from the list</option> : null}
</select>
);
}