From d75edb3ba1cac8132f248ff44ef0943dcca3dd36 Mon Sep 17 00:00:00 2001 From: Jan Hajek Date: Tue, 26 May 2026 12:00:59 +0200 Subject: [PATCH 1/5] feat: Implement MVP Form Component with Standard Control Handling - Added UnsupportedControlError for handling unsupported controls in forms. - Created FieldInput component to route input types based on control class IDs. - Developed FormCell to render individual cells, validating and displaying inputs. - Introduced FormRow and FormSection components to structure form layout. - Implemented FormTab to render the active tab and its sections. - Defined standard control class IDs for recognized input types. - Added useFieldValidation and useFieldValue hooks for managing field state and validation. - Created useFormDirty and useFormValidation hooks for overall form state management. - Established useFormInstance hook to access the FormModel context. - Defined interfaces for form parameters, outputs, and validation results. - Added translations for form validation messages. - Updated main component index to export Form components. --- src/components/Form/form/Form.tsx | 76 ++ src/components/Form/form/FormContext.ts | 4 + src/components/Form/form/FormModel.ts | 678 ++++++++++++++++++ .../form/errors/UnsupportedControlError.ts | 22 + src/components/Form/form/parts/FieldInput.tsx | 255 +++++++ src/components/Form/form/parts/FormCell.tsx | 76 ++ src/components/Form/form/parts/FormRow.tsx | 18 + .../Form/form/parts/FormSection.tsx | 26 + src/components/Form/form/parts/FormTab.tsx | 38 + .../form/parts/standardControlClassIds.ts | 39 + .../Form/form/useFieldValidation.ts | 35 + src/components/Form/form/useFieldValue.ts | 30 + src/components/Form/form/useFormDirty.ts | 21 + src/components/Form/form/useFormInstance.ts | 12 + src/components/Form/form/useFormValidation.ts | 31 + src/components/Form/index.ts | 9 + src/components/Form/interfaces.ts | 145 ++++ src/components/Form/translations.ts | 6 + src/components/index.ts | 1 + 19 files changed, 1522 insertions(+) create mode 100644 src/components/Form/form/Form.tsx create mode 100644 src/components/Form/form/FormContext.ts create mode 100644 src/components/Form/form/FormModel.ts create mode 100644 src/components/Form/form/errors/UnsupportedControlError.ts create mode 100644 src/components/Form/form/parts/FieldInput.tsx create mode 100644 src/components/Form/form/parts/FormCell.tsx create mode 100644 src/components/Form/form/parts/FormRow.tsx create mode 100644 src/components/Form/form/parts/FormSection.tsx create mode 100644 src/components/Form/form/parts/FormTab.tsx create mode 100644 src/components/Form/form/parts/standardControlClassIds.ts create mode 100644 src/components/Form/form/useFieldValidation.ts create mode 100644 src/components/Form/form/useFieldValue.ts create mode 100644 src/components/Form/form/useFormDirty.ts create mode 100644 src/components/Form/form/useFormInstance.ts create mode 100644 src/components/Form/form/useFormValidation.ts create mode 100644 src/components/Form/index.ts create mode 100644 src/components/Form/interfaces.ts create mode 100644 src/components/Form/translations.ts diff --git a/src/components/Form/form/Form.tsx b/src/components/Form/form/Form.tsx new file mode 100644 index 00000000..651d86ab --- /dev/null +++ b/src/components/Form/form/Form.tsx @@ -0,0 +1,76 @@ +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(); + }); + + // 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; + + if (hasFormXml && hasChildren) { + throw new Error( + "[Form] FormXml and children are mutually exclusive. Provide one or the other, not both." + ); + } + + 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..7cca1010 --- /dev/null +++ b/src/components/Form/form/FormModel.ts @@ -0,0 +1,678 @@ +import { IRecord } from "@talxis/client-libraries"; +import { + AttributeTypeEnum, + FormXml, + FormXmlLabels, + FormXmlTab, + IEntityDefinition, + IMetadataProvider, + Option, + OptionSetDefinition, + RequiredLevelEnum, + parseFormXml, +} 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"; + +interface IFormDependencies { + labels: Required>; + onGetProps: () => IForm; + theme?: ITheme; + metadataProvider?: IMetadataProvider; + scriptLoader?: IScriptLoader; +} + +/** + * Reserved seam for future Power Apps-style form-event execution context. + * Empty marker for now — see plan.md "Stubbed for later". + */ +export interface IFormExecutionContext { + readonly __reserved?: never; +} + +/** + * Reserved seam for future loading of `formLibraries` / web-resource scripts. + * The default implementation in `FormModel` is a no-op. + */ +export interface IScriptLoader { + load(libraryName: string): Promise; +} + +const NOOP_SCRIPT_LOADER: IScriptLoader = { + load: async () => { /* no-op MVP stub */ }, +}; + +export interface IRegisteredField { + name: string; +} + +export class FormModel { + private _getProps: () => IForm; + private _labels: Required>; + private _theme: ITheme; + private _metadataProvider?: IMetadataProvider; + private _scriptLoader: IScriptLoader; + + private _parsedFormXml: 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; + 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._parsedFormXml = undefined; + this._parsedFromRaw = raw; + return undefined; + } + if (raw !== this._parsedFromRaw) { + this._parsedFormXml = parseFormXml(raw); + this._parsedFromRaw = raw; + } + return this._parsedFormXml; + } + + 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): 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); + } + } + + /** + * 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); + if (!attr) { + return { + requiredLevel: 'none', + }; + } + return { + requiredLevel: mapRequiredLevel(attr.RequiredLevel), + options: mapOptions(attr.OptionSet), + format: attr.Format, + maxLength: attr.MaxLength, + minValue: attr.MinValue, + maxValue: attr.MaxValue, + targets: attr.Targets, + }; + } + + // ------------------------------------------------------------------ + // 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 */ } }); + } + + /** + * Reserved — see plan.md "Stubbed for later". Throws today; will be wired up + * when Power Apps-style script execution lands. + */ + public getExecutionContext(): IFormExecutionContext { + throw new Error("[Form] getExecutionContext() is not implemented yet."); + } + + /** + * 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; + } + + // ------------------------------------------------------------------ + // 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; + } + + /** + * 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; + return def; + }) + .catch(() => 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 ( +