From 3f36ea610bccd9e53797ec40e39518ca1b31a775 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 9 Sep 2026 17:06:11 +0530 Subject: [PATCH] console: unmangle the document form and rebuild the upload control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems on the collect-documents form. LAYOUT. Every field sat in one flat auto-fit grid, so on a wide screen the three uploads took the first half of a row and three text inputs took the rest — and an upload that has read a document is several times taller than a text box, so the inputs stranded themselves up beside the buttons and the row went ragged. Documents now render in their own section above the details, and each grid is internally one height. The upload cells align to the top so a read document no longer drags its neighbours down. THE UPLOAD ITSELF. It was one pink slab that only changed its words, so a finished upload shouted as loudly as an empty one. Now there are two states: an empty field is a dashed target with an icon and the formats it takes; a filled one is a quiet row with a document icon, the file name, and a small Replace. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ActivityForm.css | 71 +++++++++++++++++++++++++++------ src/components/ActivityForm.jsx | 54 +++++++++++++++++-------- src/components/FileField.jsx | 34 ++++++++++++---- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css index 4d38e37..4cacf91 100644 --- a/src/components/ActivityForm.css +++ b/src/components/ActivityForm.css @@ -245,11 +245,14 @@ display: none; } -.ff__btn { +.ff__drop { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + width: 100%; font: inherit; - font-size: var(--fs-xs); - font-weight: 500; - padding: 14px 14px; + padding: 16px 12px; border-radius: var(--r-md); cursor: pointer; border: 1px dashed var(--zk-blue-light); @@ -258,28 +261,56 @@ text-align: center; transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); } +.ff__drop svg { width: 20px; height: 20px; } +.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; } +.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); } -.ff__btn:hover:not(:disabled) { +.ff__drop:hover:not(:disabled) { background: var(--zk-white); border-style: solid; border-color: var(--zk-blue); - box-shadow: var(--ring); } +.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); } +.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; } -.ff__btn:disabled { - opacity: 0.6; - cursor: default; - border-style: solid; +/* Uploaded: the file as a fact, not a call to action. */ +.ff__file { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 9px 9px 11px; + border: 1px solid var(--zk-line); + border-radius: var(--r-md); + background: var(--zk-white); } +.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); } -.ff__got { +.ff__name { + flex: 1; + min-width: 0; font-size: var(--fs-2xs); - color: var(--zk-muted); + color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ff__replace { + flex: none; + font: inherit; + font-size: var(--fs-3xs); + font-weight: 500; + padding: 4px 10px; + border: 1px solid var(--zk-line); + border-radius: var(--r-pill); + background: var(--zk-white); + color: var(--zk-blue-dark); + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast); +} +.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); } +.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); } + .ff__read { border: 1px solid var(--zk-blue-light); border-left-width: 3px; @@ -349,3 +380,19 @@ } .af__err--soft ul { margin: 6px 0 0; padding-left: 18px; } .af__err--soft li { margin: 2px 0; } + +/* ---- form sections ---- */ +.af__sec + .af__sec { margin-top: 22px; } + +.af__sech { + margin: 0 0 10px; + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--zk-grey); +} + +/* Uploads size to their content and never stretch to the tallest card in the + row, so a document that has been read does not pull its neighbours down. */ +.af__grid--docs { align-items: start; } diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index 8e564b3..8ee1ab3 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -267,21 +267,26 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on if (error && !schema) return
Could not load the form — {describeError(error).title}
if (!schema) return

Loading the form…

- return ( -
-
- {/* Stamped fields are in `fields` — they validate and they submit — - but they are not drawn. See STAMPED_FIELDS. + // Stamped fields are in `fields` — they validate and they submit — but they + // are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is + // drawn anyway: source_channel is mandatory, so a prefill that did not + // resolve would otherwise fail validation against a box that is not on the + // screen and cannot be filled. + const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])) - A stamped field that came back EMPTY is drawn anyway. source_channel - is mandatory, so a prefill that did not resolve would otherwise fail - validation against a box that is not on the screen and cannot be - filled — the exact failure the field filter elsewhere in this file - guards against. Hidden when it is answered; asked when it is not. */} - {fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])).map((f) => { - const opts = f.properties?.options || [] - const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') - return ( + /* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat + beside plain inputs, and an upload that has read a document is several + times taller than a text box — so a row carried three tall OCR cards and + three short fields, and the form went ragged with the inputs stranded up + beside the buttons. Split in two, each grid is internally one height. */ + const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr' + const docFields = visible.filter(isDoc) + const dataFields = visible.filter((f) => !isDoc(f)) + + const renderField = (f) => { + const opts = f.properties?.options || [] + const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') + return ( - ) - })} -
+ ) + } + + return ( + + {docFields.length ? ( +
+

Documents

+
{docFields.map(renderField)}
+
+ ) : null} + + {dataFields.length ? ( +
+ {docFields.length ?

Details

: null} +
{dataFields.map(renderField)}
+
+ ) : null} {/* The form's own complaint, before anything is sent. */} {missing.length ? ( diff --git a/src/components/FileField.jsx b/src/components/FileField.jsx index a640da9..94e54e6 100644 --- a/src/components/FileField.jsx +++ b/src/components/FileField.jsx @@ -61,20 +61,40 @@ export default function FileField({ field, value, instanceId, activityUid, onCha } } + const name = files.length ? (files[0].original_name || files[0].uuid) : '' + const open = () => inputRef.current?.click() + return (
- - {files.length ? ( -
{files[0].original_name || files[0].uuid}
- ) : null} + {/* Two states, not one button that changes its words. Nothing uploaded is + an invitation — a dashed target that says what it takes. Something + uploaded is a fact — the file, named, with a quiet way to swap it. The + old single pink slab stayed the same size and weight either way, which + made a finished upload shout as loudly as an empty one. */} + {files.length && !busy ? ( +
+ + {name} + +
+ ) : ( + + )} {extracted ? (