Render file and OCR fields as uploads, not text boxes

Collect Documents shipped with every document field rendering as a plain
text input — ActivityForm had no case for `file` or `ocr`, so they fell
through to the default. There was a form asking you to type an RC book.

Uploads happen ON PICK rather than on submit, because /ocr-extract takes
a REFERENCE to a stored file: the document crosses the wire once, it
survives a reload (a draft cannot carry a File), and re-extracting costs
no second upload.

For an ocr field the extracted values are shown inline — "read from the
document" — and filled into the fields they map to. Only into fields that
are still blank: a value a person typed is never overwritten by one a
model read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-25 13:37:51 +05:30
parent b90c94686e
commit 6df594f0b4
4 changed files with 172 additions and 1 deletions

View File

@ -170,6 +170,47 @@ export class ZinoClient {
}) })
} }
/**
* Upload a file and get back a reference {uuid, blob_path, original_name,
* mime_type}. The field context matters: the backend resolves the field's
* own config (allowed types, size limit, ocr_config) server-side from it and
* ignores anything the client claims.
*/
async uploadFile(file, ctx) {
const form = new FormData()
form.append('file', file)
if (ctx.workflowUuid) form.append('workflow_uuid', ctx.workflowUuid)
if (ctx.activityId) form.append('activity_id', ctx.activityId)
if (ctx.fieldId) form.append('field_id', ctx.fieldId)
if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId))
const headers = {}
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form })
if (!res.ok) {
let message = res.statusText
try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ }
throw { status: res.status, message }
}
return res.json()
}
/**
* Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes
* the document crosses the wire once, survives a reload, and re-extracting
* costs no re-upload. The ocr_config (which fields to pull, where they map)
* is resolved server-side from the deployed workflow; anything the client
* sends is ignored.
*/
ocrExtract(fileRef, ctx) {
return this.request('POST', `/app/${APP_ID}/ocr-extract`, {
workflow_uuid: WORKFLOW,
activity_id: ctx.activityId,
field_id: ctx.fieldId,
instance_id: ctx.instanceId || undefined,
files: [fileRef],
})
}
instance(instanceId) { instance(instanceId) {
return this.request('POST', `/app/${APP_ID}/instance`, { return this.request('POST', `/app/${APP_ID}/instance`, {
workflow_uuid: WORKFLOW, workflow_uuid: WORKFLOW,

View File

@ -51,3 +51,32 @@
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px; color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
} }
/* ---- file / ocr ---- */
.ff { display: flex; flex-direction: column; gap: 6px; }
.ff__input { display: none; }
.ff__btn {
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
color: var(--zk-blue-dark); text-align: center;
}
.ff__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; }
.ff__got {
font-size: .78rem; color: var(--zk-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ff__read {
border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
}
.ff__readlabel {
font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--zk-blue-dark); margin-bottom: 2px;
}
.ff__read div { font-size: .8rem; color: var(--zk-ink); }
.ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; }
.ff__err {
font-size: .78rem; color: var(--zk-danger);
background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import FileField from './FileField.jsx'
import './ActivityForm.css' import './ActivityForm.css'
/** /**
@ -80,7 +81,25 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}> <label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span> <span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
{f.data_type === 'id_gen' ? ( {f.data_type === 'file' || f.data_type === 'ocr' ? (
<FileField
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
onChange={(refs) => set(f.id, refs)}
onExtract={(fields) => {
// The document answered questions the form was about to ask.
// Only fill what is still blank never overwrite something a
// person typed with something a model read.
setValues((prev) => {
const next = { ...prev }
for (const [k, val] of Object.entries(fields)) {
if (val === null || val === undefined || String(val) === '') continue
if (next[k] === undefined || next[k] === '') next[k] = val
}
return next
})
}}
/>
) : f.data_type === 'id_gen' ? (
<div className="af__auto"> <div className="af__auto">
{v || 'Issued on submit'} {v || 'Issued on submit'}
<span>auto</span> <span>auto</span>

View File

@ -0,0 +1,82 @@
import { useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
/**
* A file or OCR field.
*
* Uploads ON PICK rather than on submit: the backend's extract endpoint takes
* a REFERENCE to a stored file, so the document crosses the wire once, survives
* a reload, and re-extracting costs no second upload.
*
* For an `ocr` field it then calls /ocr-extract, which resolves the field's own
* extraction config server-side and returns the values keyed by extraction key.
* Those are handed up so the form can fill the fields the document answers
* which is the entire point: nobody should be typing an engine capacity that is
* printed on the RC.
*/
export default function FileField({ field, value, instanceId, activityUid, onChange, onExtract }) {
const { client } = useZino()
const inputRef = useRef(null)
const [busy, setBusy] = useState('')
const [err, setErr] = useState(null)
const [extracted, setExtracted] = useState(null)
const isOcr = field.data_type === 'ocr'
const files = Array.isArray(value) ? value : []
async function pick(e) {
const file = e.target.files?.[0]
if (!file) return
setErr(null); setExtracted(null)
const ctx = { activityId: activityUid, fieldId: field.id, instanceId }
try {
setBusy('Uploading…')
const ref = await client.uploadFile(file, ctx)
onChange([ref])
if (isOcr) {
setBusy('Reading the document…')
const out = await client.ocrExtract(ref, ctx)
const fields = out?.fields ?? out?.extracted ?? out?.data ?? out
if (fields && typeof fields === 'object') {
setExtracted(fields)
onExtract?.(fields)
}
}
} catch (ex) {
setErr(ex)
} finally {
setBusy('')
}
}
return (
<div className="ff">
<input
ref={inputRef} type="file" className="ff__input"
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
/>
<button type="button" className="ff__btn" disabled={Boolean(busy)}
onClick={() => inputRef.current?.click()}>
{busy || (files.length ? 'Replace file' : isOcr ? 'Upload — we read it for you' : 'Upload')}
</button>
{files.length ? (
<div className="ff__got">{files[0].original_name || files[0].uuid}</div>
) : null}
{extracted ? (
<div className="ff__read">
<span className="ff__readlabel">Read from the document</span>
{Object.entries(extracted)
.filter(([, v]) => v !== null && v !== undefined && String(v) !== '')
.map(([k, v]) => (
<div key={k}><em>{k.replace(/_/g, ' ')}</em> {String(v)}</div>
))}
</div>
) : null}
{err ? <div className="ff__err">{err.status} {err.message}</div> : null}
</div>
)
}