Foundation 6

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.

FileHolds
TableForms/6/Input/form.4DFormthe form, JSON
TableForms/6/Input/method.4dmthe form method, a thin shell
TableForms/6/Input/ObjectMethods/<objectName>.4dmone 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.

  1. 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 dataSource token is [Table:tableId]Field:fieldId; a wrong id binds silently to the wrong thing, so derive them, never guess.
  2. Write form.4DForm. Three settings are load-bearing: "inheritedForm": "Fnd_IO_InputForm"; all eight events (onLoad, onValidate, onActivate, onCloseBox, onOutsideCall, onClick, onDataChange, onResize); formSizeAnchor on 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 is pages[0] crashes 4D on open with no message, and a compile check cannot see it. Every form needs pages[1]; put a tabless form's content there and leave pages[0] empty.
  3. Lay out the record's own fields, one label and one field per row. Name objects Table-Field with a hyphen (Invoices-InvoiceNumber, Label-InvoiceNumber); the template's Fnd_IO_Field1…n names 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. ID is shown, non-enterable: these are developer forms.
  4. A parent field per foreign key (section 4).
  5. A tab control with one listbox per child table, every column non-enterable (section 5).
  6. The form method (below) and one object method per parent field and per listbox.
  7. 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.

PropertyValue
listboxTypecollection
headerHeight1em
scrollbarHorizontal / scrollbarVerticalautomatic
alternateFill#EDF3FE
horizontalLineStroke / verticalLineStroketransparent
column width120, adjusted to the data; differ by 50, never by 1
enterablefalse on every column
textAligncenter 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:

Two of these cost a 4D crash each before they were written down.

Files in HooksAndHosts/

Gen_IO_DisplayParent.4dmthe parent display wrapper, required
Gen_WindowTitle.4dm, Gen_GetFieldPtr_Name.4dmwindow titles, with the per-table override commented
Fnd_Hook_SqNo_SetIDField.4dmtells 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.