diff --git a/package-lock.json b/package-lock.json index 29f9271f..4fe808fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4671,6 +4671,34 @@ "esbuild-windows-arm64": "0.13.15" } }, + "node_modules/esbuild-android-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz", + "integrity": "sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/esbuild-darwin-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz", + "integrity": "sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/esbuild-darwin-arm64": { "version": "0.13.15", "cpu": [ @@ -4683,6 +4711,202 @@ "darwin" ] }, + "node_modules/esbuild-freebsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz", + "integrity": "sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz", + "integrity": "sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/esbuild-linux-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz", + "integrity": "sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz", + "integrity": "sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz", + "integrity": "sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz", + "integrity": "sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz", + "integrity": "sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz", + "integrity": "sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz", + "integrity": "sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ] + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz", + "integrity": "sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/esbuild-sunos-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz", + "integrity": "sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/esbuild-windows-32": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz", + "integrity": "sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz", + "integrity": "sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.13.15", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz", + "integrity": "sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/escalade": { "version": "3.1.1", "dev": true, diff --git a/src/components/Form/Cell.tsx b/src/components/Form/Cell.tsx new file mode 100644 index 00000000..b2b7b213 --- /dev/null +++ b/src/components/Form/Cell.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useFieldValidation } from "./form/useFieldValidation"; +import { useFormInstance } from "./form/useFormInstance"; +import { useFormUiState } from "./form/useFormUiState"; +import { FormCellContext } from "./FormCellContext"; + +export interface IFormCellProps { + id?: string; + controlId?: string; + datafieldname?: string; + label?: React.ReactNode; + showLabel?: boolean; + required?: boolean; + disabled?: boolean; + visible?: boolean; + className?: string; + children?: React.ReactNode; +} + +export const Cell: React.FC = ({ + id, + controlId, + datafieldname, + label, + showLabel = true, + required, + disabled, + visible = true, + className, + children, +}) => { + const form = useFormInstance(); + useFormUiState(); + + if (visible === false) { + return null; + } + + if (controlId && form.getControlVisible(controlId) === false) { + return null; + } + + const labelOverride = controlId ? form.getControlLabel(controlId) : undefined; + const resolvedLabel = label + ?? labelOverride + ?? (datafieldname ? form.getFieldLabel(datafieldname) : undefined); + + const disabledOverride = controlId ? form.getControlDisabled(controlId) : undefined; + const resolvedDisabled = disabled !== undefined + ? disabled + : disabledOverride !== undefined + ? disabledOverride + : undefined; + + let resolvedRequired = required ?? false; + if (required === undefined && datafieldname) { + try { + const override = controlId ? form.getRequiredLevelOverride(datafieldname) : undefined; + if (override !== undefined) { + resolvedRequired = override === "required"; + } else { + resolvedRequired = form.getAttributeConfiguration(datafieldname).requiredLevel === "required"; + } + } catch { + resolvedRequired = false; + } + } + + const renderedChildren = injectDisabled(children, resolvedDisabled); + + return ( + +
+ {showLabel && resolvedLabel ? ( + + ) : null} + {renderedChildren} + {datafieldname ? ( + + ) : null} +
+
+ ); +}; + +const CellValidationMessage: React.FC<{ datafieldname: string; controlId?: string }> = ({ datafieldname, controlId }) => { + const { result: validation } = useFieldValidation(datafieldname); + + return validation.error ? ( +
+ {validation.errorMessage} +
+ ) : null; +}; + +const injectDisabled = (children: React.ReactNode, disabled: boolean | undefined): React.ReactNode => { + if (disabled === undefined) { + return children; + } + + return React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + if ((child.props as { disabled?: boolean }).disabled !== undefined) { + return child; + } + + return React.cloneElement(child as React.ReactElement, { disabled }); + }); +}; diff --git a/src/components/Form/Column.tsx b/src/components/Form/Column.tsx new file mode 100644 index 00000000..4be33768 --- /dev/null +++ b/src/components/Form/Column.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +export interface IFormColumnProps { + width?: React.CSSProperties["width"]; + columnIndex?: number; + className?: string; + children?: React.ReactNode; +} + +export const Column: React.FC = ({ width, columnIndex, className, children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Form/Control.tsx b/src/components/Form/Control.tsx new file mode 100644 index 00000000..01a60591 --- /dev/null +++ b/src/components/Form/Control.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { UnsupportedControlError } from "./form/errors/UnsupportedControlError"; +import { FieldInput } from "./form/parts/FieldInput"; +import { isStandardControlClassId } from "./form/parts/standardControlClassIds"; +import { FormCellContext } from "./FormCellContext"; + +export interface IFormControlProps { + classid: string; + datafieldname?: string; + controlId?: string; + disabled?: boolean; + cellId?: string; +} + +export const Control: React.FC = ({ + classid, + datafieldname, + controlId, + disabled, + cellId, +}) => { + const cell = React.useContext(FormCellContext); + const resolvedDatafieldname = datafieldname ?? cell.datafieldname ?? ""; + const resolvedControlId = controlId ?? cell.controlId; + const resolvedDisabled = disabled ?? cell.disabled; + + if (!isStandardControlClassId(classid)) { + throw new UnsupportedControlError({ + cellId, + classId: classid, + controlName: resolvedControlId, + }); + } + + if (!resolvedDatafieldname) { + throw new UnsupportedControlError({ + cellId, + classId: classid, + controlName: resolvedControlId, + }); + } + + return ( + + ); +}; diff --git a/src/components/Form/FormCellContext.ts b/src/components/Form/FormCellContext.ts new file mode 100644 index 00000000..3869c5bd --- /dev/null +++ b/src/components/Form/FormCellContext.ts @@ -0,0 +1,9 @@ +import * as React from "react"; + +export interface IFormCellContextValue { + datafieldname?: string; + controlId?: string; + disabled?: boolean; +} + +export const FormCellContext = React.createContext({}); diff --git a/src/components/Form/FormLayoutContext.ts b/src/components/Form/FormLayoutContext.ts new file mode 100644 index 00000000..497f808b --- /dev/null +++ b/src/components/Form/FormLayoutContext.ts @@ -0,0 +1,7 @@ +import * as React from "react"; + +export interface IFormLayoutContextValue { + tabName?: string; +} + +export const FormLayoutContext = React.createContext({}); diff --git a/src/components/Form/Row.tsx b/src/components/Form/Row.tsx new file mode 100644 index 00000000..414a6270 --- /dev/null +++ b/src/components/Form/Row.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +export interface IFormRowProps { + className?: string; + id?: string; + children?: React.ReactNode; +} + +export const Row: React.FC = ({ className, id, children }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Form/Section.tsx b/src/components/Form/Section.tsx new file mode 100644 index 00000000..42b63b18 --- /dev/null +++ b/src/components/Form/Section.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { FormLayoutContext } from "./FormLayoutContext"; +import { useFormInstance } from "./form/useFormInstance"; +import { useFormUiState } from "./form/useFormUiState"; + +export interface IFormSectionProps { + id?: string; + name?: string; + label?: React.ReactNode; + tabName?: string; + showLabel?: boolean; + visible?: boolean; + className?: string; + children?: React.ReactNode; +} + +export const Section: React.FC = ({ + id, + name, + label, + tabName, + showLabel = true, + visible = true, + className, + children, +}) => { + const form = useFormInstance(); + const layout = React.useContext(FormLayoutContext); + useFormUiState(); + + if (visible === false) { + return null; + } + + const resolvedTabName = tabName ?? layout.tabName ?? ""; + if (resolvedTabName && name && form.getSectionVisible(resolvedTabName, name) === false) { + return null; + } + + return ( +
+ {showLabel && label && ( +

{label}

+ )} + {children} +
+ ); +}; diff --git a/src/components/Form/Tab.tsx b/src/components/Form/Tab.tsx new file mode 100644 index 00000000..c8643a1d --- /dev/null +++ b/src/components/Form/Tab.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { FormLayoutContext } from "./FormLayoutContext"; +import { useFormInstance } from "./form/useFormInstance"; +import { useFormUiState } from "./form/useFormUiState"; + +export interface IFormTabProps { + id?: string; + name?: string; + label?: React.ReactNode; + showLabel?: boolean; + visible?: boolean; + children?: React.ReactNode; +} + +export const Tab: React.FC = ({ + id, + name, + label, + showLabel = true, + visible = true, + children, +}) => { + const form = useFormInstance(); + useFormUiState(); + + if (visible === false) { + return null; + } + + if (name && form.getTabVisible(name) === false) { + return null; + } + + return ( + +
+ {showLabel && label && ( +

{label}

+ )} + {children} +
+
+ ); +}; diff --git a/src/components/Form/form/Form.tsx b/src/components/Form/form/Form.tsx new file mode 100644 index 00000000..99b1fcae --- /dev/null +++ b/src/components/Form/form/Form.tsx @@ -0,0 +1,85 @@ +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { getClassNames } from "@talxis/react-components"; +import { useControl } from "../../../hooks"; +import { IForm } from "../interfaces"; +import { formTranslations } from "../translations"; +import { FormContext } from "./FormContext"; +import { FormModel } from "./FormModel"; +import { FormTab } from "./parts/FormTab"; + +const buildFormInstance = (onGetProps: () => IForm, labels: any, theme: any, metadataProvider?: any): FormModel => { + return new FormModel({ + labels, + onGetProps, + theme, + metadataProvider, + }); +}; + +export const Form = (props: IForm) => { + const { labels, theme, className } = useControl('Form', props, formTranslations); + + const propsRef = useRef(props); + propsRef.current = props; + + const form = useMemo(() => { + return buildFormInstance(() => propsRef.current, labels, theme, props.metadataProvider); + }, []); + + // Assign synchronously during render — `form` is stable so this is safe + // and guarantees the ref is populated before any child or sibling effect runs, + // including effects inside dynamically-compiled codeful snippets. + if (props.formInstanceRef) { + props.formInstanceRef.current = form; + } + + // Run after every render so the model stays in sync with the latest record + // from props. useLayoutEffect ensures this runs before sibling effects read + // dirty state (e.g. DirtyBadge). + useLayoutEffect(() => { + form.syncRecordBinding(); + }); + + // Fire OnLoad once on mount, then Loaded after OnLoad resolves. + useEffect(() => { + let cancelled = false; + (async () => { + await form.fireOnLoad(); + if (!cancelled) { + await form.fireLoaded(); + } + })().catch((err) => { + console.error('[Form] OnLoad/Loaded dispatch error:', err); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clean up the ref on unmount. + useEffect(() => { + return () => { + if (props.formInstanceRef && props.formInstanceRef.current === form) { + props.formInstanceRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + return () => { + form.destroy(); + }; + }, []); + + const hasFormXml = !!props.parameters.FormXml?.raw; + const hasChildren = props.children !== undefined && props.children !== null; + + return ( + +
+ {hasChildren && props.children} + {!hasChildren && hasFormXml && } +
+
+ ); +}; diff --git a/src/components/Form/form/FormContext.ts b/src/components/Form/form/FormContext.ts new file mode 100644 index 00000000..397d5438 --- /dev/null +++ b/src/components/Form/form/FormContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { FormModel } from "./FormModel"; + +export const FormContext = createContext(null as any); diff --git a/src/components/Form/form/FormModel.ts b/src/components/Form/form/FormModel.ts new file mode 100644 index 00000000..cc38cfb9 --- /dev/null +++ b/src/components/Form/form/FormModel.ts @@ -0,0 +1,1171 @@ +import { IRecord } from "@talxis/client-libraries"; +import { + AttributeTypeEnum, + FormXml, + FormXmlLabels, + FormXmlTab, + IEntityDefinition, + IMetadataProvider, + Option, + OptionSetDefinition, + RequiredLevelEnum, + parseFormXml, + serializeFormXml, +} from "@talxis/client-metadata"; +import { ITheme } from "@talxis/react-components"; +import { getTheme } from "@fluentui/react"; +import { ITranslation } from "../../../hooks"; +import { formTranslations } from "../translations"; +import { IForm, IFormOutputs, IFormParameters, IAttributeConfiguration, AttributeRequiredLevel, IAttributeOption, IFieldValidationResult, FieldValidator, VALID_RESULT } from "../interfaces"; +import { XrmFormContext } from "./xrm/XrmFormContext"; +import { XrmExecutionContext } from "./xrm/XrmExecutionContext"; +import { XrmOnLoadEventArgs, XrmOnSaveEventArgs } from "./xrm/XrmEventArgs"; + +const HANDLER_TIMEOUT_MS = 10_000; + +interface IFormDependencies { + labels: Required>; + onGetProps: () => IForm; + theme?: ITheme; + metadataProvider?: IMetadataProvider; + scriptLoader?: IScriptLoader; +} + +interface IAttributeMetadataOverride { + requiredLevel?: Xrm.Attributes.RequirementLevel; + addedOptions?: IAttributeOption[]; + removedOptionValues?: Set; +} + +/** + * Seam for loading and resolving FormXml `formLibraries` / web-resource scripts. + * The default implementation is a no-op that never resolves any functions. + */ +export interface IScriptLoader { + /** + * Load (or ensure already loaded) the web-resource script identified by `libraryName`. + * Implementations should resolve once the script is ready to use. + */ + load(libraryName: string): Promise; + + /** + * Resolve a handler function by its library and dot-notation function path. + * Returns the function if found, or `null` if not available. + * Example: resolve("myapp_/scripts/handler.js", "MyNamespace.handleOnLoad") + */ + resolve(libraryName: string, functionName: string): ((...args: any[]) => any) | null; +} + +const NOOP_SCRIPT_LOADER: IScriptLoader = { + load: async () => { /* no-op stub */ }, + resolve: () => null, +}; + +export interface IRegisteredField { + name: string; +} + +export class FormModel { + private _getProps: () => IForm; + private _labels: Required>; + private _theme: ITheme; + private _metadataProvider?: IMetadataProvider; + private _scriptLoader: IScriptLoader; + + private _originalFormXml: FormXml | undefined; + private _workingFormXml: FormXml | undefined; + private _parsedFromRaw: string | null | undefined; + + private _resolvedEntity: IEntityDefinition | undefined; + private _entityResolutionPromise: Promise | null = null; + + private _registeredFields = new Map(); + private _fieldSubscribers = new Map void>>(); + private _formDirtySubscribers = new Set<() => void>(); + private _localDirty = false; + private _validators = new Map(); + private _validationResults = new Map(); + private _validationSubscribers = new Map void>>(); + private _formValidationSubscribers = new Set<() => void>(); + private _attachedRecord: IRecord | null = null; + + // Code-registered OnChange handlers (via XrmAttribute.addOnChange / removeOnChange) + private _codeOnChangeHandlers = new Map>(); + + // ------------------------------------------------------------------ + // UI state (Xrm visibility / disabled / label overrides applied + // directly to _workingFormXml; metadata overrides kept separately) + // ------------------------------------------------------------------ + private _metadataOverrides = new Map(); + private _uiStateSubscribers = new Set<() => void>(); + + // Lazily-created Xrm surface + private _formContext: Xrm.FormContext | null = null; + private _executionContext: Xrm.Events.EventContext | null = null; + + private _onFieldValueChanged = (columnName: string, _newValue: any) => { + this._localDirty = true; + this._notifyFormDirtySubscribers(); + this._emitOutputs(); + this._notifyFieldSubscribers(columnName); + if (this._validationResults.has(columnName) || this._validators.has(columnName)) { + this.validateField(columnName); + } + }; + + constructor({ onGetProps, labels, theme, metadataProvider, scriptLoader }: IFormDependencies) { + this._getProps = onGetProps; + this._labels = labels; + this._theme = theme ?? getTheme(); + this._metadataProvider = metadataProvider; + this._scriptLoader = scriptLoader ?? NOOP_SCRIPT_LOADER; + this._attachRecordListeners(); + } + + public getProps(): IForm { + return this._getProps(); + } + + public getRecord(): IRecord { + return this._getProps().parameters.Form; + } + + public getTheme(): ITheme { + return this._theme; + } + + public getLabels(): Required> { + return this._labels; + } + + public getFormXml(): FormXml | undefined { + const raw = this._getProps().parameters.FormXml?.raw; + if (raw == null || raw === "") { + this._originalFormXml = undefined; + this._workingFormXml = undefined; + this._parsedFromRaw = raw; + return undefined; + } + if (raw !== this._parsedFromRaw) { + this._originalFormXml = parseFormXml(raw); + this._workingFormXml = JSON.parse(JSON.stringify(this._originalFormXml)); + this._parsedFromRaw = raw; + } + return this._workingFormXml; + } + + public getOriginalFormXml(): FormXml | undefined { + // Ensure parsed + this.getFormXml(); + return this._originalFormXml; + } + + public getActiveTab(): FormXmlTab | undefined { + const formXml = this.getFormXml(); + return formXml?.tabs?.tab?.[0]; + } + + public getEntityDefinition(): IEntityDefinition | undefined { + const fromParams = this._getProps().parameters.EntityMetadata; + if (fromParams) { + return fromParams; + } + if (this._resolvedEntity) { + return this._resolvedEntity; + } + this._kickOffMetadataProviderResolution(); + return undefined; + } + + public isDialog(): boolean { + return this._getProps().parameters.IsDialog?.raw === true; + } + + /** + * Resolves the active language LCID for formXml `` lookup, in order: + * 1. `parameters.LanguageCode.raw` + * 2. `context.userSettings.languageId` + * 3. 1033 (en-US) + */ + public getLanguageCode(): number { + const explicit = this._getProps().parameters.LanguageCode?.raw; + if (typeof explicit === 'number' && !Number.isNaN(explicit) && explicit > 0) { + return explicit; + } + const fromContext = (this._getProps().context as any)?.userSettings?.languageId; + if (typeof fromContext === 'number' && !Number.isNaN(fromContext) && fromContext > 0) { + return fromContext; + } + return 1033; + } + + /** + * Pick a label from a formXml `` node, matching the active LCID. + * Falls back to the first available label, then to the provided fallback. + */ + public resolveLocalizedLabel(labels: FormXmlLabels | undefined, fallback: string): string { + const entries = labels?.label; + if (!entries || entries.length === 0) { + return fallback; + } + const lcid = this.getLanguageCode(); + const exact = entries.find((l) => l.languagecode === lcid); + if (exact?.description) { + return exact.description; + } + const anyWithText = entries.find((l) => !!l.description); + return anyWithText?.description ?? fallback; + } + + public getFieldLabel(datafieldname: string, control?: { labels?: FormXmlLabels }): string { + if (control?.labels) { + const fromXml = this.resolveLocalizedLabel(control.labels, ''); + if (fromXml) { + return fromXml; + } + } + const entity = this.getEntityDefinition(); + const attr = entity?.Attributes?.find((a) => a.LogicalName === datafieldname); + return attr?.DisplayName || datafieldname; + } + + public getValue(datafieldname: string): unknown { + const record = this.getRecord(); + if (!record) { + return undefined; + } + try { + return record.getValue(datafieldname); + } catch { + return undefined; + } + } + + public setValue(datafieldname: string, value: unknown, shouldFireOnChange: boolean = true): void { + const record = this.getRecord(); + if (!record) { + return; + } + record.setValue(datafieldname, value); + this._localDirty = true; + this._notifyFormDirtySubscribers(); + this._emitOutputs(); + this._notifyFieldSubscribers(datafieldname); + if (this._validationResults.has(datafieldname) || this._validators.has(datafieldname)) { + this.validateField(datafieldname); + } + if (shouldFireOnChange) { + this.fireOnChange(datafieldname).catch((err) => { + console.error(`[Form] fireOnChange("${datafieldname}") rejected unexpectedly:`, err); + }); + } + } + + /** + * Subscribe to value changes for a single attribute. The callback is invoked + * synchronously after the underlying record emits `onFieldValueChanged` for + * that column (or after a direct `setValue` on the model). Returns an + * unsubscribe function. + */ + public subscribeFieldValue(name: string, cb: () => void): () => void { + let set = this._fieldSubscribers.get(name); + if (!set) { + set = new Set(); + this._fieldSubscribers.set(name, set); + } + set.add(cb); + return () => { + const s = this._fieldSubscribers.get(name); + if (!s) return; + s.delete(cb); + if (s.size === 0) { + this._fieldSubscribers.delete(name); + } + }; + } + + private _notifyFieldSubscribers(name: string): void { + const set = this._fieldSubscribers.get(name); + if (!set) return; + set.forEach((cb) => { + try { cb(); } catch { /* swallow subscriber errors */ } + }); + } + + public register(field: IRegisteredField): void { + this._registeredFields.set(field.name, field); + } + + public unregister(name: string): void { + this._registeredFields.delete(name); + } + + public getRegisteredFields(): IRegisteredField[] { + return Array.from(this._registeredFields.values()); + } + + public getAttributeConfiguration(name: string): IAttributeConfiguration { + const entity = this.getEntityDefinition(); + if (!entity) { + throw new Error( + `[Form] Cannot resolve attribute configuration for "${name}" — no IEntityDefinition is available. ` + + `Provide parameters.EntityMetadata or wire a metadata provider on the Form props ` + + `(use DynamicEntityDefinition / DynamicAttributesMetadataProvider for dialog / unbound scenarios).` + ); + } + const attr = entity.Attributes?.find((a) => a.LogicalName === name); + const base: IAttributeConfiguration = attr + ? { + requiredLevel: mapRequiredLevel(attr.RequiredLevel), + options: mapOptions(attr.OptionSet), + format: attr.Format, + maxLength: attr.MaxLength, + minValue: attr.MinValue, + maxValue: attr.MaxValue, + targets: attr.Targets, + } + : { requiredLevel: 'none' }; + + // Apply runtime metadata overrides + const meta = this._metadataOverrides.get(name); + if (meta) { + if (meta.requiredLevel !== undefined) { + base.requiredLevel = meta.requiredLevel; + } + if (meta.addedOptions || meta.removedOptionValues) { + let opts = [...(base.options ?? [])]; + if (meta.removedOptionValues) { + opts = opts.filter((o) => !meta.removedOptionValues!.has(o.value)); + } + if (meta.addedOptions) { + opts = [...opts, ...meta.addedOptions]; + } + base.options = opts; + } + } + + return base; + } + + // ------------------------------------------------------------------ + // Validation + // ------------------------------------------------------------------ + + /** + * Register a custom validator for a single column. The validator runs after + * built-in required-level / max-length checks during `validateField`. + * + * When the wrapped `IRecord` exposes `expressions.setValidationExpression` + * (the real `@talxis/client-libraries` runtime), the validator is also + * forwarded so downstream consumers reading `record.getField(name).isValid()` + * stay in sync. + */ + public setValidator(name: string, validator: FieldValidator): void { + this._validators.set(name, validator); + const record = this.getRecord() as any; + const setExpr = record?.expressions?.setValidationExpression; + if (typeof setExpr === 'function') { + try { setExpr.call(record.expressions, name, validator); } catch { /* ignore */ } + } + } + + public clearValidator(name: string): void { + this._validators.delete(name); + } + + public getValidator(name: string): FieldValidator | undefined { + return this._validators.get(name); + } + + /** + * Runs all validation rules for the column and caches the result: + * 1. Required-level (`required` and value is empty → error). + * 2. Max-length for string attributes. + * 3. Custom validator registered via `setValidator`. + * 4. As a final fallback, defer to `record.getField(name).isValid()` if + * the record exposes that surface (real `IRecord`). + * + * Subscribers attached via `subscribeFieldValidation` are notified. + */ + public validateField(name: string): IFieldValidationResult { + const result = this._computeFieldValidation(name); + const prev = this._validationResults.get(name); + this._validationResults.set(name, result); + if (!prev || prev.error !== result.error || prev.errorMessage !== result.errorMessage) { + this._notifyValidationSubscribers(name); + this._notifyFormValidationSubscribers(); + } + return result; + } + + /** + * Returns the cached validation result for a column, or `VALID_RESULT` if + * the column has not been validated yet. + */ + public getFieldError(name: string): IFieldValidationResult { + return this._validationResults.get(name) ?? VALID_RESULT; + } + + /** + * Validates every known column (formXml cells + registered codeful fields + * + columns with a registered custom validator). Returns the overall + * validity of the form. + */ + public validateForm(): boolean { + const names = this._collectKnownFieldNames(); + let allValid = true; + for (const name of names) { + const r = this.validateField(name); + if (r.error) allValid = false; + } + return allValid; + } + + /** + * Whether the form is currently valid based on cached validation results. + * Does not re-run any validators — call `validateForm` first to ensure + * the cache reflects current state. + */ + public isFormValid(): boolean { + for (const r of this._validationResults.values()) { + if (r.error) return false; + } + const record = this.getRecord() as any; + if (typeof record?.isValid === 'function') { + try { return record.isValid() !== false; } catch { /* ignore */ } + } + return true; + } + + public subscribeFieldValidation(name: string, cb: () => void): () => void { + let set = this._validationSubscribers.get(name); + if (!set) { + set = new Set(); + this._validationSubscribers.set(name, set); + } + set.add(cb); + return () => { + const s = this._validationSubscribers.get(name); + if (!s) return; + s.delete(cb); + if (s.size === 0) this._validationSubscribers.delete(name); + }; + } + + public subscribeFormValidation(cb: () => void): () => void { + this._formValidationSubscribers.add(cb); + return () => { this._formValidationSubscribers.delete(cb); }; + } + + private _computeFieldValidation(name: string): IFieldValidationResult { + let cfg: IAttributeConfiguration | undefined; + try { cfg = this.getAttributeConfiguration(name); } catch { cfg = undefined; } + + const value = this.getValue(name); + + if (cfg?.requiredLevel === 'required' && this._isEmpty(value)) { + return { + error: true, + errorMessage: this._labels.requiredFieldError(), + }; + } + + if (cfg?.maxLength != null && typeof value === 'string' && value.length > cfg.maxLength) { + return { + error: true, + errorMessage: this._labels.maxLengthError({ max: cfg.maxLength }), + }; + } + + if (typeof value === 'number' && !Number.isNaN(value)) { + if (cfg?.minValue != null && value < cfg.minValue) { + return { + error: true, + errorMessage: this._labels.minValueError({ min: cfg.minValue }), + }; + } + if (cfg?.maxValue != null && value > cfg.maxValue) { + return { + error: true, + errorMessage: this._labels.maxValueError({ max: cfg.maxValue }), + }; + } + } + + const custom = this._validators.get(name); + if (custom) { + try { + const r = custom(); + if (r && r.error) { + return { error: true, errorMessage: r.errorMessage ?? '' }; + } + } catch (err) { + return { + error: true, + errorMessage: err instanceof Error ? err.message : 'Validator threw.', + }; + } + } + + const record = this.getRecord() as any; + if (typeof record?.getField === 'function') { + try { + const field = record.getField(name); + const r = field?.isValid?.(); + if (r && r.error) { + return { error: true, errorMessage: r.errorMessage ?? '' }; + } + } catch { /* swallow — record may not implement getField */ } + } + + return VALID_RESULT; + } + + private _isEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (typeof value === 'string') return value.length === 0; + if (Array.isArray(value)) return value.length === 0; + return false; + } + + private _collectKnownFieldNames(): string[] { + const names = new Set(); + for (const v of this._validators.keys()) names.add(v); + for (const f of this._registeredFields.values()) names.add(f.name); + const tab = this.getActiveTab(); + const columns = tab?.columns?.column ?? []; + for (const col of columns) { + const sections = col.sections?.section ?? []; + for (const sec of sections) { + const rows = sec.rows?.row ?? []; + for (const row of rows) { + const cells = row.cell ?? []; + for (const cell of cells) { + const dfn = cell.control?.datafieldname; + if (dfn) names.add(dfn); + } + } + } + } + return Array.from(names); + } + + private _notifyValidationSubscribers(name: string): void { + const set = this._validationSubscribers.get(name); + if (!set) return; + set.forEach((cb) => { try { cb(); } catch { /* swallow */ } }); + } + + private _notifyFormValidationSubscribers(): void { + this._formValidationSubscribers.forEach((cb) => { try { cb(); } catch { /* swallow */ } }); + } + + // ------------------------------------------------------------------ + // Xrm surface + // ------------------------------------------------------------------ + + public getFormContext(): Xrm.FormContext { + if (!this._formContext) { + this._formContext = new XrmFormContext(this); + } + return this._formContext!; + } + + public getExecutionContext(): Xrm.Events.EventContext { + if (!this._executionContext) { + this._executionContext = new XrmExecutionContext(this); + } + return this._executionContext!; + } + + // ------------------------------------------------------------------ + // UI state setters — mutate _workingFormXml directly + // ------------------------------------------------------------------ + + public getTabVisible(name: string): boolean { + const tab = this.getFormXml()?.tabs?.tab?.find((t) => t.name === name); + return tab?.visible !== false; + } + + public setTabVisible(name: string, visible: boolean): void { + const tab = this.getFormXml()?.tabs?.tab?.find((t) => t.name === name); + if (tab) tab.visible = visible; + this._notifyUiStateSubscribers(); + } + + public getSectionVisible(tabName: string, sectionName: string): boolean { + const section = this._findSection(tabName, sectionName); + return section?.visible !== false; + } + + public setSectionVisible(tabName: string, sectionName: string, visible: boolean): void { + const section = this._findSection(tabName, sectionName); + if (section) section.visible = visible; + this._notifyUiStateSubscribers(); + } + + public getControlVisible(controlId: string): boolean { + const cell = this.findCellByControlId(controlId); + return cell?.visible !== false; + } + + public setControlVisible(controlId: string, visible: boolean): void { + const cell = this.findCellByControlId(controlId); + if (cell) cell.visible = visible; + this._notifyUiStateSubscribers(); + } + + public getControlDisabled(controlId: string): boolean { + const cell = this.findCellByControlId(controlId); + return cell?.control?.disabled === true; + } + + public setControlDisabled(controlId: string, disabled: boolean): void { + const cell = this.findCellByControlId(controlId); + if (cell?.control) cell.control.disabled = disabled; + this._notifyUiStateSubscribers(); + } + + public getControlLabel(controlId: string): string | undefined { + const cell = this.findCellByControlId(controlId); + if (!cell?.control?.labels) return undefined; + const label = this.resolveLocalizedLabel(cell.control.labels, ''); + return label || undefined; + } + + public setControlLabel(controlId: string, label: string): void { + const cell = this.findCellByControlId(controlId); + if (cell?.control) { + const lcid = this.getLanguageCode(); + const existing = cell.control.labels?.label?.find((l) => l.languagecode === lcid); + if (existing) { + existing.description = label; + } else { + const prev = cell.control.labels?.label ?? []; + cell.control.labels = { label: [...prev, { languagecode: lcid, description: label }] }; + } + } + this._notifyUiStateSubscribers(); + } + + // ------------------------------------------------------------------ + // Attribute metadata overrides (_metadataOverrides) + // ------------------------------------------------------------------ + + public getRequiredLevelOverride(name: string): Xrm.Attributes.RequirementLevel | undefined { + return this._metadataOverrides.get(name)?.requiredLevel; + } + + public setRequiredLevelOverride(name: string, level: Xrm.Attributes.RequirementLevel): void { + this._patchMeta(name, (o) => { o.requiredLevel = level; }); + this._notifyUiStateSubscribers(); + } + + public addAttributeOption(name: string, option: IAttributeOption, index?: number): void { + this._patchMeta(name, (o) => { + if (!o.addedOptions) o.addedOptions = []; + if (index != null) o.addedOptions.splice(index, 0, option); + else o.addedOptions.push(option); + o.removedOptionValues?.delete(option.value); + }); + this._notifyUiStateSubscribers(); + } + + public removeAttributeOption(name: string, value: number): void { + this._patchMeta(name, (o) => { + if (!o.removedOptionValues) o.removedOptionValues = new Set(); + o.removedOptionValues.add(value); + if (o.addedOptions) o.addedOptions = o.addedOptions.filter((opt) => opt.value !== value); + }); + this._notifyUiStateSubscribers(); + } + + private _patchMeta(name: string, fn: (o: IAttributeMetadataOverride) => void): void { + let o = this._metadataOverrides.get(name); + if (!o) { o = {}; this._metadataOverrides.set(name, o); } + fn(o); + } + + public subscribeUiState(cb: () => void): () => void { + this._uiStateSubscribers.add(cb); + return () => { this._uiStateSubscribers.delete(cb); }; + } + + /** + * Returns a serialized FormXml string reflecting all current UI-state mutations + * (visibility, disabled, label) — trivially the working copy serialized. + */ + public getEffectiveFormXmlString(): string { + const xml = this.getFormXml(); + return xml ? serializeFormXml(xml) : ''; + } + + private _notifyUiStateSubscribers(): void { + this._uiStateSubscribers.forEach((cb) => { try { cb(); } catch { /* swallow */ } }); + } + + private _findSection(tabName: string, sectionName: string) { + const tab = this.getFormXml()?.tabs?.tab?.find((t) => t.name === tabName); + return tab?.columns?.column + ?.flatMap((c) => c.sections?.section ?? []) + ?.find((s) => s.name === sectionName); + } + + public findCellByControlId(controlId: string) { + const formXml = this.getFormXml(); + if (!formXml) return undefined; + for (const tab of formXml.tabs?.tab ?? []) { + for (const col of tab.columns?.column ?? []) { + for (const sec of col.sections?.section ?? []) { + for (const row of sec.rows?.row ?? []) { + for (const cell of row.cell ?? []) { + if (cell.control?.id === controlId) return cell; + } + } + } + } + } + return undefined; + } + + /** + * Returns parsed event metadata from formXml for inspection only. + * No handlers are invoked. + */ + public getEvents(): { + formEvents: FormXml['events']; + formLibraries: FormXml['formLibraries']; + } { + const formXml = this.getFormXml(); + return { + formEvents: formXml?.events, + formLibraries: formXml?.formLibraries, + }; + } + + public getScriptLoader(): IScriptLoader { + return this._scriptLoader; + } + + // ------------------------------------------------------------------ + // Code-registered OnChange handlers (addOnChange / removeOnChange API) + // ------------------------------------------------------------------ + + /** + * Register a handler to be invoked when the given attribute's value changes. + * The execution context is automatically passed as the first argument. + * Called by `XrmAttribute.addOnChange`. + */ + public addOnChangeHandler(name: string, handler: Xrm.Events.ContextSensitiveHandler): void { + let set = this._codeOnChangeHandlers.get(name); + if (!set) { + set = new Set(); + this._codeOnChangeHandlers.set(name, set); + } + set.add(handler); + } + + /** + * Remove a previously registered OnChange handler for the given attribute. + * Called by `XrmAttribute.removeOnChange`. + */ + public removeOnChangeHandler(name: string, handler: Xrm.Events.ContextSensitiveHandler): void { + const set = this._codeOnChangeHandlers.get(name); + if (!set) return; + set.delete(handler); + if (set.size === 0) this._codeOnChangeHandlers.delete(name); + } + + /** + * Fires the form `onload` event handlers declared in FormXml. + * Each async handler is awaited with a 10-second timeout (per Power Apps platform behaviour). + * `OnLoad` → `Loaded` is sequenced by `Form.tsx` after this Promise resolves. + */ + public async fireOnLoad(): Promise { + const args = new XrmOnLoadEventArgs(); + await this._dispatchFormXmlEvent('onload', args); + } + + /** + * Fires the form `loaded` event handlers declared in FormXml. + * Called by `Form.tsx` automatically after `fireOnLoad()` resolves. + */ + public async fireLoaded(): Promise { + await this._dispatchFormXmlEvent('loaded', null); + } + + /** + * Fires the form `onsave` event handlers declared in FormXml. + * Returns `{ prevented: true }` if any handler called `eventArgs.preventDefault()`. + * Callers should abort the save operation when `prevented` is `true`. + * + * @param saveMode - XrmEnum.SaveMode value (default 1 = Save). + */ + public async fireOnSave(saveMode: number = 1): Promise<{ prevented: boolean }> { + const args = new XrmOnSaveEventArgs(saveMode); + await this._dispatchFormXmlEvent('onsave', args); + return { prevented: args.isDefaultPrevented() }; + } + + /** + * Fires the `onchange` event handlers for the given attribute: + * 1. FormXml-declared handlers (via `_dispatchFormXmlEvent`). + * 2. Code-registered handlers added via `XrmAttribute.addOnChange`. + * + * Called automatically from `_onFieldValueChanged`; fire-and-forget from the + * synchronous change pipeline. + */ + public async fireOnChange(attributeName: string): Promise { + await this._dispatchFormXmlEvent('onchange', null, attributeName); + this._invokeCodeOnChangeHandlers(attributeName); + } + + /** + * Invoke code-registered OnChange handlers for the given attribute. + * Handlers are called synchronously with the execution context; returned + * Promises are not awaited (matching Dynamics 365 programmatic-fireOnChange behaviour). + * Errors are caught and logged. + */ + private _invokeCodeOnChangeHandlers(attributeName: string): void { + const handlers = this._codeOnChangeHandlers.get(attributeName); + if (!handlers || handlers.size === 0) return; + + const execCtx = this.getExecutionContext() as XrmExecutionContext; + // Depth continues from where FormXml handlers left off; reset args to null for OnChange + execCtx.setEventArgs(null); + + let depth = 0; + handlers.forEach((handler) => { + execCtx.setDepth(++depth); + try { + handler(execCtx as any); + } catch (err) { + console.error(`[Form] addOnChange handler for "${attributeName}" threw:`, err); + } + }); + } + + /** + * Dispatches FormXml event handlers for the given event name. + * + * - Reuses a single `XrmExecutionContext` for all handlers in the dispatch so that + * `setSharedVariable` / `getSharedVariable` share state across the pipeline. + * - Sets `executionContext.depth` (1-based) before each handler call. + * - Awaits returned Promises with a 10-second timeout unless the handler called + * `eventArgs.disableAsyncTimeout()` (OnSave only) synchronously. + * - Errors / rejections / timeouts are caught and logged; they never propagate. + */ + private async _dispatchFormXmlEvent( + eventName: string, + eventArgs: XrmOnLoadEventArgs | XrmOnSaveEventArgs | null, + attribute?: string, + ): Promise { + const formXml = this.getFormXml(); + if (!formXml?.events?.event) { + return; + } + + // Filter to enabled handlers for this event (and optionally attribute) + const events = formXml.events.event.filter((ev) => { + if (!ev.name || ev.name.toLowerCase() !== eventName.toLowerCase()) return false; + if (attribute !== undefined && ev.attribute !== attribute) return false; + return true; + }); + + if (events.length === 0) { + return; + } + + // Collect all handlers across matching events, preserving declaration order + const handlers: Array<{ + functionName: string; + libraryName: string; + passExecutionContext: boolean; + parameters: string | undefined; + enabled: boolean; + }> = []; + + for (const ev of events) { + for (const handler of ev.Handlers?.Handler ?? []) { + if (handler.enabled === false) continue; + handlers.push({ + functionName: handler.functionName, + libraryName: handler.libraryName, + passExecutionContext: handler.passExecutionContext !== false, + parameters: handler.parameters, + enabled: true, + }); + } + } + + if (handlers.length === 0) { + return; + } + + // Obtain (or create) a stable execution context; reset its shared state for this dispatch + const execCtx = this.getExecutionContext() as XrmExecutionContext; + execCtx.resetForDispatch(); + execCtx.setEventArgs(eventArgs); + + for (let i = 0; i < handlers.length; i++) { + const handler = handlers[i]; + execCtx.setDepth(i + 1); + + // Load the library (no-op if already loaded) + try { + await this._scriptLoader.load(handler.libraryName); + } catch (err) { + console.error(`[Form] Failed to load library "${handler.libraryName}":`, err); + this._invokeErrorCallback(eventArgs, err); + continue; + } + + // Resolve the handler function + const fn = this._scriptLoader.resolve(handler.libraryName, handler.functionName); + if (!fn) { + // Library not loaded / function not found — silently skip + continue; + } + + // Parse additional parameters (comma-separated string → array) + const extraParams = this._parseHandlerParameters(handler.parameters); + + // Call the handler + let result: any; + try { + result = handler.passExecutionContext + ? fn(execCtx, ...extraParams) + : fn(...extraParams); + } catch (err) { + console.error(`[Form] Handler "${handler.functionName}" threw synchronously:`, err); + this._invokeErrorCallback(eventArgs, err); + continue; + } + + // If the handler returned a Promise, await it (with optional timeout) + if (result && typeof result.then === 'function') { + // Check disableAsyncTimeout synchronously (flag must be set before any await) + const timeoutDisabled = + eventArgs instanceof XrmOnSaveEventArgs && eventArgs.isAsyncTimeoutDisabled(); + + try { + if (timeoutDisabled) { + await result; + } else { + await this._withTimeout(result, HANDLER_TIMEOUT_MS, handler.functionName); + } + } catch (err) { + console.error(`[Form] Async handler "${handler.functionName}" failed:`, err); + this._invokeErrorCallback(eventArgs, err); + } + } + } + } + + private _parseHandlerParameters(raw: string | undefined): any[] { + if (!raw || raw.trim() === '') { + return []; + } + try { + return raw.split(',').map((s) => { + const trimmed = s.trim(); + // Try to coerce to JSON primitive; fall back to the raw string + try { return JSON.parse(trimmed); } catch { return trimmed; } + }); + } catch { + return []; + } + } + + private _withTimeout(promise: Promise, ms: number, fnName: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`[Form] Handler "${fnName}" timed out after ${ms}ms`)); + }, ms); + promise.then( + () => { clearTimeout(timer); resolve(); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); + } + + private _invokeErrorCallback( + eventArgs: XrmOnLoadEventArgs | XrmOnSaveEventArgs | null, + err: unknown, + ): void { + const cb = eventArgs instanceof XrmOnLoadEventArgs + ? eventArgs.getErrorCallback() + : eventArgs instanceof XrmOnSaveEventArgs + ? eventArgs.getErrorCallback() + : null; + if (cb) { + try { + cb(err instanceof Error ? err : new Error(String(err))); + } catch { + /* swallow errors inside the error callback */ + } + } + } + + // ------------------------------------------------------------------ + // Dirty tracking + // ------------------------------------------------------------------ + + /** + * Returns whether the form has unsaved changes. Delegates to + * `record.isDirty()` when available; falls back to a local flag + * that is set by `setValue` / `onFieldValueChanged`. + */ + public isDirty(): boolean { + const record = this.getRecord() as any; + if (typeof record?.isDirty === 'function') { + try { + if (record.isDirty()) return true; + } catch { /* ignore */ } + } + return this._localDirty; + } + + /** + * Subscribe to dirty-state changes. Callback is invoked whenever + * the form transitions between clean and dirty. Returns an unsubscribe + * function. + */ + public subscribeFormDirty(cb: () => void): () => void { + this._formDirtySubscribers.add(cb); + return () => { this._formDirtySubscribers.delete(cb); }; + } + + private _notifyFormDirtySubscribers(): void { + this._formDirtySubscribers.forEach((cb) => { try { cb(); } catch { /* swallow */ } }); + } + + public destroy(): void { + this._detachRecordListeners(); + this._registeredFields.clear(); + this._fieldSubscribers.clear(); + this._formDirtySubscribers.clear(); + this._validators.clear(); + this._validationResults.clear(); + this._validationSubscribers.clear(); + this._formValidationSubscribers.clear(); + this._localDirty = false; + this._metadataOverrides.clear(); + this._uiStateSubscribers.clear(); + this._codeOnChangeHandlers.clear(); + this._originalFormXml = undefined; + this._workingFormXml = undefined; + this._parsedFromRaw = undefined; + this._formContext = null; + this._executionContext = null; + } + + /** + * Hook called by `Form.tsx` after each render so the model can react to + * a possibly-swapped `IRecord` instance (rebind listeners). + */ + public syncRecordBinding(): void { + const current = this.getRecord(); + if (current !== this._attachedRecord) { + this._detachRecordListeners(); + this._attachedRecord = current ?? null; + this._localDirty = false; + this._attachRecordListeners(); + // Notify subscribers and outputs that dirty state reset to clean. + this._notifyFormDirtySubscribers(); + this._emitOutputs(); + } + } + + private _attachRecordListeners(): void { + const record = this.getRecord(); + if (!record) { + return; + } + this._attachedRecord = record; + const anyRecord = record as any; + if (typeof anyRecord.addEventListener === 'function') { + anyRecord.addEventListener('onFieldValueChanged', this._onFieldValueChanged); + } + } + + private _detachRecordListeners(): void { + const record = this._attachedRecord; + if (!record) { + return; + } + const anyRecord = record as any; + if (typeof anyRecord.removeEventListener === 'function') { + anyRecord.removeEventListener('onFieldValueChanged', this._onFieldValueChanged); + } + this._attachedRecord = null; + } + + private _emitOutputs(): void { + const props = this._getProps(); + if (!props.onNotifyOutputChanged) { + return; + } + const record = props.parameters.Form; + const values: Record = {}; + if (record) { + const raw = record.getRawData?.(); + if (raw && typeof raw === 'object') { + for (const key of Object.keys(raw)) { + values[key] = raw[key]; + } + } + } + const outputs: IFormOutputs = { values, dirty: this.isDirty() }; + props.onNotifyOutputChanged(outputs); + } + + private _kickOffMetadataProviderResolution(): void { + if (!this._metadataProvider || this._entityResolutionPromise) { + return; + } + const record = this.getRecord(); + const entityName = (record as any)?.getNamedReference?.()?.etn + ?? (record as any)?.getRawData?.()?.['@odata.type']; + if (!entityName || typeof entityName !== 'string') { + return; + } + this._entityResolutionPromise = this._metadataProvider.entity + .get(entityName) + .then((def) => { + this._resolvedEntity = def; + this._notifyUiStateSubscribers(); + return def; + }) + .catch(() => { + this._notifyUiStateSubscribers(); + return undefined; + }); + } +} + +function mapRequiredLevel(level: RequiredLevelEnum | undefined): AttributeRequiredLevel { + switch (level) { + case RequiredLevelEnum.SystemRequired: + case RequiredLevelEnum.ApplicationRequired: + return 'required'; + case RequiredLevelEnum.Recommended: + return 'recommended'; + default: + return 'none'; + } +} + +function mapOptions(optionSet: OptionSetDefinition | undefined): IAttributeOption[] | undefined { + if (!optionSet?.Options) { + return undefined; + } + return optionSet.Options.map((o: Option) => ({ + value: o.Value, + label: o.Label, + color: o.Color, + })); +} + +// Touch AttributeTypeEnum so the import is preserved for downstream type tooling +void AttributeTypeEnum; diff --git a/src/components/Form/form/errors/UnsupportedControlError.ts b/src/components/Form/form/errors/UnsupportedControlError.ts new file mode 100644 index 00000000..6412fdb4 --- /dev/null +++ b/src/components/Form/form/errors/UnsupportedControlError.ts @@ -0,0 +1,22 @@ +/** + * Thrown when a form cell references a control that the MVP Form component does not + * know how to render (custom controls beyond the well-known standard classids). + */ +export class UnsupportedControlError extends Error { + public readonly cellId: string | undefined; + public readonly classId: string | undefined; + public readonly controlName: string | undefined; + + constructor(args: { cellId?: string; classId?: string; controlName?: string }) { + super( + `[Form] Unsupported control in cell "${args.cellId ?? ""}"` + + (args.classId ? ` (classid=${args.classId})` : "") + + (args.controlName ? ` (control=${args.controlName})` : "") + + ". Custom controls are not rendered in MVP." + ); + this.name = "UnsupportedControlError"; + this.cellId = args.cellId; + this.classId = args.classId; + this.controlName = args.controlName; + } +} diff --git a/src/components/Form/form/parts/FieldInput.tsx b/src/components/Form/form/parts/FieldInput.tsx new file mode 100644 index 00000000..c61da3c6 --- /dev/null +++ b/src/components/Form/form/parts/FieldInput.tsx @@ -0,0 +1,255 @@ +import * as React from "react"; +import { useFieldValue } from "../useFieldValue"; +import { useFormInstance } from "../useFormInstance"; +import { + BOOLEAN_CLASSID, + DATETIME_CLASSID, + DECIMAL_CLASSID, + INTEGER_CLASSID, + MONEY_CLASSID, + MULTILINE_TEXT_CLASSID, + MULTISELECT_PICKLIST_CLASSID, + PICKLIST_CLASSID, + SINGLE_LINE_TEXT_CLASSID, + STATUS_CLASSID, + STATUSREASON_CLASSID, +} from "./standardControlClassIds"; + +export interface IFieldInputProps { + classid: string; + datafieldname: string; + /** id of the form-cell control element (used for data-id). */ + controlId?: string; + /** disabled flag from formXml `disabled` attr. */ + disabled?: boolean; +} + +/** + * Routes a cell to a concrete input based on classid. Anything not handled + * here falls back to a read-only `` of the stringified value so the + * cell is still visible (caller has already gated on `isStandardControlClassId`). + */ +export const FieldInput: React.FC = ({ classid, datafieldname, controlId, disabled }) => { + const normalized = classid.toLowerCase(); + const common = { + datafieldname, + controlId, + disabled, + }; + switch (normalized) { + case SINGLE_LINE_TEXT_CLASSID: + return ; + case MULTILINE_TEXT_CLASSID: + return ; + case BOOLEAN_CLASSID: + return ; + case INTEGER_CLASSID: + return ; + case DECIMAL_CLASSID: + case MONEY_CLASSID: + return ; + case DATETIME_CLASSID: + return ; + case PICKLIST_CLASSID: + case STATUS_CLASSID: + case STATUSREASON_CLASSID: + return ; + case MULTISELECT_PICKLIST_CLASSID: + return ; + default: + return ; + } +}; + +interface IBaseInputProps { + datafieldname: string; + controlId?: string; + disabled?: boolean; +} + +const fieldDataId = (datafieldname: string) => `field-${datafieldname}`; + +const TextInput: React.FC = ({ datafieldname, controlId, disabled }) => { + const [value, setValue] = useFieldValue(datafieldname); + return ( + setValue(e.target.value)} + /> + ); +}; + +const TextAreaInput: React.FC = ({ datafieldname, controlId, disabled }) => { + const [value, setValue] = useFieldValue(datafieldname); + return ( +