TSX Generation Internals
generateTsx() in src/tsx/tsx-generator.js converts a JSON layer version into a typed index.tsx file. This page documents every significant step, internal helper, and edge-case behaviour.
The 10-Step Pipeline
gen:tsx
│
1. Resolve JSON source version (--jsonVersion or latest)
2. Load data.json + changes.json
3. Determine next TSX version, create directory
4. Copy config-template.tsx → index.tsx
5. Normalize all field sets to { fieldName: metadata } objects
6. Apply fieldOverrides from changes.json
7. Apply exclusions, hide lists, order, writeableProperties filtering
8. Call all template-generator AddX() functions to populate placeholders
9. applyChangesCustomizations() — regex post-processing
10. Format with PrettierStep 5 — Field Normalization
normalizeToFieldObject(data)
All field data — whether it arrived from an old array format or the current object format — is normalised to a single shape before any processing:
{ [fieldName: string]: { fieldName: string, type: string, ... } }| Input shape | How it’s handled |
|---|---|
{ fieldName: metadata, … } (current raw API format) | Each entry enriched with enrichFieldWithResourceInfo() |
[{ fieldName, type, … }, …] (old array format) | Indexed by fieldName |
{ "Section": [field, …], … } (sectioned legacy format) | Sections flattened, indexed by fieldName |
["fieldName", …] (string array) | Each string becomes { fieldName, type: 'string' } |
normalizeRequiredFields(requiredFields, allFields)
Required fields may arrive as either a string array (["name", "type"]) or a full field-object array. In the string case, metadata is looked up from allFields — or a minimal stub { fieldName, type: 'string', required: true } is created.
enrichFieldWithResourceInfo(field)
Relation fields (MANY_TO_ONE, ONE_TO_ONE, etc.) need targetEntityResourceName and targetEntityResourceIri for the SELECT_ASYNC component to make API calls. If these are missing from the raw API data, they are derived automatically:
field.targetEntity = "AccountingBundle/AccountType"
↓ deriveResourceNameFromEntity()
targetEntityResourceName = "accounting_account_types"
targetEntityResourceIri = "/api/accounting_account_types"This derivation only runs when targetEntity is present but the resource fields are absent. If the API already returned them, they are used as-is.
Step 6 — fieldOverrides
changes.fieldOverrides is applied to allAvailableFields — a merged pool of every field from every source (listing, show, form, filter, required). This means overrides affect the field regardless of which section it appears in.
"fieldOverrides": {
"price": { "type": "CURRENCY" },
"tableName": { "optionsArrayFinite": "TableNames" }
}If the field does not exist in allAvailableFields at all, it is created as a new entry from the override object — useful for injecting synthetic fields.
Step 7 — Field Filtering & Ordering
isFieldHidden(fieldName, section) — exclusion category mapping
Each changes.json section maps to a specific exclusionList category:
| Section key | exclusionList category checked |
|---|---|
listingFields | listing |
formFields | forms |
showFields | show |
filterFields | listing |
formModalShowFields | forms |
formModalEditFields | forms |
formModalAddFields | forms |
A field is hidden if it appears in either changes[section].hide or changes.exclusionList[category].
For modal sections (formModalShow/Edit/Add), changes.formFields.hide is also checked — hiding a form field hides it from all modals too.
listingFields.hideSelected
listingFields.selected controls what’s shown in the listing. listingFields.hideSelected is a sub-list of fields that are selected (counted in the column set) but not visible by default in the user-facing column toggle. They are present in the data but hidden in the UI toggle.
"listingFields": {
"selected": ["id", "name", "status", "internalRef"],
"hideSelected": ["internalRef"]
}internalRef is in the column set but won’t appear in the user’s column-visibility toggle.
writeableProperties — three distinct states
writeableProperties comes from the API’s writable_properties field and controls which fields appear in forms and modals.
| State | Value | Behaviour |
|---|---|---|
| Not returned by API | null / undefined | Skip whitelist — all form fields included |
| Returned as empty array | [] | Entity is explicitly read-only — form fields cleared to {} |
| Returned as populated array | ["name", "type", …] | Whitelist — only listed fields kept in formFields, formModalEditFields, formModalAddFields, section maps |
The null vs [] distinction matters. An API that returns writable_properties: [] explicitly signals a read-only entity. The generator will produce empty form inputs in that case.
formModalShowFields (read-only view modal) is not filtered by writeableProperties — it’s for display only.
sortableFields — auto-applied from API
If the API returns OrderFilter in apiFilters, the generator extracts its gobal.properties list and uses it as the default sortable list for listingFields, unless changes.listingFields.sortable is already set explicitly.
Form sections + writeableProperties interaction
When both formFields.sections and writeableProperties are present, sections are filtered field-by-field. Any section whose entire field list is excluded ends up empty and is dropped from the output.
Step 8 — Template Function Calls
The template functions are called in this order on index.tsx:
| Function | Replaces placeholder | Input |
|---|---|---|
HeaderInformation | [generationDate] | Current timestamp |
AddFQCN | [bundle], [unit] | Encoded entity + bundleCrud |
AddResource | [resource], [tableName] | resourceName + tableName from JSON |
AddFieldsAll | [fieldsAll] | All listing column keys |
AddFieldsExport | [fieldsExport] | Same as fieldsAll |
AddSelectedList | [selectedList] | Default-selected listing fields |
AddMainColumns | [mainColumns] | Listing fields with listingConfig (sortable, widths, alignment, iconFields) |
AddFormModalInputs (×3) | [formModalShowInputs], [formModalEditInputs], [formModalAddInputs] | Per-modal field set |
AddFormInputs | [addFormInputs] | Form fields with section map and required set |
AddImportStatements | [importStatements] | Extra imports for special entities |
AddFilterFields | [addFilterFields] | Filter fields with filter config |
AddSideBoxs | [addSideBoxs] | Sidebox relationship fields |
AddDisplayFields | [addDisplayFields] | Show-page field display with section map |
AddqField | [qfield] | Primary search field from encoded entity |
AddInterfaces | [implementedInterfaces] | Interface map |
AddDefaultListingFilters | [defaultFilters] | defaultFilterValues from filterFields changes |
DefaultListingActions | [defaultListingActions] | Default actions per bundleCrud + interfaces |
AllListingActions | [listingAllActions], [kanbanListingAllActions], [treeviewListingAllActions] | From changes.actions.* |
DefaultCustomActions | [customActions] | Actions from resourceName + interfaces |
formModalFields fallback chain
The three typed modal sections (formModalShowFields, formModalEditFields, formModalAddFields) were introduced after the legacy formModalFields key. The resolver uses:
const sectionChanges = changes[sectionKey] ?? changes.formModalFields ?? {};So if a changes.json only has the old formModalFields key, all three modal types inherit from it. If a typed key exists, it takes precedence.
Step 9 — Post-Processing (applyChangesCustomizations)
After all AddX() calls, the generated file is read back as a string and a set of regex replacements are applied. This step allows changes.json to directly override values that were already written into the TypeScript source by the template functions.
What’s patched via regex
changes.json key | What gets patched in the TSX |
|---|---|
listingFields.selected | Rewrites const selectedList: string[] = [...] |
filterFields.defaults | Sets default: true/false on each filter field object |
formFields.required / optional | Sets required: true/false on each form input object |
actions.listing | Rewrites const defaultActions: string[] = [...] |
actions.listingAllActions | Rewrites const listingAllActions: string[] = [...] |
actions.kanbanListingAllActions | Rewrites const kanbanListingAllActions: string[] = [...] |
actions.treeviewListingAllActions | Rewrites const treeviewListingAllActions: string[] = [...] |
actions.showPage | Rewrites const showPageActions: string[] = [...] |
This two-phase approach (template functions → regex patch) means that some values are set twice — once by the template generator based on raw field data, then overridden in the post-processing step based on your changes.json. The regex patch is the final value.
Boolean fields always non-required
A hardcoded workaround forces all ColumnTypeEnum.BOOLEAN fields to required: false, regardless of what the API or changes.json specifies. This is applied globally after all other processing.
// TEMPORARY WORKAROUND
// https://github.com/PHPCreation/.../issues/838
content = content.replace(
/(type:\s*ColumnTypeEnum\.BOOLEAN[^}]*?required:\s*)true/g,
'$1false'
);Relation Field Auto-Detection
Fields with relational types (MANY_TO_ONE, ONE_TO_MANY, MANY_TO_MANY, ONE_TO_ONE) receive ColumnTypeEnum.SELECT_ASYNC automatically. Additionally:
MANY_TO_MANYfields are automatically excluded from form inputs and all modal inputs — they appear only in sideboxestargetEntityResourceIriis set to/api/{resourceName}for use as the async options endpoint
allAvailableFields Pool
A merged map of all fields from all sources (listing + show + form + required + filter) is built before filtering and used as the lookup for every section. This means:
- A field in
showFieldscan be referenced informFields.sections fieldOverridesapplies universallytoStringis always injected as a virtual field:{ fieldName: 'toString', type: 'string', format: 'string' }
generateTsxAll(options)
Calls MetaDataEntitiesList to enumerate all entities in the bundle, then runs generateTsx for each one sequentially. Entity names are decoded from URL-encoded PHP FQCN format back to Bundle/Entity format before each call.
Returns { success: [], failed: [], skipped: [] }.