This file: Completed phases only — decision rationale, what was built, implementation detail.
Current API contracts: SPEC.md
Upcoming work: PLAN.md
Completed. Implemented
setFieldProperty,setFieldProperties,removeFieldProperty,getFieldfor fields, sections, tabs, and child table rows.
| File | Purpose |
|---|---|
src/utils/expressions.js |
_eval, evaluateDependsOnValue, evaluateExpression — extracted from utils/index.js to allow import without pulling in Vue components |
src/utils/fieldTransforms.js |
processField(), findMissingMandatory(), parseLinkFilters() — pure functions, independently testable |
src/utils/scriptHelpers.js |
getClassNames(), createDocProxy() — extracted from script.js closure |
Every place that previously mutated shared field objects now clones first:
Field.vuecomputed:let field = { ...props.field }SidePanelLayout.vueparsedField():field = { ...field }Grid.vuegetFieldObj():field = { ...field }
JSON.parse(field.link_filters) (6 call sites, would throw when link_filters was already an object) replaced everywhere with parseLinkFilters(field.link_filters).
Added to the document cache entry alongside fieldHtmlMap:
fieldPropertyOverrides = {
// parent/side-panel fields
'annual_revenue': { hidden: true },
'status': { options: 'New\nIn Progress' },
// sections and tabs (by name)
'financial_section': { hidden: true },
'advanced_tab': { hidden: true, label: 'Expert' },
// child table columns (dot notation)
'products.qty': { read_only: true },
'products.discount': { hidden: true },
// child table per-row (dot notation + colon + row.name)
'products.rate:row_abc123': { read_only: false },
}Old: called getFields() which filtered out hidden fields and only checked mandatory_depends_on.
New: findMissingMandatory() from fieldTransforms.js which:
- Uses raw
doctypesMeta[doctype].fields(all fields including hidden) - Checks both
reqd: 1andmandatory_depends_onexpressions - Respects
hiddenandreqdfromfieldPropertyOverrides(script overrides win) - Hidden fields are always skipped regardless of
reqd
script.js setFieldProperty()
└─► ctx.fieldPropertyOverrides[target][property] = value
│
├─ SidePanelLayout.vue
│ parsedField() → Object.assign(field, overrides)
│ parsedSection() → Object.assign(section, overrides)
│
├─ FieldLayout.vue
│ processedTabs computed → tab/section overrides merged → hidden tabs filtered
│ │
│ └─ Field.vue (non-grid)
│ computed field → getFieldOverrides(fieldname) → Object.assign(field, overrides)
│ provide('fieldPropertyOverrides', ...) → Grid.vue injects it
│
└─ Field.vue (isGridRow=true, inside GridRowModal)
inject fieldPropertyOverrides from Grid.vue
resolves: col key (products.qty) + row key (products.qty:rowName)
Grid.vue
getFieldObj(field)
→ colKey = `${parentFieldname}.${field.fieldname}`
→ Object.assign(field, overrides[colKey]) ← column-level
→ hidden columns filtered → gridTemplateColumns recalculated
getRowFieldObj(field, row)
→ rowKey = `${colKey}:${row.name}`
→ merged = { ...colOverrides, ...rowOverrides } ← row wins over column
→ per-row hidden → empty cell (preserves grid alignment)
| Issue | Status |
|---|---|
getFields() still mutates doctypesMeta field objects (Select options, Link→User) |
Deferred — rendering components clone first now, acceptable until Phase 4 |
| Layout APIs return redundant full field meta | Deferred — full getMeta refactor (Phase 4) |
getMeta getFields() filters hidden fields |
Intentional for now; raw doctypesMeta used where hidden fields needed |
Completed. Added
contextprop to FieldLayout enabling standalone rendering withoutuseDocument.
FieldLayout.vue always called useDocument(props.doctype, props.data?.name) to get fieldPropertyOverrides. For a dialog with inline fields (no doctype), this called useDocument('', undefined) creating a garbage entry in documentsCache. For a dialog with a real doctype like 'CRM Lost Reason', it would trigger script loading unintentionally.
The context prop carries the externally managed context object ({ fieldPropertyOverrides, fieldHtmlMap }). When provided, useDocument is skipped entirely.
Not chosen: Option A (standalone boolean) — context is more extensible, can carry more in future (triggerOnChange, triggerButton, etc.) without adding more props.
FieldLayout.vue:
- Added
context: { type: Object, default: null }prop - When
contextis present: usescontext.fieldPropertyOverridesfor tab/section overrides instead of callinguseDocument - Provides
fieldLayoutContextvia inject for child Field components
Field.vue:
- Injects
fieldLayoutContext. When present: skipsuseDocumententirely, field changes update data directly, scripting triggers are no-ops - Guards
getMeta(doctype)— only called when doctype is truthy. Inline mode usesformatNumber/formatCurrencyfallback formatters directly
Completed. Script authors can open a FieldLayout-based dialog, collect data, and act on it.
Three patterns were considered:
- Option A (callbacks only, consistent with
createDialog) — too verbose for simple cases - Option B (
onSubmitonly) — doesn't support sequential multi-step workflows - Option C (all three, Promise always resolves) — chosen. Most flexible. Promise for sequential, callback for fire-and-forget, actions for full control.
Dialog fields are NOT scriptable (intentional). The dialog is a data collector only. setFieldProperty called inside a dialog action affects the page fields, not the dialog's fields. Full isolation would require a separate fieldPropertyOverrides scope per dialog — deferred.
| File | Description |
|---|---|
frontend/src/components/Modals/FieldLayoutDialog.vue |
Dialog shell + standalone FieldLayout + local reactive doc. Validates before resolving. |
frontend/src/components/Modals/FieldLayoutDialogContainer.vue |
Renders entries from the fieldLayoutDialogs reactive array |
frontend/src/utils/renderFieldLayoutDialog.js |
Pushes config to array, returns Promise. Internal onResolve is distinct from user's onSubmit. |
frontend/src/components/Modals/GlobalModals.vue |
Mounts <FieldLayoutDialogContainer /> |
frontend/src/data/script.js |
helpers.formDialog = renderFieldLayoutDialog — bare helper in script scope |
- Buttons stuck in loading:
_loadingwas aref()insidecomputed(). Vue doesn't auto-unwrap refs nested inside plain objects in templates. Fixed withreactive({})actionLoadingMapoutside the computed. - Double-event bug:
v-bind="dialog.props"passedonResolveas a@resolvelistener AND@resolveexplicitly added it again. Fixed by strippingonResolvefrom the spread inFieldLayoutDialogContainer. getMeta('')console error:Field.vuecalledgetMeta(doctype)unconditionally. When doctype is empty (inline mode) this triggers an API call that fails. Fixed with doctype guard.v-bind="action"spreading internals: Template was spreading entire action objects including_loadingref, wrappedonClick, etc. onto Button. Fixed with explicit prop bindings.
tabs— full custom layout (highest)fields— flat list, auto-wrappeddoctype+fieldnames— specific fields from doctype metadoctypealone — full Quick Entry layout
Current stable API: SPEC.md — formDialog API
Full guide with examples: feats/form-scripting/form-dialog.md