fix: pre-fill the Edit Asset form with the asset's values

- Look up each Edit value under every key the details page reads, and
  fill the asset's Add Asset values from the INIT record view (asset
  table, de18263e-…), matched by Asset ID, serial number or identifier.
  Edited values still win.
- Match select values by value or label in any case or spacing, also
  against the builder's options; read dates and formatted costs into
  what the inputs hold; pick Asset Owner by name when there's no id.
- Take activity select options from the builder; show which asset each
  assignment and return row is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vamsi.balaga 2026-10-28 11:44:53 +05:30
parent c0749f50e4
commit c4ccdbf7d3
5 changed files with 501 additions and 45 deletions

View File

@ -68,6 +68,96 @@ export function imageEntries(field, raw) {
return list.filter(Boolean)
}
/**
* The builder's field definitions for one activity, as
* Map<field id, { id, label, type, options? }>:
*
* GET {APP_API_BASE_URL}/api/apps/{APP_ID}/versions/{VERSION_UID}/activities/{ACTIVITY_ID}/data-fields
* Authorization: Bearer <JWT>
*
* The runtime accepts a select only with one of these option values, so forms
* take their options from here rather than from a copy that can drift. The
* response shape isn't documented: the list may come bare or wrapped, and
* options may sit on the field or in its config, so both are read loosely.
* Fetched once per activity; a failed fetch is retried next time.
*/
export function fetchActivityFieldDefs({ appId, versionUuid, activityId }) {
const key = `${appId}/${versionUuid}/${activityId}`
if (!fieldDefsCache.has(key)) {
const defs = apiFetch(
`${APP_API_BASE_URL}/api/apps/${appId}/versions/${versionUuid}/activities/${activityId}/data-fields`,
)
.then((res) => new Map(listOf(res).map(builderFieldDef).filter(Boolean).map((d) => [d.id, d])))
.catch((err) => {
fieldDefsCache.delete(key)
throw err
})
fieldDefsCache.set(key, defs)
}
return fieldDefsCache.get(key)
}
const fieldDefsCache = new Map()
/** The first array in a response: itself, or under data / fields / data_fields / items. */
function listOf(res) {
if (Array.isArray(res)) return res
for (const k of ['data', 'fields', 'data_fields', 'items', 'results']) {
const v = res?.[k]
if (Array.isArray(v)) return v
if (v && typeof v === 'object') {
const inner = listOf(v)
if (inner.length) return inner
}
}
return []
}
/** One builder field as { id, label, type, options? }, or null when it has no field id. */
function builderFieldDef(raw) {
if (!raw || typeof raw !== 'object') return null
const id = [raw.field_id, raw.field_key, raw.key, raw.id, raw.name].find(
(v) => typeof v === 'string' && /^(field|idgen)_\d+$/.test(v),
)
if (!id) return null
const holders = [raw, raw.config, raw.properties, raw.settings, raw.meta, raw.data_config, raw.field_config]
const rawOptions = holders.map((h) => h?.options ?? h?.choices).find(Array.isArray)
const options = rawOptions?.map(builderOption).filter(Boolean)
return {
id,
label: String(raw.label ?? raw.field_label ?? raw.display_name ?? raw.name ?? id),
type: String(raw.data_type ?? raw.type ?? ''),
...(options && { options }),
}
}
/** A builder option ("x", or { label, value } under various names) as { label, value }. */
function builderOption(o) {
if (typeof o === 'string' || typeof o === 'number') return { label: String(o), value: String(o) }
if (!o || typeof o !== 'object') return null
const value = [o.value, o.option_value, o.key, o.id, o.label].find((v) => v != null && v !== '')
if (value == null) return null
const label = [o.label, o.option_label, o.name, o.text, o.display_name].find((v) => v != null && v !== '')
return { label: String(label ?? value), value: String(value) }
}
/**
* `fields` with each select's options taken from the builder (`defs`, from
* fetchActivityFieldDefs), matched by field id; the app's own list stays where
* the builder has none. A field marked `unconfirmedOptions` has no list known
* to be right, so without the builder's options (`defs` null, or it has
* none) it is left out of the form — sending a guessed value is rejected
* ("… must be one of the listed options") and fails the whole activity.
*/
export function withBuilderOptions(fields, defs) {
return fields.flatMap((field) => {
if (field.type !== 'select') return [field]
const options = defs?.get(field.id)?.options
if (options?.length) return [{ ...field, options }]
return field.unconfirmedOptions ? [] : [field]
})
}
/**
* Turn a raw backend error into something actionable.
*
@ -94,6 +184,13 @@ export function explainActivityError(err) {
: `This activity is not one of the "${state}" state's activities. ` +
'Check that the activity id in the app matches the one in that state in the Zino builder.'
}
// A select value that isn't one of the deployed field's option values.
const select = message.match(/([^:;.\n]+?)\s+must be one of the listed options/i)?.[1]?.trim()
if (select) {
err.message =
`"${select}" was rejected: the value picked is not one of that field's options in the deployed ` +
'Zino app. Leave it empty and try again, or ask an admin to add that option in the builder and deploy.'
}
return err
}
@ -106,6 +203,43 @@ export function instanceState(record) {
/** True once an instance has reached its end state — no activity can run on it. */
export const isEndState = (record) => /^end(\s|_)?state$|^end$/i.test(instanceState(record))
/** 'In use', 'in_use' and ' IN-USE ' compare equal. */
const optionKey = (v) => String(v ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '_')
/**
* The option value `raw` names, matched by value or label in any case or
* spacing (a view may send "In use" for the value in_use), or undefined.
* `raw` may also be an option object ({ value, label }).
*/
export function matchOption(options, raw) {
const wanted = [raw?.value, raw?.option_value, raw?.label, raw]
.filter((v) => v != null && typeof v !== 'object')
.map(optionKey)
.filter(Boolean)
const hit = (options ?? []).find((o) => {
const opt = typeof o === 'string' ? { value: o, label: o } : o
return wanted.includes(optionKey(opt.value)) || wanted.includes(optionKey(opt.label))
})
return hit === undefined ? undefined : typeof hit === 'string' ? hit : hit.value
}
/**
* A stored date as a date input's value (yyyy-mm-dd), or '' when it can't be
* read: ISO dates and timestamps, dd-mm-yyyy / dd/mm/yyyy, and epoch numbers.
*/
export function toDateInput(value) {
if (value == null || value === '') return ''
const s = String(value).trim()
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
if (iso) return `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`
const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/)
if (dmy) return `${dmy[3]}-${dmy[2].padStart(2, '0')}-${dmy[1].padStart(2, '0')}`
const d = new Date(/^\d+$/.test(s) ? Number(s) : s)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** Blank form state for a field list. */
export function emptyValues(fields) {
return Object.fromEntries(fields.filter((f) => !f.computed).map((f) => [f.id, '']))

View File

@ -1,5 +1,13 @@
import { APP_API_BASE_URL, apiFetch } from './client'
import { buildActivityData, explainActivityError, instanceState, uploadActivityFile } from './activityForm'
import {
buildActivityData,
explainActivityError,
fetchActivityFieldDefs,
instanceState,
matchOption,
toDateInput,
uploadActivityFile,
} from './activityForm'
import { fetchEmployeeListPage } from './employees'
import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView'
import { withRichColumns } from './richColumns'
@ -176,6 +184,10 @@ export async function uploadAssetFile(activityId, fieldId, file) {
return send(ASSET_INIT_ACTIVITY_ID, INIT_IMAGE_FIELD)
}
/** The builder's field definitions for one of the asset workflow's activities (see fetchActivityFieldDefs). */
export const fetchAssetActivityFieldDefs = (activityId) =>
fetchActivityFieldDefs({ appId: ASSET_APP_ID, versionUuid: ASSET_VERSION_UID, activityId })
/**
* "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee
* data". The API sample shows only { id }; it is sent as { id, first_name }
@ -367,12 +379,17 @@ export const ASSET_RETURN_FIELDS = [
otherSlot: 'Other',
},
{
// UNCONFIRMED: the sample cURL shows only "option_value". Copy this field's
// label and option values from the builder exactly, or the runtime rejects it.
// The builder spells it "Recived by". Its option values aren't known here
// (the sample cURL shows only "option_value"), and guessed names were
// rejected with "Recived by must be one of the listed options". So the
// form offers the builder's own options (see withBuilderOptions) and
// leaves the field out when they can't be read.
id: 'field_72',
label: 'Received by',
label: 'Recived by',
formLabel: 'Received by',
type: 'select',
options: ['Praveen', 'Akshay', 'Prasad'].map((name) => ({ label: name, value: name })),
options: [],
unconfirmedOptions: true,
},
]
@ -618,26 +635,47 @@ export function completeAssetRepair(instanceId, values) {
/**
* The Edit form's starting values: each field's current value on the asset
* (its latest edit, else its INIT value), as the form holds it.
* (its latest edit, else its INIT value), as the form holds it. `row` and
* `config` are fetchAssetInstance's. A value is looked for under every key
* the details page may show it by — the Edit field id (and field_60 for Hard
* Drive), the INIT field id, the view's column for the field, and a key named
* after its label — so the form opens with what the page shows.
*/
export function assetEditValues(row) {
const norm = (v) => String(v).trim().toLowerCase()
export function assetEditValues(config, row) {
const columns = config?.fields ?? []
return Object.fromEntries(
ASSET_EDIT_FIELDS.map((f) => {
const init = ASSET_FIELDS.find((d) => d.id === f.initId)
const raw = present(row?.[f.id]) ? row[f.id] : row?.[initValueKey(init, row ?? {})]
if (!present(raw)) return [f.id, '']
if (f.type === 'image') return [f.id, savedPhotos(f, raw)]
if (f.type === 'date') return [f.id, toISODate(raw)]
if (f.type === 'select') {
const hit = f.options.find((o) => norm(o.value) === norm(raw) || norm(o.label) === norm(raw))
return [f.id, hit ? hit.value : String(raw)]
}
return [f.id, String(raw)]
const keys = [
f.id,
...EDITED_FIELDS.filter((e) => e.initId === init.id && e.id !== f.id).map((e) => e.id),
init.id,
...columns.filter((c) => hasLabel(init, c.output_label)).map((c) => c.field_key),
initValueKey(init, row ?? {}),
]
const raw = keys.map((k) => row?.[k]).find(present)
return [f.id, present(raw) ? editFormValue(f, raw) : '']
}),
)
}
/** A stored value as an Edit form field holds it; '' when it can't be shown. */
function editFormValue(field, raw) {
if (field.type === 'image') return savedPhotos(field, raw)
if (field.type === 'select') return matchOption(field.options, raw) ?? String(raw?.value ?? raw)
// A single-value field may come as an option-like object or a one-item list.
let v = Array.isArray(raw) ? raw[0] : raw
if (v && typeof v === 'object') v = v.value ?? v.label ?? v.name ?? ''
if (!present(v)) return ''
if (field.type === 'date') return toDateInput(v)
if (field.type === 'number') {
// "₹1,200.50" shows as 1200.5; the number input can't hold the formatting.
const n = Number(String(v).replace(/[^\d.-]/g, ''))
return /\d/.test(String(v)) && Number.isFinite(n) ? String(n) : String(v)
}
return String(v)
}
/**
* An asset's saved photos as the image form value ({ Front, Back, Side,
* Other: [...] }), in the order they were sent, so saving the form sends them
@ -684,11 +722,16 @@ export function withEditedValues(row) {
return out
}
/** The employee-table id of the asset's current owner (its latest edit, else INIT), or null. */
export function assetOwnerId(row) {
/**
* The employee-table id of the asset's current owner (its latest edit, else
* INIT), or null. Read from the owner field, or any `config` column labelled
* Asset Owner.
*/
export function assetOwnerId(row, config) {
const edited = withEditedValues(row)
const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD)
return lookupId(edited, initValueKey(owner, edited))
const keys = [initValueKey(owner, edited), ...ownerKeys(config)]
return keys.map((k) => lookupId(edited, k)).find((id) => id != null) ?? null
}
/** Asset workflow states, as named in the builder diagram. */
@ -806,7 +849,11 @@ export const assignedEmployeeName = (row) => currentAssignment(row).name
function activityValues(row, people) {
const current = currentAssignment(row, people)
const assignee = current.id != null ? people?.get(String(current.id)) : undefined
const asset = withEditedValues(row)
return {
asset_code: assetCodeOf(row),
asset_model: asset.field_42 ?? '',
asset_serial: asset.field_44 ?? '',
assigned_to: current.name,
assigned_date: current.date,
assigned_by: current.by,
@ -835,6 +882,13 @@ const derived = (field_key, output_label, data_type = 'text', { sources = [], al
const STATE_COLUMN = derived('state', 'State', 'asset_state')
/** Which asset a row is: an activity's own view may have no column for it. */
const ASSET_IDENTITY_COLUMNS = [
derived('asset_code', 'Asset ID', 'id_gen', { aliases: ['Asset ID', 'Asset id'] }),
derived('asset_model', 'Model', 'text', { sources: ['field_42', 'field_2'] }),
derived('asset_serial', 'Serial number', 'text', { sources: ['field_44', 'field_13'] }),
]
/** Who has the asset now: ASSIGNED's or Reassigned's employee, whichever is newer. */
const ASSIGNED_TO_COLUMN = derived('assigned_to', 'Assigned to', 'text', {
sources: [ASSIGN_EMPLOYEE_FIELD, REASSIGN_EMPLOYEE_FIELD],
@ -846,6 +900,7 @@ const ASSIGNED_DATE_COLUMN = derived('assigned_date', 'Assigned date', 'date', {
/** Columns the asset record view doesn't have: the ASSIGNED values and the state. */
const ASSIGNMENT_COLUMNS = [
...ASSET_IDENTITY_COLUMNS,
ASSIGNED_TO_COLUMN,
ASSIGNED_DATE_COLUMN,
derived('assigned_by', 'Assigned by', 'text', { sources: ['field_5', 'field_11'] }),
@ -855,6 +910,7 @@ const ASSIGNMENT_COLUMNS = [
/** Columns for the return list: who had it, and the RETURNED values. */
const RETURN_COLUMNS = [
...ASSET_IDENTITY_COLUMNS,
ASSIGNED_TO_COLUMN,
ASSIGNED_DATE_COLUMN,
derived('returned_date', 'Returned date', 'date', { sources: ['field_8'] }),
@ -893,13 +949,20 @@ export const RETURN_FILTERS = [
* `columns` the view already has (by field id or label) isn't added twice:
* the view's column shows its value — the latest assignment's, so after a
* reassignment "Employee data" names the new holder.
*
* A view holds only the columns it was built with (the return view has no
* ASSIGNED values and nothing naming the asset), so each row is filled in
* from the same instance in the other asset workflow views, then from its
* detail view (see withInstanceValues).
*/
async function fetchAssetActivityPage(templateUid, query, { columns, filters, images = [] }) {
const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query
const [{ config, rows }, people] = await Promise.all([
const [{ config, rows: own }, people, others] = await Promise.all([
fetchAllRecordRows(templateUid, rest),
fetchEmployeeDirectory(),
fetchInstanceValues(ASSET_INSTANCE_VIEWS.filter((uid) => uid !== templateUid)),
])
const rows = await withDetailValues(own.map((row) => fillMissing(row, others.get(String(row.instance_id)))))
const match = (filters.find((f) => f.value === filter) ?? filters[0]).match
// The activity's own photo fields (`images`) show like the asset list's
@ -941,14 +1004,103 @@ async function fetchAssetActivityPage(templateUid, query, { columns, filters, im
})
.filter(match)
// The asset's identity first, then the view's columns, then the activity's.
const isIdentity = (col) => ASSET_IDENTITY_COLUMNS.includes(col)
const totalPages = Math.max(1, Math.ceil(data.length / limit))
return {
config: { ...config, fields: [...fields, ...added, ...imageColumns] },
config: {
...config,
fields: [...added.filter(isIdentity), ...fields, ...added.filter((c) => !isIdentity(c)), ...imageColumns],
},
data: data.slice((page - 1) * limit, page * limit),
pagination: { page, limit, total_count: data.length, total_pages: totalPages },
}
}
/**
* The asset workflow's record views. Each holds only some of an instance's
* fields, so a list fills its rows in from the others, by instance_id.
*/
const ASSET_INSTANCE_VIEWS = [
RECORD_VIEWS.assets.templateUid,
RECORD_VIEWS.assetAssignments.templateUid,
RECORD_VIEWS.assetReturns.templateUid,
]
/** Workflow field keys (field_22, idgen_2): unique across the workflow, so safe to merge. */
const isFieldKey = (k) => /^(field|idgen)_\d+$/.test(k)
/** `row` with the field values it lacks taken from `from`; its own values always win. */
function fillMissing(row, from) {
if (!from) return row
const out = { ...row }
for (const [k, v] of Object.entries(from)) if (isFieldKey(k) && present(v) && !present(out[k])) out[k] = v
return out
}
/**
* Every instance's field values across the views `templateUids`, as
* Map<instance_id as string, values>. A view that fails to load (except on
* 401) is skipped, so the list still renders from its own view.
*/
async function fetchInstanceValues(templateUids) {
const results = await Promise.all(
templateUids.map((uid) =>
fetchAllRecordRows(uid).catch((err) => {
if (err?.status === 401) throw err
return { rows: [] }
}),
),
)
const byId = new Map()
for (const { rows } of results) {
for (const row of rows ?? []) {
if (!present(row.instance_id)) continue
const key = String(row.instance_id)
byId.set(key, fillMissing(byId.get(key) ?? {}, row))
}
}
return byId
}
/** The asset's generated Asset ID (e.g. ID-00002) from its id_gen field, or ''. */
const assetCodeOf = (row) => String(Object.entries(row ?? {}).find(([k, v]) => /^idgen_\d+$/.test(k) && present(v))?.[1] ?? '')
/** Most detail views read for one list, so a large list doesn't send a request per row. */
const MAX_DETAIL_FILLS = 30
/**
* Rows that still don't say which asset they are, or who has an Assigned
* asset, filled in from the instance's detail view — which holds every field.
* A detail view that fails to load (except on 401) leaves its row as it is.
*/
async function withDetailValues(rows) {
const lacking = (row) =>
present(row.instance_id) &&
((!assetCodeOf(row) && !present(withEditedValues(row).field_42)) ||
(isAssetState(row, ASSET_STATE.ASSIGNED) && !currentAssignment(row).name))
const todo = rows.filter(lacking).slice(0, MAX_DETAIL_FILLS)
if (!todo.length) return rows
const BATCH = 6
const filled = new Map()
for (let i = 0; i < todo.length; i += BATCH) {
await Promise.all(
todo.slice(i, i + BATCH).map(async (row) => {
try {
const res = await fetchDetailView(DETAIL_VIEWS.assets.templateUid, row.instance_id)
const view = normalizeDetailView(res, { instanceId: row.instance_id, fieldDefs: ASSET_FORM_FIELDS })
const fields = (view.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key)
filled.set(row, { ...view.record, ...Object.fromEntries(fields.map((f) => [f.key, f.value])) })
} catch (err) {
if (err?.status === 401) throw err
}
}),
)
}
return rows.map((row) => fillMissing(row, filled.get(row)))
}
/** One page of the asset assignment list. `query.filter` is one of ASSIGNMENT_FILTERS. */
export const fetchAssignmentListPage = (templateUid, query = {}) =>
fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS })
@ -1093,12 +1245,75 @@ function withInstanceIdColumn(res) {
* field, related columns grouped (ASSET_RICH_COLUMNS), and the instance id.
*/
export async function withAssetListDisplay(res) {
const rows = Array.isArray(res?.data) ? res.data : []
const table = await fetchAssetTableIndex()
const rows = (Array.isArray(res?.data) ? res.data : []).map((row) => withTableValues(table, res?.config, row))
const named = await withOwnerNames(res?.config, rows.map(withEditedValues))
const display = withAssetFormColumns({ ...res, data: named })
return withInstanceIdColumn(withRichColumns(display, ASSET_RICH_COLUMNS))
}
/**
* The asset table view (RECORD_VIEWS.assetTable) indexed for withTableValues:
* each table row's INIT values by field id, looked up by instance id, Asset
* ID, serial number or asset identifier. Null when the view can't be read
* (except on 401), so the pages still work from the workflow views alone.
*/
async function fetchAssetTableIndex() {
let table
try {
table = await fetchAllRecordRows(RECORD_VIEWS.assetTable.templateUid)
} catch (err) {
if (err?.status === 401) throw err
return null
}
const index = { instance: new Map(), code: new Map(), serial: new Map(), ident: new Map() }
const add = (map, key, values) => key && !map.has(key) && map.set(key, values)
for (const row of table.rows ?? []) {
const values = initValuesOf(table.config, row)
add(index.instance, present(row.instance_id) ? String(row.instance_id) : '', values)
add(index.code, matchKey(assetCode(table.config, row)), values)
add(index.serial, matchKey(values.field_44), values)
add(index.ident, matchKey(values.field_49), values)
}
return index
}
/** A view row's Add Asset values by INIT field id, read from its columns by id, label or table column name. */
function initValuesOf(config, row) {
const values = {}
for (const c of config?.fields ?? []) {
const def = assetFieldDef(c.field_key, c.output_label)
if (def && present(row[c.field_key]) && !present(values[def.id])) values[def.id] = row[c.field_key]
}
for (const def of ASSET_FORM_FIELDS) {
const v = rowFieldValue(def, row)
if (!present(values[def.id]) && present(v)) values[def.id] = v
}
return values
}
/**
* A workflow row with its asset's Add Asset values from the asset table
* (`table`, from fetchAssetTableIndex) wherever it has none. The workflow
* views hold the Edit activity's values, so an asset that was never edited
* shows nothing there. Its own values, edits included, always win.
*/
function withTableValues(table, config, row) {
if (!table || !row) return row
const serial = ASSET_FIELDS.find((f) => f.id === 'field_44')
const ident = ASSET_FIELDS.find((f) => f.id === 'field_49')
const edited = withEditedValues(row)
const values =
(present(row.instance_id) && table.instance.get(String(row.instance_id))) ||
table.code.get(matchKey(assetCode(config, row) || assetCodeOf(row))) ||
[rowFieldValue(serial, row), rowFieldValue(serial, edited)].map((v) => table.serial.get(matchKey(v))).find(Boolean) ||
[rowFieldValue(ident, row), rowFieldValue(ident, edited)].map((v) => table.ident.get(matchKey(v))).find(Boolean)
if (!values) return row
const out = { ...row }
for (const [k, v] of Object.entries(values)) if (!present(out[k])) out[k] = v
return out
}
/**
* The row key holding an INIT field's value. Workflow-backed views send it by
* field id (field_43); table-backed views send the storage column, named after
@ -1163,8 +1378,18 @@ export function withAssetFormColumns(res) {
})
const otherColumns = viewFields.filter((c) => !defOf.get(c))
const selectColumns = viewFields.filter((c) => defOf.get(c)?.type === 'select')
// An Edit column (field_2…) is empty until the asset is edited: it shows the INIT value.
const fieldColumns = viewFields.filter((c) => defOf.get(c))
const withInit = (row) => ({
...row,
...Object.fromEntries(
fieldColumns
.filter((c) => !present(row[c.field_key]) && present(rowFieldValue(defOf.get(c), row)))
.map((c) => [c.field_key, rowFieldValue(defOf.get(c), row)]),
),
})
const rows = Array.isArray(res?.data) ? res.data : []
const rows = Array.isArray(res?.data) ? res.data.map(withInit) : []
return {
...res,
config: { ...res?.config, fields: [...formColumns, ...otherColumns] },
@ -1557,19 +1782,22 @@ export async function fetchHistoryListPage(templateUid, query = {}) {
* The row comes from the asset record view (it carries the state and the
* activity values), with the asset detail view's values filled in where the
* record view has none — the detail view also holds fields the record view has
* no column for. `config` is the record view's column config, so the details
* page shows the same fields as the asset list.
* no column for — and the asset table's Add Asset values where neither has
* one (see withTableValues). `config` is the record view's column config, so
* the details page shows the same fields as the asset list.
* If the detail view fails, the page still works from the record view alone.
* `ownerId` is the Asset Owner's employee id, which the row shows as a name.
* @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, instanceProblem?: string}>}
* `ownerId` is the Asset Owner's employee id, which the row shows as a name;
* `ownerName` is that name.
* @returns {Promise<{config: object|null, row: object|null, ownerId?: number|string|null, ownerName?: string, instanceProblem?: string}>}
*/
export async function fetchAssetInstance(instanceId) {
const [{ config, rows }, detail] = await Promise.all([
const [{ config, rows }, detail, table] = await Promise.all([
fetchAllRecordRows(RECORD_VIEWS.assets.templateUid),
fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => {
if (err?.status === 401) throw err
return null
}),
fetchAssetTableIndex(),
])
const listRow = rows.find((r) => String(assetRowId(r)) === String(instanceId)) || null
const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null
@ -1583,6 +1811,16 @@ export async function fetchAssetInstance(instanceId) {
}
const row = { ...detailValues }
for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v
// The detail view may key a field by its own name (not its field id); keep
// its value under the field id too, so the page and the Edit form find it.
// A key that is a field id is already in place (and may be another activity's field).
for (const f of detailFields) {
if (isFieldKey(f.key)) continue
const def = assetFieldDef(f.key, f.label)
if (def && present(f.value) && !present(row[def.id])) row[def.id] = f.value
}
// Every Add Asset value the workflow views lack, from the asset table.
Object.assign(row, withTableValues(table, config, row))
// The asset table's rows carry no instance_id, and the URL id is the table
// id, which the runtime rejects as NOT_FOUND; look the instance up instead.
const known = [view?.record?.instance_id, listRow?.instance_id].find(present)
@ -1590,7 +1828,18 @@ export async function fetchAssetInstance(instanceId) {
row.instance_id = lookup.instanceId ?? null
const [named] = await withOwnerNames(config, [withEditedValues(row)])
// Read before withOwnerNames swaps the owner lookup for a name.
const owner = ASSET_FORM_FIELDS.find((d) => d.id === ASSET_OWNER_FIELD)
const ownerName = [initValueKey(owner, named), ...ownerKeys(config)]
.map((k) => named[k])
.find((v) => typeof v === 'string' && v.trim())
// ownerId is read before withOwnerNames swaps the owner lookup for a name;
// ownerName picks the owner when the id isn't known.
// instanceProblem: why the instance couldn't be found, when it couldn't.
return { config, row: named, ownerId: assetOwnerId(row), instanceProblem: lookup.problem }
return {
config,
row: named,
ownerId: assetOwnerId(row, config),
ownerName: ownerName?.trim() ?? '',
instanceProblem: lookup.problem,
}
}

View File

@ -33,6 +33,14 @@ export const RECORD_VIEWS = {
screen: 'ddddd',
rowKey: 'instance_id',
},
assetTable: {
// The asset table: the INIT (Add Asset) activity's values, columns named
// after the builder labels (asset_brand, serial_number…). Rows are keyed
// by _pk_value and carry no instance_id, so they are matched to a
// workflow instance by Asset ID or serial number (see withTableValues).
templateUid: 'de18263e-dc82-4d4d-98a2-99607104373b',
rowKey: '_pk_value',
},
assetAssignments: {
// The asset workflow view the Assignment list reads; its rows carry the
// instance_id the ASSIGNED and Reassigned activities run on.

View File

@ -3,10 +3,13 @@ import { useNavigate } from 'react-router-dom'
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
import ActivityFields from '../../components/activity/ActivityFields'
import {
ASSET_ASSIGN_ACTIVITY_ID,
ASSET_ASSIGN_FIELDS,
ASSET_EDIT_ACTIVITY_ID,
ASSET_EDIT_FIELDS,
ASSET_LOST_ACTIVITY_ID,
ASSET_LOST_FIELDS,
ASSET_REASSIGN_ACTIVITY_ID,
ASSET_REASSIGN_FIELDS,
ASSET_REPAIRED_FIELDS,
ASSET_REPAIR_FIELDS,
@ -15,6 +18,7 @@ import {
assignAsset,
completeAssetRepair,
editAsset,
fetchAssetActivityFieldDefs,
fetchAssignableEmployees,
reassignAsset,
reportAssetLost,
@ -22,7 +26,7 @@ import {
sendAssetForRepair,
uploadAssetFile,
} from '../../api/assets'
import { emptyValues, validateActivity } from '../../api/activityForm'
import { emptyValues, matchOption, validateActivity, withBuilderOptions } from '../../api/activityForm'
import { useAuth } from '../../store/AuthContext'
/**
@ -31,12 +35,14 @@ import { useAuth } from '../../store/AuthContext'
* other fields come from api/assets.js, so each form matches its activity.
* `employeeRequired: false` lets the form run without one, and `employee: false`
* leaves the picker out; `warning` is shown above the form for an activity
* that can't be undone.
* that can't be undone. `activityId` tags photo uploads, and its select
* options are read from the builder when the dialog opens.
*/
const ACTIVITIES = {
assign: {
title: 'Assign Asset',
subtitle: 'Runs the ASSIGNED activity in the asset workflow.',
activityId: ASSET_ASSIGN_ACTIVITY_ID,
fields: ASSET_ASSIGN_FIELDS,
employeeLabel: 'Employee data',
employeeError: 'Choose who gets this asset.',
@ -48,6 +54,7 @@ const ACTIVITIES = {
reassign: {
title: 'Reassign Asset',
subtitle: 'Runs the Reassigned activity in the asset workflow. The asset stays Assigned.',
activityId: ASSET_REASSIGN_ACTIVITY_ID,
fields: ASSET_REASSIGN_FIELDS,
employeeLabel: 'Employee data',
employeeError: 'Choose who gets this asset next.',
@ -72,6 +79,7 @@ const ACTIVITIES = {
lost: {
title: 'Report Asset Lost',
subtitle: 'Runs the Lost activity in the asset workflow.',
activityId: ASSET_LOST_ACTIVITY_ID,
fields: ASSET_LOST_FIELDS,
employeeLabel: 'Last owner',
employeeRequired: false,
@ -121,7 +129,8 @@ const ACTIVITIES = {
/**
* Dialog for one asset activity. `kind` is a key of ACTIVITIES;
* `defaultEmployeeId` preselects an employee (e.g. the assignee on return);
* `defaultEmployeeId` preselects an employee (e.g. the assignee on return),
* else `defaultEmployeeName` does, matched by name;
* `initialValues` pre-fills the fields (e.g. the asset's details on edit);
* `instanceProblem` says why there is no `instanceId`, when there is none.
*/
@ -131,6 +140,7 @@ export default function AssetActivityModal({
instanceId,
instanceProblem,
defaultEmployeeId,
defaultEmployeeName,
initialValues,
onClose,
onDone,
@ -144,6 +154,9 @@ export default function AssetActivityModal({
const [employees, setEmployees] = useState([])
const [loadingEmployees, setLoadingEmployees] = useState(false)
const [employeeId, setEmployeeId] = useState('')
// The builder's field definitions (select options); null until read, or when they can't be.
const [builderDefs, setBuilderDefs] = useState(null)
const [loadingOptions, setLoadingOptions] = useState(false)
const [values, setValues] = useState(() => emptyValues(activity.fields))
const [errors, setErrors] = useState({})
const [serverError, setServerError] = useState('')
@ -160,14 +173,34 @@ export default function AssetActivityModal({
setErrors({})
setServerError('')
setSubmitting(false)
if (!hasEmployee) return undefined
let cancelled = false
// Select options as the builder has them. Not being able to read them
// (the builder API may refuse an app user) isn't an error: the form keeps
// its own lists and leaves out fields whose options are unconfirmed.
setBuilderDefs(null)
if (activity.activityId && activity.fields.some((f) => f.type === 'select')) {
setLoadingOptions(true)
fetchAssetActivityFieldDefs(activity.activityId)
.then((defs) => !cancelled && setBuilderDefs(defs))
.catch(() => {})
.finally(() => !cancelled && setLoadingOptions(false))
}
if (!hasEmployee) {
return () => {
cancelled = true
}
}
setLoadingEmployees(true)
fetchAssignableEmployees()
.then((list) => {
if (cancelled) return
setEmployees(list)
// No usable default id (none given, or not an active employee): match by name.
const byName = (defaultEmployeeName ?? '').trim().toLowerCase()
const named = byName && list.find((emp) => String(emp.name ?? '').trim().toLowerCase() === byName)
if (named) setEmployeeId((cur) => (list.some((emp) => String(emp.id) === cur) ? cur : String(named.id)))
// A field that names an employee (Assigned by) starts as the signed-in user.
const me = list.find(
(emp) => (user?.email && emp.email === user.email) || (user?.name && emp.name === user.name),
@ -189,7 +222,27 @@ export default function AssetActivityModal({
return () => {
cancelled = true
}
}, [open, activity, hasEmployee, defaultEmployeeId, logout, navigate, user])
}, [open, activity, hasEmployee, defaultEmployeeId, defaultEmployeeName, logout, navigate, user])
// The builder's options may name a pre-filled select's value differently
// ("In use" for in_use): switch it to the matching option's value.
useEffect(() => {
if (!builderDefs) return
setValues((v) => {
let changed = false
const next = { ...v }
for (const f of activity.fields) {
const options = builderDefs.get(f.id)?.options
if (f.type !== 'select' || !options?.length || !next[f.id]) continue
const hit = matchOption(options, next[f.id])
if (hit !== undefined && hit !== next[f.id]) {
next[f.id] = hit
changed = true
}
}
return changed ? next : v
})
}, [builderDefs, activity])
function close() {
if (submitting) return
@ -213,7 +266,7 @@ export default function AssetActivityModal({
return
}
const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null
const nextErrors = validateActivity(activity.fields, values)
const nextErrors = validateActivity(fields, values)
if (!employee && employeeRequired) nextErrors.employee = activity.employeeError
setErrors(nextErrors)
if (Object.keys(nextErrors).length) return
@ -221,7 +274,10 @@ export default function AssetActivityModal({
setSubmitting(true)
setServerError('')
try {
const response = await activity.run(instanceId, values, employee)
// Only what the form shows is sent; a field left out has no options the runtime accepts.
const shown = new Set(fields.map((f) => f.id))
const sent = Object.fromEntries(Object.entries(values).filter(([id]) => shown.has(id)))
const response = await activity.run(instanceId, sent, employee)
onDone?.(employee, response)
onClose?.()
} catch (err) {
@ -241,9 +297,16 @@ export default function AssetActivityModal({
}))
const formId = `${kind}-asset-form`
// Fields with no builder options that pick an employee by name (Assigned by).
const fields = activity.fields.map((f) =>
f.employeeOptions ? { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) } : f,
)
// A pre-filled select value that's in none of the options (the asset was
// saved with it) is still offered, so the form shows it instead of blank.
const fields = withBuilderOptions(activity.fields, builderDefs).map((f) => {
if (f.employeeOptions) return { ...f, options: employees.map((emp) => ({ label: emp.name, value: emp.name })) }
const current = initialValues?.[f.id]
if (f.type !== 'select' || !current || values[f.id] !== current || matchOption(f.options, current) !== undefined) {
return f
}
return { ...f, options: [...(f.options ?? []), { label: String(current), value: String(current) }] }
})
return (
<Modal
@ -262,7 +325,7 @@ export default function AssetActivityModal({
form={formId}
variant={activity.variant ?? 'primary'}
icon={submitting ? undefined : 'check'}
disabled={submitting || loadingEmployees}
disabled={submitting || loadingEmployees || loadingOptions}
>
{submitting ? activity.submitting : activity.submit}
</Button>

View File

@ -190,7 +190,7 @@ export default function AssetDetails() {
const toFields = (defs) =>
defs.map((f) => ({
key: f.id,
label: f.label,
label: f.formLabel ?? f.label,
type: f.type,
slots: f.slots,
otherSlot: f.otherSlot,
@ -412,7 +412,9 @@ export default function AssetDetails() {
? current.id
: null
}
initialValues={activity === 'edit' ? assetEditValues(row) : undefined}
// Picks the owner by name when the view gives no employee id.
defaultEmployeeName={activity === 'edit' ? data.ownerName : undefined}
initialValues={activity === 'edit' ? assetEditValues(config, row) : undefined}
onClose={() => setActivity(null)}
onDone={handleDone}
/>