Skip to main content
MUI Form & Table

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:

    • FormInstance for imperative mounting,
    • automatic QueryClientProvider, CLDR loading, and Studio theme setup,
    • a simplified select-from-table configuration 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:

GlobalPurpose
FormInstanceImperatively mounts a form into a DOM element.

Related convenience behavior:

  • JSX is supported in scripts.
  • Imports from @mui/material and its common subpaths are rewritten by the transpiler to getMuiComponent(...) calls automatically, so normal MUI import syntax works in scripts without any extra setup.
  • getMuiComponent is 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:

  1. It creates an internal <div> container inside the parent element that you pass in.
  2. It mounts the App Engine form wrapper into that container.
  3. It returns a Proxy object.
  4. 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 data wrapper object,
  • no query object,
  • 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(), and setIsError(),
  • 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 query object instead of data,
  • the instance exposes the React Query result surface such as data, error, isLoading, isInitialLoading, isFetching, isRefetching, isSuccess, errorUpdateCount, failureReason, and refetch(),
  • query.queryKey is the React Query key (ReadonlyArray<unknown>),
  • query.queryFn receives 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.sections
  • defaultValues
  • formId
  • resetOnDataChange
  • validationSchema
  • handleSubmit

Compared with the core form typings, App Engine intentionally narrows a few authoring points:

  • select-from-table uses the App Engine-specific simplified configuration described below.
  • autocomplete, template, and expandable-plain are not part of AppEngineField<T>.
  • text and number keep the normal behavior but do not expose inputComponent.
  • plain fields are not part of the typed AppEngineFormConfig<T> surface.
  • ui follows the normal section/builder shape except footerComponents, which is not part of AppEngineUIConfig.

The main difference is that the outer object passed to FormInstance.create() may additionally contain:

  • data for data-supplied forms, or
  • query for 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:

  1. If you provide validationSchema, the wrapper uses it.
  2. If you do not provide validationSchema, the wrapper builds an App Engine-specific yup schema by calling buildAppEngineYupSchema(...).

That means:

  • field-level validation arrays 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 from data.data or fetched query data,
  • methods: the active react-hook-formmethods object (UseFormReturn<T>)

Practical consequences:

  • If you need to call rect-hook-form' methods such as setValue, getValues, trigger, or clearErrors inside handleSubmit, use them from the FormInstancereturned by create() () for example form.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:

  • destroy
  • watch
  • getValues
  • setValue
  • trigger
  • clearErrors
  • reset
  • handleSubmit
  • control
  • formState

Additional API by mode

ModeExtra runtime members
Empty formNo wrapper-specific extras beyond the base form API.
Data-supplied formisLoading, isError, setIsLoading(loading), setIsError(error)
Data-fetching formReact 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 MaeTable instance,
  • 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:

  • selectionMode defaults to 'single' if you omit it.
  • selectionLimit (optional) caps how many rows can be selected. The wrapper reconciles conflicting values between selectionMode and selectionLimit internally (for example, selectionLimit: 1 is normalized to single-selection semantics).
  • initialSelection becomes the starting selection of the embedded table.
  • sx is forwarded to the embedded table, so it styles the table inside the selection dialog.
  • configuration.data.keyField is required and becomes the actual table key field.
  • configuration.data.data is the full row list used by the modal table.
  • the App Engine wrapper injects the main selector button icon itself, so treat the default icon as runtime-controlled.
  • other high-level field options such as syncSelectionToField, dialogTitle, dialogIcon, displayAttribute, tooltip, startIcon, icon, and onChange still 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:

  1. use normal MUI imports,
  2. write JSX normally,
  3. stay within the typed AppEngineField<T> surface unless you intentionally want runtime-only behavior,
  4. 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

  1. create() guarantees synchronous mount.
  2. Use FormInstance.create(...), not new FormInstance(...). The runtime is designed around the static factory.
  3. useFormContext() is only valid inside React components rendered by the form. (not usable in 17.2)
  4. select-from-table uses the App Engine-specific simplified configuration shape. Do not copy the low-level core-library table config directly from non-App-Engine examples.
  5. The embedded table ref is keyed by form field name. Use form.tables?.fieldName, not form.tables?.configurationId.
  6. plain is not part of the typed App Engine field surface. If you force it in, you are outside AppEngineFormConfig<T> and should use the core form README as the reference instead.
  7. destroy() exists at runtime and should be called when the mounted form is no longer needed.
Updated on Aug 11, 2026