asset-management/src/components/activity/ActivityFields.jsx

261 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}