Building a Foundation input form
How a host database gives one of its tables a data-entry form that Foundation drives: navigation between records, the Save and Cancel paths, record I/O and window handling all come from Fnd_IO_InputForm; you draw the fields and wire the related tables. The worked example is the demo database, Foundation Product Sales: an Invoices record (table 6) with its Contacts parent (table 4) and its LineItems children (table 7).
Written for a developer or an AI agent building such forms from a catalog. Everything an agent cannot see at compile time is marked not checked by the compiler.
1. Where the form lives, and why
Input forms are table forms: Project/Sources/TableForms/<n>/Input/, where <n> is the table number from catalog.4DCatalog (<table name="Invoices" … id="6">), not its name. Fnd_IO_InputForm is table-bound and a project form under Forms/ cannot inherit from it. This is deliberate, not legacy drift.
| File | Holds |
|---|---|
TableForms/6/Input/form.4DForm | the form, JSON |
TableForms/6/Input/method.4dm | the form method, a thin shell |
TableForms/6/Input/ObjectMethods/<objectName>.4dm | one per clickable object; the file name is the binding |
2. Classic for the form's own record, ORDA for everything else
A form opened by MODIFY SELECTION or MODIFY RECORD has no Form object and no entity. The current record is what exists, so the form reads its own record classically ([Invoices]InvoiceNumber) and reaches related data with ORDA (ds.LineItems.query(…)). That is the rule here, narrower than "never mix": classic for this record, ORDA for the rest.
State the form and its objects share lives on a process object named for your project (FPS, ACME), created by an init method that Fnd_Hook_IO_DisplayRecord and Fnd_Hook_IO_DisplayTable both call, so it exists before any form opens; application-wide state goes in Storage.<Project>. Property names derive mechanically from the table name: drop underscores, lower-case the first letter, capitalise what followed each underscore, then one of four suffixes that match the listbox properties they bind:
FPS.lineItemSelection // the entity selection
FPS.lineItemCurrItem // currentItemSource
FPS.lineItemCurrItemPos // currentItemPositionSource
FPS.lineItemHighlighted // selectedItemsSource
No abbreviations: the full table name needs no registry.
3. Making one
Foundation Product Sales carries Forms/Fnd_IO_InputTemplate, a copy-from template whose help object lists these same steps; copy its objects into the new form, then delete the help object. Its "window size 450 x 300" step is historical: use automatic sizing with the margins below.
- Read the catalog first. Take the table's fields in order with their ids and types, its N-to-1 relations (they become parent fields) and its 1-to-N relations (they become child listboxes). Every
dataSourcetoken is[Table:tableId]Field:fieldId; a wrong id binds silently to the wrong thing, so derive them, never guess. - Write
form.4DForm. Three settings are load-bearing:"inheritedForm": "Fnd_IO_InputForm"; all eight events (onLoad,onValidate,onActivate,onCloseBox,onOutsideCall,onClick,onDataChange,onResize);formSizeAnchoron the tallest object, with a 20-pixel right margin and a 60-pixel bottom margin so the inherited Save button clears.pages[0]is the background layer drawn on every page, not a page: a form whose only entry ispages[0]crashes 4D on open with no message, and a compile check cannot see it. Every form needspages[1]; put a tabless form's content there and leavepages[0]empty. - Lay out the record's own fields, one label and one field per row. Name objects
Table-Fieldwith a hyphen (Invoices-InvoiceNumber,Label-InvoiceNumber); the template'sFnd_IO_Field1…nnames are a starting point, nothing depends on them. Hyphens are fine; full stops are not (object notation reads them as property access). Labels use 4D's reference syntax (<?3>:is field 3 of the current table,<?[4]>:is table 4's name) so they follow renames. A single-line input at the form font is 17 pixels high, not the 21 the editor draws.IDis shown, non-enterable: these are developer forms. - A parent field per foreign key (section 4).
- A tab control with one listbox per child table, every column non-enterable (section 5).
- The form method (below) and one object method per parent field and per listbox.
- Compile-check, then open it. The compiler validates code, never form JSON; a malformed form compiles clean and fails on open. Say so when you report.
The form method
var $formEvent_o : Object:=FORM Event
Case of
: ($formEvent_o.code=On Load)
SET WINDOW TITLE(Gen_WindowTitle)
Fnd_Menu_Window_Add // Update the Window menu
RELATE ONE([Invoices]Contact_ID) // One per foreign key: loads the parent display field
FPS.lineItemSelection:=ds.LineItems.query("invoice.Invoice_ID = :1"; [Invoices]Invoice_ID).orderBy("LineItem_ID asc")
End case
Fnd_IO_InputFormMethod
Fnd_IO_InputFormMethod must be called; where it sits does not matter. A Case of on the event code is right here because the form subscribes to eight events. Load every child selection at On Load: entity selections are lazy and the listbox pages through them, so an unbounded child costs nothing. Per-table setup that must happen before the window opens (window type, title, nav buttons) goes in the Case of on the table pointer in Fnd_Hook_IO_DisplayRecord, as FPS does for [CompanyInfo] and [Invoices].
Window titles come from Gen_WindowTitle (in HooksAndHosts/), never from a hand-built string: it reads the current table and record, says "Invoices: New Record" for a new one, and clips long titles. Copy it and Gen_GetFieldPtr_Name into your project; the commented branches show the per-table override.
4. Navigating up: the parent as a display field
Do not show the raw foreign key. Bind the field to the parent's display field, [Contacts:4]Company:4 rather than [Invoices:6]Contact_ID:2, with enterable: false and focusable: false. The form method's RELATE ONE loads it. The object method is one line:
Gen_IO_DisplayParent(->[Contacts]; ->[Invoices]Contact_ID)
Call the wrapper, never Fnd_IO_DisplayRecord directly. Fnd_IO_DisplayRecord unloads the related record on return, so a direct call leaves the field blank the moment the user clicks it: the parent window opens correctly and the field empties behind it. The wrapper (in HooksAndHosts/) is RELATE ONE, display, RELATE ONE again, and exists because the two statements are only correct together.
One click or two buttons. A field carrying the method is enough where the parent is set elsewhere and only ever looked at. Where the user must also change or create the parent, the field carries no method and Display and Choose buttons sit beside it: one handler cannot serve three intentions. FPS's Invoices form shows the full set as View, Clear, Select and New Contact buttons, with SelectContact as the chooser form.
Read-only fields must not look alike. Inert (ID, a total): sunken, stroke #696969, fill transparent. Clickable (a parent field with no buttons): raised, stroke #000000. Raised is a button affordance, so raised only where the object has a method.
5. Navigating down: one listbox per child, on tabs
More than one child table goes on a tab control on page 0, each listbox on its own page. Page 0 objects appear on every page, so the record's own fields stay visible while the tabs swap the child list; a fourth child needs no redesign. The listbox and its tab control both take sizingX: grow, sizingY: grow, or the listbox leaves a gap on resize.
| Property | Value |
|---|---|
listboxType | collection |
headerHeight | 1em |
scrollbarHorizontal / scrollbarVertical | automatic |
alternateFill | #EDF3FE |
horizontalLineStroke / verticalLineStroke | transparent |
| column width | 120, adjusted to the data; differ by 50, never by 1 |
enterable | false on every column |
textAlign | center on numeric columns, header included; left on text |
Columns traverse the wired relations and are named for what they show: This.product.ProductName in a column called product, not Product_ID. Where a column needs composing, put an exposed Function on the entity class and bind This.lineText(); never assemble the string in the form and never parse one back.
Non-enterable is not cosmetic. A listbox bound to an entity selection writes through: an enterable This.product.ProductName would rename a Product from the Invoices form.
Bind currentItemSource, currentItemPositionSource and selectedItemsSource to the process-object properties of section 2; the double-click handler needs the position:
var $formEvent_o : Object:=FORM Event
Case of
: ($formEvent_o.code=On Double Clicked)
USE ENTITY SELECTION(FPS.lineItemSelection)
GOTO SELECTED RECORD([LineItems]; FPS.lineItemCurrItemPos)
Fnd_IO_DisplayRecord(->[LineItems])
End case
Three lines, and the two worlds meet once. Fnd_IO_DisplayRecord is right here: a child row has no parent display field to restore.
The self-join hazard. A child listbox on the same table as the form gets no double-click handler: USE ENTITY SELECTION plus GOTO SELECTED RECORD would move the current record of the table the open form is bound to and discard the user's unsaved keystrokes. List the rows and leave navigation out.
6. Validation before saving
Pre-save checks go in Fnd_Hook_IO_InputFormButton. The Save button belongs to the inherited form, so there is no object method of yours to hold them. The hook receives the button name in lower case ("save", "cancel", "first", …) and returns a Boolean; return False to stop the default action. Branch on Fnd_Gen_CurrentTable; on failure alert, GOTO OBJECT the offending field by the object name the Table-Field convention makes predictable, and return False.
Watch the empty test on a text field: If ([Contacts]EmailAddress=!00-00-00!) compiles cleanly and throws "A function was expected" at runtime. The test is ="". This hook sees the UI only; a rule that must hold however a record is written belongs where every path passes.
7. Building more than two
Generate them from a table-and-relation spec rather than by hand, then sweep every emitted form for the invariants a compile check cannot see:
pageshas at least two entries (section 3, step 2);- every object method file is named exactly after its object;
- table and field ids match the catalog; relation names behind each column
dataSourceresolve;enterable: falseon every column; eight events;inheritedFormset; tab-label count equals pages minus one; no duplicate object names; no orphan or missing object-method files.
Two of these cost a 4D crash each before they were written down.
Files in HooksAndHosts/
Gen_IO_DisplayParent.4dm | the parent display wrapper, required |
|---|---|
Gen_WindowTitle.4dm, Gen_GetFieldPtr_Name.4dm | window titles, with the per-table override commented |
Fnd_Hook_SqNo_SetIDField.4dm | tells Foundation a table's key field when it is not the first |
Gen_* are host-side project methods, not Foundation's API; rename the prefix to your project's. Copy them into Project/Sources/Methods/ and register them in folders.json.