Attributes on the Frontend
The backend's Attribute Engine lets an administrator
add fields to almost any entity at runtime. On the frontend those fields
have to appear in forms, validate, type-check, and show up as table
columns — without a code change per field. Three small modules in
src/components/attribute-fields/ do that.
Fetching definitions
src/core/hooks/useAttributeDefinitionService.ts:
| Hook | Use |
|---|---|
useAttributeDefinitions() | Every definition — filter client-side by entityType / isActive |
useAttributeDefinitionsForEndpoint(method, path) | Just the definitions that apply to one API operation, e.g. ('POST', '/stocks/NewArrivalBatch'). Prefer this on a specific form |
useActionsForEntityType(entityType) | The list of actions an entity supports — for the definition editor's "Action" dropdown |
getActiveAttributeDefinitions(defs, entityType) (in
attribute-table-columns.ts) is the standard filter+sort: active only,
ordered by displayOrder.
Rendering a field
<AttributeField definition={def} control={form.control} /> renders one
field, switching on definition.dataType:
dataType | Control |
|---|---|
text | <Input> |
number | <Input type="number">, empty → undefined |
boolean | <Switch> in a bordered row |
date | <Popover> + <Calendar> |
select | <Select> over definition.options |
The field name is definition.key, so the value lands in the form under
that key.
Validating
buildAttributeSchema(definitions) builds a Zod object — z.string() /
z.number() / z.boolean() / z.date() / z.enum(options) per
dataType, wrapped in .optional() unless definition.required.
The pattern on a form with custom fields:
const combinedSchema = BaseFormSchema.merge(
buildAttributeSchema(activeProductDefinitions)
);
// ...
<Form ... resolver={zodResolver(combinedSchema)}>
{/* base fields */}
{activeProductDefinitions.map((d) => (
<AttributeField key={d.id} definition={d} control={form.control} />
))}
On submit, collect the dynamic values back out by key:
const attributeValues = Object.fromEntries(
activeProductDefinitions.map((d) => [d.key, data[d.key]])
);
and send them as the payload's attributeValues object.
Showing values in tables
formatAttributeValue(definition, value) renders a stored value
consistently regardless of table implementation — — for empty,
Yes/No for boolean, YYYY-MM-DD for date, String(value) otherwise.
The data tables build one extra column per active definition:
...getActiveAttributeDefinitions(defs, "Product").map((d) => ({
accessorFn: (row) => row.attributeValues?.[d.key],
header: d.label,
}))