Form configuration in AppEngine
Mount forms with FormInstance.create(), choose the right form mode, and use the App Engine-specific select-from-table shape.
POSTED: | UPDATED: | by Stefan Schüttenkopf
Overview
The App Engine runtime exposes a script-friendly wrapper around the normal MVC form stack.
At runtime:
-
you mount forms with
FormInstance.create(parentElement, config), -
the wrapper renders
MvcForm, -
the form still uses the same field and section definitions,
-
but App Engine adds a few runtime conveniences:
FormInstancefor imperative mounting,- automatic
QueryClientProvider, CLDR loading, and Studio theme setup, - a simplified
select-from-tableconfiguration shape for scripts, - and a narrower typed field surface than the core form library.
If you already know the normal form README, the main difference here is not the field model itself, but how the form is created, what globals are injected, and which wrapper-specific shortcuts and limitations exist in App Engine scripts.
Runtime globals available to scripts
The injected typings expose one wrapper-specific symbol for form authoring:
| Global | Purpose |
|---|---|
FormInstance | Imperatively mounts a form into a DOM element. |
Related convenience behavior:
- JSX is supported in scripts.
- Imports from
@mui/materialand its common subpaths are rewritten by the transpiler togetMuiComponent(...)calls automatically, so normal MUI import syntax works in scripts without any extra setup. getMuiComponentis an internal transpiler helper. Script authors should never call it directly; always use standard MUI import statements instead.- The Monaco editor injects type definitions for the App Engine form wrapper.
Lifecycle of FormInstance
The only supported entry point for scripts is the static create() method.
type FormValues = {
name: string;
isActive: boolean;
};
const host = document.getElementById('form-root') as HTMLElement;
const form = FormInstance.create<FormValues>(host, {
config: {
sections: [
{
section: 'General',
fields: [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'isActive', type: 'checkbox', label: 'Active' }
]
}
]
}
});
What create() does:
- It creates an internal
<div>container inside the parent element that you pass in. - It mounts the App Engine form wrapper into that container.
- It returns a
Proxyobject. - Property access and method calls on that proxy are forwarded to the mounted form instance once the ref is ready.
Important lifecycle notes:
create()mounts the wrapper synchronously before returning.- Calls on the returned instance can be made immediately after
create().
The runtime instance also has:
form.destroy();
destroy() unmounts the React root and removes the internal container from the DOM. It is part of the public runtime surface returned by create().
When you create multiple forms and/or tables before opening a sidebar panel, create them first and then register the panel:
const headerForm = FormInstance.create<HeaderValues>(headerRoot, headerConfig);
const filtersForm = FormInstance.create<FilterValues>(filtersRoot, filtersConfig);
const table = TableInstance.create<Row, 'id'>(tableRoot, tableConfig);
window.map.stores.sidebarMenu.addMenuItem(
{
id: 'example-panel',
icon: { tooltip: 'Example' },
panel: {
domContent: panelRoot,
title: 'Example'
}
},
true
);
Choosing the form mode
FormInstance.create() accepts AppEngineFormProps<T>, which is a union of three shapes.
Type-level mapping used by the App Engine API:
type AppEngineForm<T> = AppEngineEmptyForm<T> | AppEngineDataSuppliedForm<T> | AppEngineDataFetchingForm<T>;
Empty form
Use this when the form starts from defaults only and does not need wrapper-managed loading or fetching state.
const form = FormInstance.create<FormValues>(host, {
query: {
queryKey: ['users', '42'],
queryFn: async ({ signal }) => {
return await loadUser('42', signal);
},
enabled: true,
refetchOnWindowFocus: false
},
config
});
Characteristics:
- no
datawrapper object, - no
queryobject, - instance behaves like the base
MaeForm<T>/UseFormReturn<T>surface, - best option for purely local forms and wizard-like input screens.
Data-supplied form
Use this when you already have the data and want App Engine to expose wrapper-managed loading and error flags on the instance.
const form = FormInstance.create<FormValues>(host, {
data: {
data: {
name: 'Initial value',
isActive: true
},
isLoading: false,
isError: false,
refetch: async () => {
await reloadData();
}
},
config,
resetOnDataChange: true
});
Characteristics:
- you pass the entity inside
data.data, - the instance exposes
isLoading,isError,setIsLoading(), andsetIsError(), - useful when the surrounding script or panel already owns the fetching lifecycle,
- good for edit forms that are fed by some other runtime service.
Data-fetching form
Use this when the form itself should fetch the entity by using the wrapper's React Query integration.
const form = FormInstance.create<FormValues>(host, {
query: {
queryKey: ['users', '42'],
queryFn: async () => {
return await loadUser('42');
},
enabled: true,
refetchOnWindowFocus: false
},
config
});
Characteristics:
- you pass a
queryobject instead ofdata, - the instance exposes the React Query result surface such as
data,error,isLoading,isInitialLoading,isFetching,isRefetching,isSuccess,errorUpdateCount,failureReason, andrefetch(), query.queryKeyis the React Query key (ReadonlyArray<unknown>),query.queryFnreceives React Query context (queryKey,signal) so requests can be cancelled cleanly,- best option when the form should own its own fetch lifecycle.
Base configuration shape
Regardless of mode, the core form description is still the same FormConfig<T> and FormProps<T> model used by the normal form library.
That means the following still applies in App Engine scripts:
config.sectionsdefaultValuesformIdresetOnDataChangevalidationSchemahandleSubmit
Compared with the core form typings, App Engine intentionally narrows a few authoring points:
select-from-tableuses the App Engine-specific simplified configuration described below.autocomplete,template, andexpandable-plainare not part ofAppEngineField<T>.textandnumberkeep the normal behavior but do not exposeinputComponent.plainfields are not part of the typedAppEngineFormConfig<T>surface.uifollows the normal section/builder shape exceptfooterComponents, which is not part ofAppEngineUIConfig.
The main difference is that the outer object passed to FormInstance.create() may additionally contain:
datafor data-supplied forms, orqueryfor data-fetching forms.
For the low-level field definitions, section layout, UI config, and normal form ref behavior, use Form configuration in code as the reference and treat this document as the App Engine wrapper guide.
Validation and submission
Validation works the same way as in the normal form runtime:
- If you provide
validationSchema, the wrapper uses it. - If you do not provide
validationSchema, the wrapper builds an App Engine-specific yup schema by callingbuildAppEngineYupSchema(...).
That means:
- field-level
validationarrays are enough for most script forms, - custom
validationSchema(initialValues)is the escape hatch when validation logic depends on initial state or when you need a schema that goes beyond field-level rules.
Submission is still provided through handleSubmit.
const form = FormInstance.create<FormValues>(host, {
config,
handleSubmit: async (values, originalValues, methods) => {
await methods.trigger();
await saveUser(values, originalValues);
}
});
The callback receives:
values: the submitted form data,originalValues: the original object fromdata.dataor fetched query data,methods: the activereact-hook-formmethods object (UseFormReturn<T>)
Practical consequences:
- If you need to call
rect-hook-form'methods such assetValue,getValues,trigger, orclearErrorsinsidehandleSubmit, use them from theFormInstancereturned bycreate()() for exampleform.setValue(…)) - If you need to compare against the initial entity, use
originalValues. - In empty forms, the original values are the wrapper's base form data object, so design your submit logic accordingly.
Instance API available after create()
The instance shape depends on the form mode, but the base imperative surface always starts with the normal form API.
Common API
All modes expose the normal form API: the react-hook-form methods/state from UseFormReturn<T>, plus destroy(). For example:
destroywatchgetValuessetValuetriggerclearErrorsresethandleSubmitcontrolformState
Additional API by mode
| Mode | Extra runtime members |
|---|---|
| Empty form | No wrapper-specific extras beyond the base form API. |
| Data-supplied form | isLoading, isError, setIsLoading(loading), setIsError(error) |
| Data-fetching form | React Query result members such as data, error, isLoading, isInitialLoading, isFetching, isRefetching, isSuccess, errorUpdateCount, failureReason, status, fetchStatus, refetch() |
Nested table refs
If the form contains one or more select-from-table fields, the form instance can also expose:
form.tables?.[fieldName]
Important details:
- the table key is the form field name,
- not the embedded table
configuration.id, - the stored value is the inner
MaeTableinstance, - and it becomes useful only after that inner table has mounted.
Example:
const ownersTable = form.tables?.ownerIds;
ownersTable?.setQuickSearch('Operations');
ownersTable?.resetFilter();
ownersTable?.selectAll();
select-from-table in App Engine scripts
This is the biggest App Engine-specific form feature.
In the normal form library, select-from-table expects a low-level table configuration based on TableContextDefinition.
In App Engine scripts, the wrapper intentionally accepts a simplified shape:
{
name: 'ownerIds',
type: 'select-from-table',
label: 'Owners',
displayAttribute: 'name',
dialogTitle: 'Select owners',
configuration: {
id: 'owners',
columns: [
{ field: 'name', headerName: 'Name' },
{ field: 'department', headerName: 'Department' }
],
selectionMode: 'multiple',
selectionLimit: 3,
initialSelection: ['u-1'],
sx: { maxHeight: 480 },
data: {
keyField: 'id',
data: [
{ id: 'u-1', name: 'Alice', department: 'Operations' },
{ id: 'u-2', name: 'Bob', department: 'Planning' }
]
}
}
}
The wrapper converts that simplified object into a real internal table by:
- creating an in-memory selectable data source with
useSelectableData(...), - wiring
keyField,rawData, selection handling, filtering, sorting, and item helpers, - injecting built-in quick search, filter, and settings controls.
Behavior details worth knowing:
selectionModedefaults to'single'if you omit it.selectionLimit(optional) caps how many rows can be selected. The wrapper reconciles conflicting values betweenselectionModeandselectionLimitinternally (for example,selectionLimit: 1is normalized to single-selection semantics).initialSelectionbecomes the starting selection of the embedded table.sxis forwarded to the embedded table, so it styles the table inside the selection dialog.configuration.data.keyFieldis required and becomes the actual table key field.configuration.data.datais the full row list used by the modal table.- the App Engine wrapper injects the main selector button icon itself, so treat the default
iconas runtime-controlled. - other high-level field options such as
syncSelectionToField,dialogTitle,dialogIcon,displayAttribute,tooltip,startIcon,icon, andonChangestill work.
Custom React content with plain fields
PlainReactField exists in the core form library, but it is not part of the typed AppEngineFormConfig<T> surface.
If you force plain fields into App Engine scripts anyway, you are outside the documented App Engine type surface and Monaco typings will not help you there. Treat the core form README as the reference for that runtime-only path.
Using MUI and React in scripts (not supported in 17.2)
For script authors, the practical rules are:
- JSX is supported.
- Normal MUI imports work.
Example:
import { Box, Button, Typography } from '@mui/material';
You do not need to manually call any MUI runtime loader.
For most scripts, the best approach is:
- use normal MUI imports,
- write JSX normally,
- stay within the typed
AppEngineField<T>surface unless you intentionally want runtime-only behavior, - use
FormInstance.create()for the top-level mount.
End-to-end examples
1. Minimal local form
type UserFormValues = {
name: string;
isActive: boolean;
};
const host = document.getElementById('form-root') as HTMLElement;
const form = FormInstance.create<UserFormValues>(host, {
defaultValues: {
isActive: true
},
config: {
sections: [
{
section: 'User',
fields: [
{
name: 'name',
type: 'text',
label: 'Name',
validation: [{ required: true, message: 'Name is required' }]
},
{
name: 'isActive',
type: 'checkbox',
label: 'Active'
}
]
}
]
},
handleSubmit: async (values) => {
console.log('Submitted values', values);
}
});
2. Data-supplied edit form
type UserFormValues = {
id: string;
name: string;
role: 'admin' | 'editor';
};
const form = FormInstance.create<UserFormValues>(host, {
data: {
data: {
id: '42',
name: 'Alice',
role: 'admin'
},
isLoading: false,
isError: false,
refetch: async () => {
console.log('Reloading source data');
}
},
resetOnDataChange: true,
config: {
sections: [
{
section: 'User',
fields: [
{ name: 'name', type: 'text', label: 'Name' },
{
name: 'role',
type: 'select',
label: 'Role',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' }
]
}
]
}
]
},
handleSubmit: async (values, originalValues, methods) => {
if (values.role !== originalValues.role) {
await methods.trigger('role');
}
console.log('Saving', values);
}
});
form.setIsLoading(true);
form.setIsLoading(false);
3. Query-driven form
type UserFormValues = {
name: string;
email: string;
};
const form = FormInstance.create<UserFormValues>(host, {
query: {
queryKey: ['user', '42'],
queryFn: async ({ signal }) => {
return await loadUser('42', signal);
},
enabled: true,
refetchOnWindowFocus: false
},
config: {
sections: [
{
section: 'Profile',
fields: [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'email', type: 'text', label: 'Email' }
]
}
]
}
});
await form.refetch();
4. Form with select-from-table
type UserFormValues = {
ownerId: string | null;
ownerName: string | null;
};
const form = FormInstance.create<UserFormValues>(host, {
config: {
sections: [
{
section: 'Ownership',
fields: [
{
name: 'ownerId',
type: 'select-from-table',
label: 'Owner',
displayAttribute: 'name',
dialogTitle: 'Select owner',
syncSelectionToField: {
field: 'ownerName',
attribute: 'name'
},
configuration: {
id: 'owners',
columns: [
{ field: 'name', headerName: 'Name' },
{ field: 'department', headerName: 'Department' }
],
data: {
keyField: 'id',
data: [
{ id: 'u-1', name: 'Alice', department: 'Operations' },
{ id: 'u-2', name: 'Bob', department: 'Planning' }
]
}
}
},
{
name: 'ownerName',
type: 'text',
label: 'Owner name',
disabled: true
}
]
}
]
}
});
Important constraints and gotchas
create()guarantees synchronous mount.- Use
FormInstance.create(...), notnew FormInstance(...). The runtime is designed around the static factory. useFormContext()is only valid inside React components rendered by the form. (not usable in 17.2)select-from-tableuses the App Engine-specific simplified configuration shape. Do not copy the low-level core-library table config directly from non-App-Engine examples.- The embedded table ref is keyed by form field name. Use
form.tables?.fieldName, notform.tables?.configurationId. plainis not part of the typed App Engine field surface. If you force it in, you are outsideAppEngineFormConfig<T>and should use the core form README as the reference instead.destroy()exists at runtime and should be called when the mounted form is no longer needed.