UNPKG

@kform/react

Version:

React integration for KForm.

1,497 lines 55.8 kB
import { jsx } from "react/jsx-runtime"; import { Path, PromiseCancellationException, AbsolutePath, AbsolutePathFragment, ValueEvent, StateEvent, FormManager, convertIssuesTableRowIndicesToIds, listableToArray, locatedValidationIssueKtToJs, isLocatedValidationIssueKt, isList, listableSize, sliceList, LocatedValidationIssue, isComputedSchema, kFormFileToJsFile, emptyPlaceholderKFormFile, jsFileToKFormFile, compareSchemaPaths, arrayToTable, arrayToList, codeToChar, stringToLong, stringToBigInteger, stringToBigDecimal, charToCode, stringToInstant, stringToLocalDate, stringToLocalDateTime, setLogLevel } from "@kform/core"; import * as React from "react"; import { createStore, useStore } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import { shallow } from "zustand/shallow"; import { shallow as shallow2 } from "zustand/shallow"; import fromExponential from "from-exponential"; const FormContext = React.createContext( null ); class AtPathError extends Error { /** Path at which the error occurred. */ path; constructor(path, message, options) { super(`At '${path.toString()}': ${message}`, options); this.name = this.constructor.name; this.path = path; } } class InvalidPathError extends AtPathError { constructor(path, message) { super(path, message); this.name = this.constructor.name; } } class NoFormContextError extends Error { constructor() { super("No form context found"); this.name = this.constructor.name; } } class NoFormManagerError extends Error { constructor() { super("No form manager found"); this.name = this.constructor.name; } } class NoFormControllerError extends Error { constructor() { super("No form controller found"); this.name = this.constructor.name; } } function useFormContext() { const formContext = React.useContext(FormContext); if (!formContext) { throw new NoFormContextError(); } return formContext; } function useResolvedPath(path = Path.CURRENT, currentPath) { const formContext = React.useContext(FormContext); currentPath ??= formContext?.currentPath; if (currentPath == null) { throw new NoFormContextError(); } const stableAbsolutePathRef = React.useRef(); const absolutePath = React.useMemo( () => currentPath.resolve(path), [currentPath, path] ); if (!absolutePath.equals(stableAbsolutePathRef.current)) { stableAbsolutePathRef.current = absolutePath; } return stableAbsolutePathRef.current; } function CurrentPath({ path = Path.CURRENT, children }) { const formContext = useFormContext(); const resolvedPath = useResolvedPath(path); const newFormContext = React.useMemo( () => ({ formManager: formContext.formManager, currentPath: resolvedPath, controller: formContext.controller }), [formContext.controller, formContext.formManager, resolvedPath] ); return /* @__PURE__ */ jsx(FormContext.Provider, { value: newFormContext, children }); } function ignorePromiseCancellationException(caughtError) { if (!(caughtError instanceof PromiseCancellationException)) { throw caughtError; } } function useLatestValues(values) { const valuesRef = React.useRef({}); Object.assign(valuesRef.current, values); return valuesRef.current; } async function arrayFromAsync(iterable) { const array = []; for await (const el of iterable) { array.push(el); } return array; } function isPromiseLike(value) { return typeof value?.then === "function"; } const INITIAL = Symbol(); function useEqualityFn(selector, equalityFn = Object.is) { const latest = React.useRef(INITIAL); return (state) => { const next = selector(state); return latest.current !== INITIAL && equalityFn(latest.current, next) ? latest.current : latest.current = next; }; } function useFormManager(formManager) { const formContext = React.useContext(FormContext); const relevantFormManager = formManager ?? formContext?.formManager; if (relevantFormManager == null) { throw new NoFormManagerError(); } return relevantFormManager; } const UNINITIALIZED_CONTROLLER_STATE = { initialized: false, exists: void 0, value: void 0, dirty: void 0, touched: void 0, issues: void 0, validationStatus: void 0, displayStatus: void 0 }; const NONEXISTENT_VALUE_CONTROLLER_STATE = { initialized: true, exists: false, value: void 0, dirty: void 0, touched: void 0, issues: void 0, validationStatus: void 0, displayStatus: void 0 }; function useController(path, { formManager: formManagerOption, enabled = true, _defaultState, onInitialized, onUninitialized, onFormManagerEvent, onValueChange, onValidationStatusChange, onDisplayStatusChange, onDirtyStatusChange, onTouchedStatusChange } = {}) { const formManager = useFormManager(formManagerOption); const resolvedPath = useResolvedPath( path, formManagerOption != null ? AbsolutePath.ROOT : void 0 ); const observingDescendants = resolvedPath.lastFragment === AbsolutePathFragment.RecursiveWildcard; const valuePath = React.useMemo( () => observingDescendants ? new AbsolutePath(resolvedPath.fragments.slice(0, -1)) : resolvedPath, [resolvedPath, observingDescendants] ); if (!formManager.isValidPath(valuePath)) { throw new Error(`Invalid path: '${resolvedPath.toString()}'.`); } if (valuePath.fragments.some( (frag) => !(frag instanceof AbsolutePathFragment.Id) )) { throw new Error( "Controller path must only contain ids (except for the last fragment, which may be a recursive wildcard)." ); } const schemaInfo = React.useMemo( () => Array.from(formManager.schemaInfo(valuePath))[0], [formManager, valuePath] ); const defaultState = React.useRef(_defaultState); defaultState.current = _defaultState; const store = React.useMemo( () => createStore()( subscribeWithSelector( () => ({ formManager, schema: schemaInfo.schema, path: valuePath, schemaPath: schemaInfo.path, observingDescendants, ...UNINITIALIZED_CONTROLLER_STATE, ...defaultState.current }) ) ), [ formManager, observingDescendants, schemaInfo.path, schemaInfo.schema, valuePath ] ); const controller = React.useMemo( () => ({ getState: () => unwrapStateValue(store.getState()), _setState: (state) => store.setState(state), subscribe: (selector, listener, options) => store.subscribe( (state) => selector(unwrapStateValue(state)), listener, options ), useState: (selector, options) => { const hasSelector = typeof selector === "function"; const result = useStore( store, useEqualityFn( (state) => hasSelector ? selector(unwrapStateValue(state)) : state, hasSelector ? options?.equalityFn : shallow ) ); return hasSelector ? result : unwrapStateValue(result); }, useFormManager: () => useStore(store, (state) => state.formManager), useSchema: () => useStore(store, (state) => state.schema), usePath: () => useStore(store, (state) => state.path), useSchemaPath: () => useStore(store, (state) => state.schemaPath), useObservingDescendants: () => useStore(store, (state) => state.observingDescendants), useInitialized: () => useStore(store, (state) => state.initialized), useExists: () => useStore(store, (state) => state.exists), useValue: () => useStore(store, (state) => state.value)?.[0], useDirty: () => useStore(store, (state) => state.dirty), useTouched: () => useStore(store, (state) => state.touched), useIssues: () => useStore(store, (state) => state.issues), useValidationStatus: () => useStore(store, (state) => state.validationStatus), useDisplayStatus: () => useStore(store, (state) => state.displayStatus), get: (pathOrValueHandler, valueHandler) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return valueHandler !== void 0 ? formManager2.get( valuePath2.resolve(pathOrValueHandler), valueHandler ) : formManager2.get( valuePath2, pathOrValueHandler ); }, getClone: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.getClone( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, set: (pathOrToSet, toSet) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return toSet !== void 0 ? formManager2.set( valuePath2.resolve(pathOrToSet), toSet ) : formManager2.set(valuePath2, pathOrToSet); }, reset: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.reset( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, remove: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.remove( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, validate: (path2 = Path.CURRENT_DEEP) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.validate( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, setDirty: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.setDirty( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, setPristine: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.setPristine( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, setTouched: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.setTouched( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); }, setUntouched: (path2) => { const { formManager: formManager2, path: valuePath2 } = store.getState(); return formManager2.setUntouched( path2 != null ? valuePath2.resolve(path2) : valuePath2 ); } }), [store] ); const latestValues = useLatestValues({ onInitialized, onUninitialized, onFormManagerEvent, onValueChange, onValidationStatusChange, onDisplayStatusChange, onDirtyStatusChange, onTouchedStatusChange }); React.useEffect(() => { if (!enabled) { return; } let cleanedUp = false; let unsubscribe = void 0; const initPromise = formManager.info(valuePath, async (infoIterable) => { if (cleanedUp) { return; } unsubscribe = await formManager.subscribe(resolvedPath, (event) => { if (cleanedUp) { return; } let specificEventHandlerResult = void 0; if (event instanceof ValueEvent) { if (!observingDescendants || event.path.equals(valuePath)) { if (event instanceof ValueEvent.Init) { store.setState({ initialized: true, exists: true, value: [event.value], dirty: false, touched: false, issues: [], validationStatus: "unvalidated", displayStatus: "valid" }); } else if (event instanceof ValueEvent.Destroy) { store.setState(NONEXISTENT_VALUE_CONTROLLER_STATE); } else { store.setState({ value: [event.value] }); } } else { store.setState( (state) => ({ value: state.value ? [state.value[0]] : void 0 }) ); } specificEventHandlerResult = latestValues.onValueChange?.( event, controller.getState() ); } else if (event instanceof StateEvent.ValidationChange) { if (!observingDescendants || event.path.equals(valuePath)) { store.setState({ issues: event.issues, validationStatus: event.status }); } specificEventHandlerResult = latestValues.onValidationStatusChange?.( event, controller.getState() ); } else if (event instanceof StateEvent.DisplayChange) { if (!observingDescendants || event.path.equals(valuePath)) { store.setState({ displayStatus: event.status }); } specificEventHandlerResult = latestValues.onDisplayStatusChange?.( event, controller.getState() ); } else if (event instanceof StateEvent.DirtyChange) { if (!observingDescendants || event.path.equals(valuePath)) { store.setState({ dirty: event.status }); } specificEventHandlerResult = latestValues.onDirtyStatusChange?.( event, controller.getState() ); } else if (event instanceof StateEvent.TouchedChange) { if (!observingDescendants || event.path.equals(valuePath)) { store.setState({ touched: event.status }); } specificEventHandlerResult = latestValues.onTouchedStatusChange?.( event, controller.getState() ); } const formManagerEventHandlerResult = latestValues.onFormManagerEvent?.( event, controller.getState() ); if (isPromiseLike(formManagerEventHandlerResult) || isPromiseLike(specificEventHandlerResult)) { return Promise.all([ formManagerEventHandlerResult, specificEventHandlerResult ]).then(() => { }); } }); const info = (await arrayFromAsync(infoIterable))[0]; store.setState( info ? { initialized: true, exists: true, value: [info.value], dirty: info.dirty, touched: info.touched, issues: info.issues, validationStatus: info.validationStatus, displayStatus: info.displayStatus } : NONEXISTENT_VALUE_CONTROLLER_STATE ); return latestValues.onInitialized?.(controller.getState()); }).catch(ignorePromiseCancellationException); return () => { cleanedUp = true; initPromise?.cancel( `Clean up 'useEffect' access to info of '${valuePath.toString()}'.` ); void unsubscribe?.(); store.setState(UNINITIALIZED_CONTROLLER_STATE); latestValues.onUninitialized?.(controller.getState()); }; }, [ controller, enabled, formManager, latestValues, observingDescendants, resolvedPath, schemaInfo.path, schemaInfo.schema, store, valuePath ]); return controller; } function unwrapStateValue(state) { return { ...state, value: state.value?.[0] }; } function equals(v1, v2) { return Object.is(v1, v2) || typeof v1?.equals === "function" && !!v1.equals(v2); } function usePrevious(value) { const ref = React.useRef(); React.useEffect(() => { ref.current = value; }, [value]); return ref.current; } function useNewFormManager(schema, initialValue, externalContexts, validationMode = "auto") { const initialValueRef = React.useRef(initialValue); const formManager = React.useMemo( () => new FormManager( schema, initialValueRef.current, void 0, void 0, false ), [schema] ); React.useEffect(() => { void formManager.init(externalContexts, validationMode); return () => void formManager.destroy(); }, [formManager]); const prevFormManager = usePrevious(formManager); const prevExternalContexts = usePrevious(externalContexts); React.useEffect(() => { if (formManager === prevFormManager) { if (externalContexts) { for (const contextName of Object.keys(externalContexts)) { const externalContext = externalContexts[contextName]; if (externalContext !== void 0 && (!prevExternalContexts || !equals(externalContext, prevExternalContexts[contextName]))) { void formManager.setExternalContext(contextName, externalContext); } } } if (prevExternalContexts) { for (const contextName of Object.keys(prevExternalContexts)) { if (prevExternalContexts[contextName] !== void 0 && (!externalContexts || externalContexts[contextName] === void 0)) { void formManager.removeExternalContext(contextName); } } } } }, [externalContexts, formManager, prevExternalContexts, prevFormManager]); const prevValidationMode = usePrevious(validationMode); React.useEffect(() => { if (formManager === prevFormManager && validationMode !== prevValidationMode) { void formManager.setValidationMode(validationMode); } }, [formManager, validationMode, prevValidationMode, prevFormManager]); return formManager; } const DEFAULT_CONFIRM_UNLOAD_MESSAGE = "Are you sure you want to leave?"; function useForm(schema, { _defaultState, initialValue, externalContexts, validationMode, confirmUnloadWhenDirty = process.env.NODE_ENV === "production", confirmUnloadMessage, onSubmit, onInvalidSubmit, onSuccessfulSubmit, onFailedSubmit, setTouchedOnSubmit = true, validateOnSubmit = true, setPristineOnSuccessfulSubmit = true, convertExternalIssuesTableRowIndicesToIds = false, onReset, ...options } = {}) { const formManager = useNewFormManager( schema, initialValue, externalContexts, validationMode ); const controller = useController( AbsolutePath.ROOT, { formManager, _defaultState: { autoValidationStatus: formManager.autoValidationStatus, submitting: false, resetting: false, ..._defaultState }, ...options } ); const latestValues = useLatestValues({ confirmUnloadMessage, onSubmit, onInvalidSubmit, onSuccessfulSubmit, onFailedSubmit, setTouchedOnSubmit, validateOnSubmit, setPristineOnSuccessfulSubmit, convertExternalIssuesTableRowIndicesToIds, onReset }); const handleSubmit = React.useCallback( async (event, { onSubmit: onSubmit2 = latestValues.onSubmit, onInvalidSubmit: onInvalidSubmit2 = latestValues.onInvalidSubmit, onSuccessfulSubmit: onSuccessfulSubmit2 = latestValues.onSuccessfulSubmit, onFailedSubmit: onFailedSubmit2 = latestValues.onFailedSubmit, setTouchedOnSubmit: setTouchedOnSubmit2 = latestValues.setTouchedOnSubmit, validateOnSubmit: validateOnSubmit2 = latestValues.validateOnSubmit, setPristineOnSuccessfulSubmit: setPristineOnSuccessfulSubmit2 = latestValues.setPristineOnSuccessfulSubmit, convertExternalIssuesTableRowIndicesToIds: convertExternalIssuesTableRowIndicesToIds2 = latestValues.convertExternalIssuesTableRowIndicesToIds } = {}) => { if (!onSubmit2) { throw new Error( "Missing `onSubmit` implementation. The `onSubmit` function may be provided as a property of the `Form` component, as an option of the `useForm` hook, or directly as an option of the `submit` function." ); } event?.preventDefault(); const { initialized, exists, schema: schema2 } = controller.getState(); if (initialized && exists) { controller._setState({ submitting: true }); try { if (setTouchedOnSubmit2) { await controller.setTouched(Path.CURRENT_DEEP); } const issues = validateOnSubmit2 ? await controller.validate(Path.CURRENT_DEEP) : void 0; if (issues?.some((issue) => issue.severity === "error")) { onInvalidSubmit2?.(issues, event); return; } await controller.get(async (value) => { const submitResult = await onSubmit2( value, issues, event ); if (isExternalValidationIssues(submitResult)) { const externalIssues = convertExternalIssuesTableRowIndicesToIds2 ? await convertIssuesTableRowIndicesToIds( submitResult, schema2, value ) : listableToArray(submitResult).map( (issue) => isLocatedValidationIssueKt(issue) ? locatedValidationIssueKtToJs(issue) : issue ); const newExternalIssues = issues === void 0 ? externalIssues : externalIssues.filter( (externalIssue) => !issues.some((issue) => issue.equals(externalIssue)) ); if (newExternalIssues.length > 0) { void formManager.addExternalIssues(newExternalIssues); } onInvalidSubmit2?.(externalIssues, event); } else { void Promise.resolve( setPristineOnSuccessfulSubmit2 && formManager.setPristine(Path.CURRENT) ).then(() => onSuccessfulSubmit2?.(submitResult, event)); } }); } catch (err) { if (onFailedSubmit2) { onFailedSubmit2(err, event); } else { throw err; } } finally { controller._setState({ submitting: false }); } } }, [controller, formManager, latestValues] ); const handleReset = React.useCallback( async (event) => { const { initialized, exists } = controller.getState(); await latestValues.onReset?.(event); if (!event?.defaultPrevented) { event?.preventDefault(); if (initialized && exists) { controller._setState({ resetting: true }); try { await controller.reset(); await Promise.all([ controller.setPristine(), controller.setUntouched() ]); } finally { controller._setState({ resetting: false }); } } } }, [controller, latestValues] ); React.useEffect(() => { let unsubscribe = void 0; const subscribePromise = formManager.onAutoValidationStatusChange( (autoValidationStatus) => controller._setState({ autoValidationStatus }) ).then((fn) => unsubscribe = fn).catch(ignorePromiseCancellationException); return () => { subscribePromise.cancel( "Clean up 'useEffect' subscription to auto validation status changes." ); void unsubscribe?.(); }; }, [controller, formManager]); React.useEffect(() => { if (confirmUnloadWhenDirty) { const handleBeforeUnload = (event) => { if (controller.getState().dirty) { event.preventDefault(); return event.returnValue = latestValues.confirmUnloadMessage || DEFAULT_CONFIRM_UNLOAD_MESSAGE; } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); } }, [confirmUnloadWhenDirty, controller, latestValues]); const formProps = React.useMemo( () => ({ noValidate: true, onSubmit: handleSubmit, onReset: handleReset }), [handleReset, handleSubmit] ); const ownController = React.useMemo( () => ({ submit: (eventOrOptions, maybeOptions) => { const [event, options2] = isEventLike(eventOrOptions) ? [eventOrOptions, maybeOptions] : [void 0, eventOrOptions]; return handleSubmit(event, options2); }, useAutoValidationStatus: () => controller.useState((state) => state.autoValidationStatus), useSubmitting: () => controller.useState((state) => state.submitting), useResetting: () => controller.useState((state) => state.resetting), formProps }), [controller, formProps, handleSubmit] ); return React.useMemo( () => ({ ...controller, ...ownController }), [controller, ownController] ); } function isEventLike(value) { return typeof value?.preventDefault === "function" && "defaultPrevented" in value; } function isExternalValidationIssues(value) { const firstEl = isList(value) ? listableSize(value) > 0 ? sliceList(value, 0, 1)[0] : void 0 : Array.isArray(value) ? value[0] : void 0; return firstEl != null && (firstEl instanceof LocatedValidationIssue || isLocatedValidationIssueKt(firstEl)); } const Form = React.forwardRef(function Form2({ schema, initialValue, enabled, _defaultState, onInitialized, onUninitialized, onFormManagerEvent, onValueChange, onValidationStatusChange, onDisplayStatusChange, onDirtyStatusChange, onTouchedStatusChange, externalContexts, validationMode, confirmUnloadWhenDirty, confirmUnloadMessage, onSubmit, onInvalidSubmit, onSuccessfulSubmit, onFailedSubmit, setTouchedOnSubmit, validateOnSubmit, setPristineOnSuccessfulSubmit, convertExternalIssuesTableRowIndicesToIds, onReset, ...otherProps }, forwardedRef) { const formController = useForm(schema, { initialValue, enabled, _defaultState, onInitialized, onUninitialized, onFormManagerEvent, onValueChange, onValidationStatusChange, onDisplayStatusChange, onDirtyStatusChange, onTouchedStatusChange, externalContexts, validationMode, confirmUnloadWhenDirty, confirmUnloadMessage, onSubmit, onInvalidSubmit, onSuccessfulSubmit, onFailedSubmit, setTouchedOnSubmit, validateOnSubmit, setPristineOnSuccessfulSubmit, convertExternalIssuesTableRowIndicesToIds, onReset }); const formManager = formController.useFormManager(); const formContext = React.useMemo( () => ({ formManager, currentPath: AbsolutePath.ROOT, controller: formController }), [formController, formManager] ); return /* @__PURE__ */ jsx(FormContext.Provider, { value: formContext, children: /* @__PURE__ */ jsx("form", { ...formController.formProps, ...otherProps, ref: forwardedRef }) }); }); function useFormatter(path = Path.CURRENT, { _defaultState, setFormattedValue, format, ...options }) { const { onInitialized, onUninitialized, onValueChange } = options; const latestOptions = useLatestValues({ setFormattedValue, format, onInitialized, onUninitialized, onValueChange }); const controller = useController(path, { ...options, _defaultState: { formattedValue: void 0, ..._defaultState }, onInitialized: (state) => { const result = formatAndSetFormattedValue( state.value, latestOptions.format, latestOptions.setFormattedValue, controller ); const handlerResult = latestOptions.onInitialized?.(state); return isPromiseLike(result) ? Promise.all([result, handlerResult]).then(() => { }) : handlerResult; }, onUninitialized: (state) => { controller._setState({ formattedValue: void 0 }); return latestOptions.onUninitialized?.(state); }, onValueChange: (event, state) => { const result = formatAndSetFormattedValue( state.value, latestOptions.format, latestOptions.setFormattedValue, controller ); const handlerResult = latestOptions.onValueChange?.(event, state); return isPromiseLike(result) ? Promise.all([result, handlerResult]).then(() => { }) : handlerResult; } }); const ownController = React.useMemo( () => ({ useFormattedValue: () => controller.useState((state) => state.formattedValue) }), [controller] ); React.useEffect(() => { const { initialized, value } = controller.getState(); if (initialized) { void formatAndSetFormattedValue( value, format, setFormattedValue, controller ); } }, [controller, format, setFormattedValue]); return React.useMemo( () => ({ ...controller, ...ownController }), [controller, ownController] ); } function formatAndSetFormattedValue(value, format, setFormattedValue, controller) { const state = controller.getState(); const set = (formattedValue2) => { controller._setState({ formattedValue: formattedValue2 }); return setFormattedValue?.(formattedValue2, state); }; const formattedValue = format ? format(value, state) : value; if (isPromiseLike(formattedValue)) { return formattedValue.then(set); } else { return set(formattedValue); } } function FormattedValue({ path, children, ...props }) { const formContext = React.useContext(FormContext); const formatterController = useFormatter(path, props); const formManager = formatterController.useFormManager(); const currentPath = formatterController.usePath(); const newFormContext = React.useMemo( () => ({ formManager, currentPath, controller: formContext?.formManager === formManager ? formContext.controller : void 0 }), [ currentPath, formContext?.controller, formContext?.formManager, formManager ] ); const state = formatterController.useState( (state2) => children ? state2 : { initialized: state2.initialized, formattedValue: state2.formattedValue }, { equalityFn: shallow } ); return /* @__PURE__ */ jsx(FormContext.Provider, { value: newFormContext, children: children ? children(state) : state.initialized && (React.isValidElement(state.formattedValue) ? state.formattedValue : state.formattedValue?.toString()) }); } function useInput(path = Path.CURRENT, { setFormattedValue = defaultSetFormattedValue, format, parse, ...options } = {}) { const latestOptions = useLatestValues({ parse, setFormattedValue }); const inputRef = React.useRef(null); const activeSetPromiseRef = React.useRef( null ); const formatterSetFormattedValue = React.useCallback( (formattedValue, state) => { if (activeSetPromiseRef.current == null) { setFormattedValue(formattedValue, state, inputRef.current); } }, [setFormattedValue] ); const formatterFormat = React.useCallback( (value, state) => format ? format(value, state, inputRef.current) : value, [format] ); const controller = useFormatter(path, { setFormattedValue: formatterSetFormattedValue, format: formatterFormat, ...options }); const handleChange = React.useCallback( async (eventOrValue) => { const { schema, observingDescendants, initialized, exists, dirty, formattedValue } = controller.getState(); if (initialized && exists && !isComputedSchema(schema)) { const newValue = isChangeEvent(eventOrValue) ? getEventValue(eventOrValue.target, eventOrValue) : eventOrValue; const parsedValue = latestOptions.parse ? await latestOptions.parse( newValue, controller.getState(), inputRef.current ) : newValue; if (!equals(formattedValue, parsedValue)) { activeSetPromiseRef.current?.cancel("New value is being set."); const newSetPromise = activeSetPromiseRef.current = controller.set(parsedValue).catch(ignorePromiseCancellationException).finally(() => { if (activeSetPromiseRef.current === newSetPromise) { activeSetPromiseRef.current = null; } }); if (!dirty) { void controller.setDirty( observingDescendants ? Path.CURRENT_DEEP : Path.CURRENT ); } } } }, [controller, latestOptions] ); const handleBlur = React.useCallback(() => { const { observingDescendants, initialized, exists, touched, formattedValue } = controller.getState(); if (initialized && exists) { if (!touched) { void controller.setTouched( observingDescendants ? Path.CURRENT_DEEP : Path.CURRENT ); } if (activeSetPromiseRef.current == null) { latestOptions.setFormattedValue( formattedValue, controller.getState(), inputRef.current ); } else { activeSetPromiseRef.current = null; } } }, [controller, latestOptions]); const listeners = useLatestValues({ onChange: handleChange, onBlur: handleBlur }); const inputName = controller.useState((state) => state.path.toString()); const isComputed = controller.useState( (state) => isComputedSchema(state.schema) ); const inputProps = React.useMemo( () => ({ name: inputName, readOnly: isComputed, ref: inputRef, onChange: (eventOrValue) => listeners.onChange(eventOrValue), onBlur: () => listeners.onBlur() }), [inputName, isComputed, listeners] ); return React.useMemo( () => ({ ...controller, inputProps }), [controller, inputProps] ); } function defaultSetFormattedValue(formattedValue, _state, input) { if (input == null) { return; } if (input instanceof HTMLInputElement && input.type === "checkbox") { const oldInputValue = input.checked; const newInputValue = !!formattedValue; if (oldInputValue !== newInputValue) { input.checked = newInputValue; } } else if (input instanceof HTMLInputElement && input.type === "file") { if (formattedValue instanceof FileList) { input.files = formattedValue; } else { input.value = ""; } } else if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement || input instanceof HTMLTextAreaElement) { const oldInputValue = input.value; const newInputValue = String(formattedValue ?? ""); if (oldInputValue !== newInputValue) { input.value = newInputValue; } } else { throw new Error( "Unknown input element, please provide a custom `setFormattedValue` function." ); } } function isChangeEvent(value) { return value !== null && typeof value === "object" && "target" in value && value.target !== null && typeof value.target === "object"; } function getEventValue(eventTarget, defaultValue) { return eventTarget instanceof HTMLInputElement || eventTarget instanceof HTMLTextAreaElement || eventTarget instanceof HTMLSelectElement ? eventTarget.type === "checkbox" ? eventTarget.checked : eventTarget.type === "file" ? eventTarget.files : eventTarget.value : defaultValue; } function Input({ path, children, ...props }) { const formContext = React.useContext(FormContext); const inputController = useInput(path, props); const formManager = inputController.useFormManager(); const currentPath = inputController.usePath(); const newFormContext = React.useMemo( () => ({ formManager, currentPath, controller: formContext?.formManager === formManager ? formContext.controller : void 0 }), [ currentPath, formContext?.controller, formContext?.formManager, formManager ] ); const state = inputController.useState((state2) => state2, { equalityFn: shallow }); return /* @__PURE__ */ jsx(FormContext.Provider, { value: newFormContext, children: children(inputController.inputProps, state) }); } function useCurrentPath() { return useFormContext().currentPath; } function formatKFormFileAsFileList(value, schema) { const dataTransfer = new DataTransfer(); if (value != null) { dataTransfer.items.add(kFormFileToJsFile(value)); } return dataTransfer.files; } function parseFileListAsKFormFile(formattedValue, schema) { if (formattedValue.length === 0) { return schema.typeInfo.nullable ? null : emptyPlaceholderKFormFile(); } return jsFileToKFormFile(formattedValue[0]); } function useFileInput(path = Path.CURRENT, { formatFromFileList, parseToFileList, ...options } = {}) { const latestOptions = useLatestValues({ parseToFileList }); const format = React.useCallback( (value, state, input) => { const fileList = formatKFormFileAsFileList(value, state.schema); return formatFromFileList ? formatFromFileList(fileList, state, input) : fileList; }, [formatFromFileList] ); const parse = React.useCallback( (formattedValue, state, input) => { return parseFileListAsKFormFile( latestOptions.parseToFileList ? latestOptions.parseToFileList(formattedValue, state, input) : formattedValue, state.schema ); }, [latestOptions] ); const inputController = useInput(path, { format, parse, ...options }); const { typeInfo } = inputController.useSchema(); if (typeInfo.name !== "File") { throw new InvalidPathError( inputController.getState().path, `Unsupported schema: Expected schema representing (possibly nullable) file values, but got schema representing values of type '${typeInfo.toString()}'.` ); } return inputController; } function useFormattedValue(path = Path.CURRENT, options = {}) { const formatterController = useFormatter(path, options); return formatterController.useState( (state) => [state.formattedValue, formatterController], { equalityFn: shallow } ); } function useFormController() { const formController = useFormContext().controller; if (formController == null) { throw new NoFormControllerError(); } return formController; } function binarySearch(array, element, compareFn) { let low = 0; let high = array.length - 1; while (low <= high) { const mid = low + high >>> 1; const cmp = compareFn(array[mid], element); if (cmp < 0) { low = mid + 1; } else if (cmp > 0) { high = mid - 1; } else { return mid; } } return -(low + 1); } function useIssuesTracker(path = Path.CURRENT_DEEP, { formManager: formManagerOption, issuesOrderCompareFn, enabled = true } = {}) { const formManager = useFormManager(formManagerOption); const absolutePath = useResolvedPath( path, formManagerOption != null ? AbsolutePath.ROOT : void 0 ); const formSchema = React.useMemo(() => formManager.schema(), [formManager]); const compareInfo = React.useCallback( (info1, info2) => issuesOrderCompareFn?.(info1.path, info2.path) || compareSchemaPaths(formSchema, info1.path, info2.path), [formSchema, issuesOrderCompareFn] ); const [trackedState, setTrackedState] = React.useState({ initialized: false }); React.useEffect(() => { if (!enabled) { return; } let cleanedUp = false; let unsubscribe = void 0; const stateByPath = /* @__PURE__ */ new Map(); let trackedInfo = []; let errors = 0; let warnings = 0; const infoPromise = formManager.info(absolutePath, async (infoIterable) => { if (cleanedUp) { return; } for await (const info of infoIterable) { const newState = { touched: info.touched, issues: info.issues }; stateByPath.set(info.path.toString(), newState); const newInfo = validationStateToValidationInfo(info.path, newState); if (newInfo !== void 0) { trackedInfo.push(newInfo); const counts = countIssueSeverities(info.issues); errors += counts.errors; warnings += counts.warnings; } } setTrackedState({ initialized: true, info: trackedInfo.sort(compareInfo), errors, warnings }); unsubscribe = await formManager.subscribe(absolutePath, (event) => { if (cleanedUp) { return; } const pathStr = event.path.toString(); const oldState = stateByPath.get(pathStr); let newState = oldState; if (event instanceof ValueEvent.Init) { newState = { touched: false, issues: [] }; } else if (event instanceof ValueEvent.Destroy) { newState = void 0; } else if (event instanceof StateEvent.ValidationChange) { newState = { touched: oldState?.touched ?? false, issues: event.issues }; } else if (event instanceof StateEvent.TouchedChange) { newState = { touched: event.status, issues: oldState?.issues ?? [] }; } if (oldState === newState) { return; } if (!newState) { stateByPath.delete(pathStr); } else { stateByPath.set(pathStr, newState); } const oldCountedIssues = oldState?.touched ? oldState.issues : []; const newCountedIssues = newState?.touched ? newState.issues : []; if (!equalIssues(oldCountedIssues, newCountedIssues)) { trackedInfo = updateTrackedInfo( trackedInfo, event.path, newState, compareInfo ); const oldCounts = countIssueSeverities(oldCountedIssues); const newCounts = countIssueSeverities(newCountedIssues); errors += newCounts.errors - oldCounts.errors; warnings += newCounts.warnings - oldCounts.warnings; setTrackedState({ initialized: true, info: trackedInfo, errors, warnings }); } }); }).catch(ignorePromiseCancellationException); return () => { cleanedUp = true; infoPromise.cancel( `Clean up 'useEffect' access to info of '${absolutePath.toString()}'.` ); void unsubscribe?.(); setTrackedState({ initialized: false }); }; }, [absolutePath, compareInfo, enabled, formManager]); return trackedState; } function validationStateToValidationInfo(path, state) { return state?.touched && state.issues.length > 0 ? { path, issues: state.issues, localDisplayStatus: state.issues.some( (issue) => issue.severity === "error" ) ? "error" : "warning" } : void 0; } function updateTrackedInfo(trackedInfo, path, newState, compareInfo) { const infoOfPath = { path }; const index = binarySearch(trackedInfo, infoOfPath, compareInfo); const newInfo = validationStateToValidationInfo(path, newState); if (index < 0 && newInfo === void 0) { return trackedInfo; } const result = trackedInfo.slice(); if (index >= 0) { if (newInfo === void 0) { result.splice(index, 1); } else { result[index] = newInfo; } } else { result.splice(-index - 1, 0, newInfo); } return result; } function countIssueSeverities(issues) { let errors = 0; let warnings = 0; for (const issue of issues) { if (issue.severity === "error") { ++errors; } else { ++warnings; } } return { errors, warnings }; } function equalIssues(issues1, issues2) { return issues1.length === issues2.length && issues1.every((issue, i) => issue.equals(issues2[i])); } function formatListableAsArray(value, schema) { return value == null ? null : listableToArray(value); } function parseArrayAsListable(formattedValue, schema) { const supportsNull = schema.typeInfo.nullable; const schemaType = schema.typeInfo.name; if (formattedValue == null) { if (supportsNull) { return null; } else { formattedValue = []; } } switch (schemaType) { case "Array": return formattedValue; case "List": return arrayToList(formattedValue); default: return arrayToTable(formattedValue); } } const ALLOWED_COLLECTION_TYPES = ["Array", "List", "Table"]; function useListableInput(path = Path.CURRENT, { formatFromArray, parseToArray, ...options } = {}) { const latestOptions = useLatestValues({ parseToArray }); const format = React.useCallback( (value, state, input) => { const array = formatListableAsArray(value, state.schema); return formatFromArray ? formatFromArray(array, state, input) : array; }, [formatFromArray] ); const parse = React.useCallback( (formattedValue, state, input) => { return parseArrayAsListable( latestOptions.parseToArray ? latestOptions.parseToArray(formattedValue, state, input) : formattedValue, state.schema ); }, [latestOptions] ); const inputController = useInput(path, { format, parse, ...options }); const { typeInfo } = inputController.useSchema(); if (!ALLOWED_COLLECTION_TYPES.includes(typeInfo.name)) { throw new InvalidPathError( inputController.getState().path, `Unsupported schema: Expected schema representing (possibly nullable) listable values, but got schema representing values of type '${typeInfo.toString()}'.` ); } return inputController; } const CHAR_MIN_NUMBER = 0; const CHAR_MAX_NUMBER = 65535; const BYTE_MIN_VALUE = -128; const BYTE_MAX_VALUE = 127; const CHAR_MIN_VALUE = codeToChar(CHAR_MIN_NUMBER); const CHAR_MAX_VALUE = codeToChar(CHAR_MAX_NUMBER); const SHORT_MIN_VALUE = -32768; const SHORT_MAX_VALUE = 32767; const INT_MIN_VALUE = -2147483648; const INT_MAX_VALUE = 2147483647; const LONG_MIN_VALUE = stringToLong("-9223372036854775808"); const LONG_MAX_VALUE = stringToLong("9223372036854775807"); const CHAR_ZERO = CHAR_MIN_VALUE; const LONG_ZERO = stringToLong("0"); const BIG_INTEGER_ZERO = stringToBigInteger("0"); const BIG_DECIMAL_ZERO = stringToBigDecimal("0"); function formatNumericAsString(value, schema) { if (value == null) { return ""; } const schemaType = schema.typeInfo.name; switch (schemaType) { case "Char": return charToCode(value).toString(); case "Float": case "Double": return Number.isNaN(value) ? "" : fromExponential(value); case "String": return value === "" || Number.isNaN(Number(value)) ? "" : fromExponential(value); default: return value.toString(); } } function parseStringAsNumeric(formattedValue, schema) { const supportsNull = schema.typeInfo.nullable; const schemaType = schema.typeInfo.name; if (formattedValue === "" || formattedValue === "-" || formattedValue === "." || formattedValue === "-.") { return supportsNull ? null : schemaType === "Char" ? CHAR_ZERO : schemaType === "Long" ? LONG_ZERO : schemaType === "String" ? "" : 0; } switch (schemaType) { case "Byte": case "Short": case "Int": { const { min, max } = numericTypeMinMax(schema); const value = parseInt(formattedValue); return Number.isNaN(value) ? supportsNull ? null : 0 : Math.min(Math.max(value, min), max); } case "Char": { const value = parseInt(formattedValue); return Number.isNaN(value) ? supportsNull ? null : CHAR_ZERO : codeToChar( Math.min(Math.max(value, CHAR_MIN_NUMBER), CHAR_MAX_NUMBER) ); } case "Long": { const value = parseIntString(formattedValue); if (value === null) { return supportsNull ? null : LONG_ZERO; } const isNegative = value.startsWith("-"); try { return stringToLong(value); } catch { return isNegative ? LONG_MIN_VALUE : LONG_MAX_VALUE; } } case "BigInteger": { const value = parseIntString(formattedValue); if (value === null) { return supportsNull ? null : BIG_INTEGER_ZERO; } return stringToBigInteger(value); } case "BigDecimal": { const preferredScale = schema.typeInfo.restrictions.scale ?? null; let bigDecimal; try { bigDecimal = stringToBigDecimal(formattedValue); } catch { bigDecimal = supportsNull ? null : BIG_DECIMAL_ZERO; } return bigDecimal === null || preferredScale === null || bigDecimal.scale >= preferredScale ? bigDecimal : bigDecimal.setScale(preferredScale); } case "Float": case "Double": return parseFloat(formattedValue); default: return formattedValue; } } function numericTypeMinMax(schema) { switch (schema.typeInfo.name) { case "Byte": return { min: BYTE_MIN_VALUE, max: BYTE_MAX_VALUE }; case "Char": return { min: CHAR_MIN_VALUE, max: CHAR_MAX_VALUE }; case "Short": return { min: SHORT_MIN_VALUE, max: SHORT_MAX_VALUE }; case "Int": return { min: INT_MIN_VALUE, max: INT_MAX_VALUE }; case "Long": return { min: LONG_MIN_VALUE, max: LONG_MAX_VALUE }; default: return { min: void 0, max: void 0 }; } } function parseIntString(str) { const len = str.length; const isNegative = str.startsWith("-"); let start = isNegative ? 1 : 0; while (start < len - 1 && str[start] === "0" && str[start + 1] >= "0" && str[start + 1] <= "9") { ++start; } let end = len; for (let i = start; i < len; ++i) { if (str[i] < "0" || str[i] > "9") { end = i; break; } } if (start === end) { return null; } const normalized = str.slice(start, end); return isNegative && normalized !== "0" ? `-${normalized}` : normalized; } const ALLOWED_NUMERIC_TYPES = [ "Byte", "Char", "Short", "Int", "Long", "Float", "Double", "BigInteger", "BigDecimal", // Not exactly a numeric type, but it is sometimes useful to treat strings // with numeric patterns as "numeric" "String" ]; function useNumericInput(path = Path.CURRENT, { formatFromString, parseToString, ...options } = {}) { const latestOptions = useLatestValues({ parseToString }); const format = React.useCallback( (value, state, input) => { const numberString = formatNumericAsString(value, state.schema); return formatFromString ? formatFromString(numberString, state, input) : numberString; }, [formatFromString] ); const parse = React.useCallback( (formattedValue, state, input) => { return parseStringAsNumeric( latestOptions.parseToString ? latestOptions.parseToString(formattedValue, state, inpu