261 lines
7.4 KiB
JavaScript
261 lines
7.4 KiB
JavaScript
import { Field, SelectInput, TextArea, TextInput } from '../ui'
|
||
|
||
/**
|
||
* Renders one Zino activity field per builder definition, matching its
|
||
* data_type. Field order, labels and select options come straight from the
|
||
* builder, so the form always mirrors the activity.
|
||
*
|
||
* Computed fields (id_gen) are skipped — the builder's Fields tab does not
|
||
* list them and the workflow fills them in.
|
||
*/
|
||
export default function ActivityFields({ fields, values, errors = {}, onChange, disabled }) {
|
||
return fields.filter((f) => !f.computed).map((field) => {
|
||
const value = values[field.id] ?? ''
|
||
const set = (v) => onChange(field.id, v)
|
||
const isWide = field.type === 'longtext' || field.type === 'image'
|
||
|
||
return (
|
||
<Field
|
||
key={field.id}
|
||
label={field.label.trim()}
|
||
error={errors[field.id]}
|
||
hint={hintFor(field)}
|
||
full={isWide}
|
||
>
|
||
{(id) => renderControl(field, id, value, set, disabled)}
|
||
</Field>
|
||
)
|
||
})
|
||
}
|
||
|
||
function hintFor(field) {
|
||
switch (field.type) {
|
||
case 'geolocation':
|
||
return 'Latitude, longitude'
|
||
case 'image':
|
||
if (field.slots?.length) return `One photo per view, each up to ${field.maxSizeMb || 5} MB.`
|
||
return field.maxSizeMb ? `Image up to ${field.maxSizeMb} MB.` : 'Image'
|
||
case 'select':
|
||
return field.options?.length ? undefined : 'No options configured in the builder.'
|
||
default:
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
function renderControl(field, id, value, set, disabled) {
|
||
const common = { id, disabled }
|
||
|
||
switch (field.type) {
|
||
case 'select':
|
||
return (
|
||
<SelectInput
|
||
{...common}
|
||
value={value}
|
||
onChange={(e) => set(e.target.value)}
|
||
options={field.options || []}
|
||
placeholder={field.options?.length ? `Select ${field.label.trim()}` : 'No options'}
|
||
disabled={disabled || !field.options?.length}
|
||
/>
|
||
)
|
||
|
||
case 'longtext':
|
||
return <TextArea {...common} value={value} onChange={(e) => set(e.target.value)} />
|
||
|
||
case 'email':
|
||
return (
|
||
<TextInput
|
||
{...common}
|
||
type="email"
|
||
value={value}
|
||
onChange={(e) => set(e.target.value)}
|
||
/>
|
||
)
|
||
|
||
case 'number':
|
||
return (
|
||
<TextInput
|
||
{...common}
|
||
type="number"
|
||
inputMode="decimal"
|
||
value={value}
|
||
onChange={(e) => set(e.target.value)}
|
||
/>
|
||
)
|
||
|
||
case 'date':
|
||
return (
|
||
<TextInput {...common} type="date" value={value} onChange={(e) => set(e.target.value)} />
|
||
)
|
||
|
||
case 'geolocation':
|
||
return (
|
||
<TextInput
|
||
{...common}
|
||
value={value}
|
||
onChange={(e) => set(e.target.value)}
|
||
placeholder="12.9716, 77.5946"
|
||
/>
|
||
)
|
||
|
||
case 'image':
|
||
return field.slots?.length ? (
|
||
<ImageSlots id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||
) : (
|
||
<ImageInput id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||
)
|
||
|
||
default:
|
||
return <TextInput {...common} value={value} onChange={(e) => set(e.target.value)} />
|
||
}
|
||
}
|
||
|
||
/** Reads `file` as a data URL, or rejects when it is over the builder's size cap. */
|
||
function readImage(file, field) {
|
||
const maxMb = field.maxSizeMb || 5
|
||
if (file.size > maxMb * 1024 * 1024) {
|
||
return Promise.reject(new Error(`Image must be ${maxMb} MB or smaller.`))
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result))
|
||
reader.onerror = () => reject(reader.error)
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
/** File picker that enforces the builder's size cap and holds a data URL. */
|
||
function ImageInput({ id, value, onChange, disabled, field }) {
|
||
function handleFile(e) {
|
||
const file = e.target.files?.[0]
|
||
if (!file) {
|
||
onChange('')
|
||
return
|
||
}
|
||
readImage(file, field).then(onChange, (err) => {
|
||
e.target.value = ''
|
||
onChange('')
|
||
window.alert(err.message)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="image-input">
|
||
<input
|
||
id={id}
|
||
className="input"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleFile}
|
||
disabled={disabled}
|
||
/>
|
||
{value && <img className="image-input__preview" src={value} alt="" />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* One picker per photo in `field.slots` (Front, Back, Side…), plus, when
|
||
* `field.otherSlot` names one, a box that takes any number of extra photos.
|
||
* The value is an object { Front: dataUrl, …, Other: [dataUrl, …] };
|
||
* buildActivityData turns it into the list sent.
|
||
*/
|
||
function ImageSlots({ id, value, onChange, disabled, field }) {
|
||
const images = value && typeof value === 'object' ? value : {}
|
||
const other = field.otherSlot
|
||
const others = (other && images[other]) || []
|
||
|
||
const setSlot = (slot, url) => {
|
||
const next = { ...images }
|
||
if (url && url.length) next[slot] = url
|
||
else delete next[slot]
|
||
onChange(Object.keys(next).length ? next : '')
|
||
}
|
||
|
||
function addOthers(e) {
|
||
const files = [...(e.target.files ?? [])]
|
||
e.target.value = ''
|
||
if (!files.length) return
|
||
Promise.all(files.map((file) => readImage(file, field))).then(
|
||
(urls) => setSlot(other, [...others, ...urls]),
|
||
(err) => window.alert(err.message),
|
||
)
|
||
}
|
||
|
||
function handleFile(slot, e) {
|
||
const file = e.target.files?.[0]
|
||
if (!file) {
|
||
setSlot(slot, '')
|
||
return
|
||
}
|
||
readImage(file, field).then(
|
||
(url) => setSlot(slot, url),
|
||
(err) => {
|
||
e.target.value = ''
|
||
setSlot(slot, '')
|
||
window.alert(err.message)
|
||
},
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="image-slots">
|
||
{field.slots.map((slot, i) => (
|
||
<label key={slot} className="image-slot">
|
||
<span className="image-slot__label">{slot}</span>
|
||
{images[slot] ? (
|
||
<img className="image-slot__preview" src={images[slot]} alt={`${slot} view`} />
|
||
) : (
|
||
<span className="image-slot__empty">No photo</span>
|
||
)}
|
||
<input
|
||
id={i === 0 ? id : undefined}
|
||
className="input"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => handleFile(slot, e)}
|
||
disabled={disabled}
|
||
/>
|
||
</label>
|
||
))}
|
||
|
||
{other && (
|
||
<div className="image-slot image-slot--others">
|
||
<span className="image-slot__label">
|
||
{other}
|
||
{others.length > 0 && ` (${others.length})`}
|
||
</span>
|
||
{others.length ? (
|
||
<div className="image-slot__grid">
|
||
{others.map((url, i) => (
|
||
<div key={i} className="image-slot__thumb">
|
||
<img src={url} alt={`${other} ${i + 1}`} />
|
||
<button
|
||
type="button"
|
||
className="image-slot__remove"
|
||
onClick={() => setSlot(other, others.filter((_, j) => j !== i))}
|
||
disabled={disabled}
|
||
aria-label={`Remove ${other} photo ${i + 1}`}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="image-slot__empty">No photos</span>
|
||
)}
|
||
<input
|
||
className="input"
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={addOthers}
|
||
disabled={disabled}
|
||
aria-label={`Add ${other} photos`}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|