File fields: surface a prior-attachment indicator from RHF prefill

The earlier 'Currently on file' indicator hung off readonlyValue, which
is sourced from evalState (only carries current_stage). For file fields
the prefill lives in RHF state, not in evalState, so the indicator
never fired.

Replaced with a FilePriorIndicator subcomponent that subscribes to the
field's RHF value via useWatch and renders a small leaf-50 banner with
a paperclip glyph when a previous attachment is present. Falls silent
the moment the user picks a new file (RHF value becomes a FileList).
Filename is derived from whatever shape Civi returned — bare string,
object with file_name/name/label/filename, or numeric file id (generic
message in that case).
This commit is contained in:
Joel Brock
2026-05-19 15:48:10 -07:00
parent dbd607b829
commit a8ed6073a9
+64 -8
View File
@@ -203,16 +203,10 @@ export function FieldRenderer({
// ── File ────────────────────────────────────────────────────────────────
if (field.type === "file") {
const priorName = typeof readonlyValue === "string" ? readonlyValue : "";
const hasPrior = priorName.length > 0;
return (
<div className="space-y-1">
<div className="space-y-1.5">
<Label id={id} field={field} />
{hasPrior && (
<p className="text-xs text-ink-soft" aria-live="polite">
Currently on file: <span className="font-medium text-ink">{priorName}</span>
</p>
)}
<FilePriorIndicator control={control} name={field.name} />
<input
id={id}
type="file"
@@ -363,6 +357,68 @@ function CurrencyPreview({
);
}
/**
* For file fields: detects whether a previously-uploaded attachment is
* carried in this field's prefill value (RHF state) and surfaces a small
* banner with a paperclip glyph. Falls silent once the user picks a new
* file (RHF value becomes a FileList) so it doesn't contradict their
* fresh upload. Filename is derived from whatever shape Civi returned —
* a bare string filename, an object with `file_name`/`name`/`label`, or
* a numeric file id (in which case we render a generic message).
*/
function FilePriorIndicator({
control,
name,
}: {
control: Control<FieldValues>;
name: string;
}) {
const value = useWatch({ control, name });
if (value === null || value === undefined || value === "") return null;
// A FileList means the user has just picked a new file — they don't
// need a reminder about what *used* to be on file.
if (typeof FileList !== "undefined" && value instanceof FileList) return null;
let filename: string | null = null;
if (typeof value === "string") {
filename = value;
} else if (typeof value === "object" && value !== null) {
const o = value as Record<string, unknown>;
const cand = o.file_name ?? o.name ?? o.label ?? o.filename;
if (typeof cand === "string") filename = cand;
}
return (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-md border border-rule-soft bg-leaf-50/60 px-3 py-2 text-xs"
>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-[1px] h-3.5 w-3.5 flex-shrink-0 text-leaf-700"
>
<path d="M11.5 4.5 L6 10 a2 2 0 1 0 2.83 2.83 L13.5 7.5 a3.5 3.5 0 0 0 -4.95 -4.95 L3.5 7.5" />
</svg>
<span className="text-ink-soft leading-snug">
Attachment on file
{filename ? (
<>
: <span className="font-medium text-ink break-all">{filename}</span>
</>
) : null}
. Choose a new file below to replace it, or leave blank to keep it.
</span>
</div>
);
}
function Label({ id, field }: { id: string; field: FieldConfig }) {
return (
<label htmlFor={id} className="block text-sm font-medium text-ink">