import { useInterpret, useSelector } from "@/import/xstate";
import { useCallback, useEffect, useMemo, useRef, useId, forwardRef } from "@/import/react";
import hoistNonReactStatic from "hoist-non-react-statics";
import ElementMachine from "@/shared/lib/machine/element";
import defaultEventCreators from "@/shared/lib/machine/eventCreators";
import { NO_ARGUMENTS_TO_REPLAY, REPLAY_ARGUMENTS } from "@/shared/enum/symbols";
import CentralSyncHelper from "@/shared/helper/sync/central";
import { useStoreValues } from "@/shared/context/storeValues";
/**
* Compare the smart value and check if the value changed.
* @param {boolean} elementMaintainsOwnActiveState If true entered value changes by the user do not result into a re-render (making the element during the user input passive)
* @param {[*, (undefined|string), ("value"|"enteredValue"), (undefined|string), ("entered"|"external")]} prev Previous array.
* @param {[*, (undefined|string), ("value"|"enteredValue"), (undefined|string), ("entered"|"external")]} curr Next array.
* @return {boolean} True if not changed.
*/
const smartValueCompare = (elementMaintainsOwnActiveState = false, prev, curr) => {
if (curr[2] === "enteredValue" && elementMaintainsOwnActiveState && curr[4] === "entered") {
return true;
}
if (prev[1] && curr[1]) {
// The value is known at the server, check if there are local changes that must be merged.
const inputSignatureMatch = prev[1] === curr[1];
if (prev[3] && curr[3]) {
return inputSignatureMatch && prev[3] === curr[3];
}
if (curr[3]) {
return curr[3] === curr[1];
}
return inputSignatureMatch;
}
if (prev[3] && curr[3]) {
// This happens if a value is not known at the server yet, but the value was set locally to be sent.
return prev[3] === curr[3];
}
// If no signature can be compared (entered value) and the subject is complex, a json comparison is done.
if (typeof curr[0] !== typeof prev[0]) {
return false;
}
if (typeof curr[0] === "object" && curr[0] !== null) {
return JSON.stringify(prev[0]) === JSON.stringify(curr[0]);
}
return prev[0] === curr[0];
};
/**
* Retrieving the entered value when the user is focused/changing the element field, otherwise showing the value instead.
* @param {object} state Element machine state.
* @return {[*,(undefined|string), 'enteredValue'|'value']} Where the first item is the actual value, the second the signature and the third item is an indication of the kind of value.
*/
const smartValueSelector = (state) =>
state.matches("interaction.unfocused")
? [state.context.store.targetValueCache, state.context.input.signature, "value", state.context.store.lastSendSignature, state.context.newEnteredValueInputComesFrom]
: [state.context.enteredValue, undefined, "enteredValue", undefined, state.context.newEnteredValueInputComesFrom];
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || "Element";
}
/**
* Wrap an element service (through an element machine) around any element.
* @param {JSXElement} Component React component.
* @param {object} [options] Options to tweak the element machine.
* @param {boolean} [options.ignoreOnFocusForUserInput=true] If set to true when an entered value (onChange function) is fired, a 'Focus' event is fired upfront. This is required for element that do not implement an onFocus handler and optionally for those who do. If set to false the element must support the onFocus callback attribute (or lack of user interaction recording is no issue)!
* @param {"onDebouncedInput"|"none"|"onBlur"|"onBlurAndInactivity"} [options.saveMode="onDebouncedInput"] Changes the save mode of the element.
* @param {function|number} [options.debounceEnteredValue] Replace the default element machine delay value with a function or number.
* @param {boolean} [options.devTools=false] Whether or not to have devTools on.
* @param {function} [options.observer] Add an observer function that receives an update of all state changes.
* @param {object} [options.eventCreators] Event creators used for element machines that override the default event creators.
* @param {Observer} [options.formStateStore$] Observer containing the store.
* @param {Observer} [options.isConcept$] Observer indicating whether the changes are 'in concept' and therefore should not send as change to the interaction tracking..
* @param {function} [options.updateStore] Function to update the store.
* @param {function} [options.elementMaintainsOwnActiveState] Prevent unwanted re-renders in cases where the component keeps a copy of its own value. Prevent re-renders on enteredValue
* @param {boolean} [options.interactionTrackingEnabled=true]
* @return {function({onChange: function, onBlur: function, onFocus: function, defaultValue: *, value: *, id: string|number, send: undefined|{current: *}, [p: string]: *}): null|JSX.Element}
*/
function withElementMachine(Component, options = {}) {
// Create the machine
const adjustedConfig = {};
if (options.debounceEnteredValue) {
// Change the delay for entered values if set.
adjustedConfig.delays = {
DEBOUNCE_ENTERED_VALUE: options.debounceEnteredValue,
};
}
/**
* JSX element containing an element service, used for React rendering.
* @param {string|number} id Id used to identify its values within the store form state. A random postfix is added to address uniqueness in the form name and html element.
* @param {function} [onChange] Call when an entered value change occurred.
* @param {function} [onBlur] Call when the user loses focus on the element.
* @param {function} [onFocus] Call when the user start focussing on the element.
* @param {object} [eventCreators] Object containing event creators.
* @param {*} [defaultValue] Optionally a default value for the element.
* @param {*} [value] A controlled value that must be set within the form machine. Please use the field only if you want to override the current value, information entered by the user is taken care of by this element already.
* @param {boolean} [readOnly] The readOnly attribute of default HTML form elements.
* @param {boolean} [disabled=false] The disabled attribute of default HTML form elements.
* @param {undefined|{current: *}} send useRef hook that receives a reference to sending commands to the element service.
* @param {Observer} [formStateStore$] Observer containing the store.
* @param {Observer} [isConcept$] Observer indicating whether the changes are 'in concept' and therefore should not send as change to the interaction tracking.
* @param {function} [updateStore] Function to update the store.
* @param {boolean} [elementInteractionTrackingEnabled=true]
* @param {boolean} [elementMaintainsOwnActiveState=false] On true prevent unwanted re-renders in cases where the component keeps a copy of its own value. Prevent re-renders on enteredValue
* @param {"onDebouncedInput"|"none"|"onBlur"|"onBlurAndInactivity"} [saveMode="onDebouncedInput"] Changes the save mode of the element.
* @param {{[p: string]: *}} props Other element props.
* @return {null|JSX.Element} Null if the element machine is not yet initialized, otherwise the machined element.
* @constructor
*/
function WithElementMachine(
{
onChange,
onBlur,
onFocus,
defaultValue,
value,
id,
readOnly,
disabled,
send,
eventCreators,
formStateStore$ = options.formStateStore$,
updateStore = options.updateStore,
saveMode = options.saveMode,
isConcept$ = options.isConcept$,
storeType,
storeId,
storeMetaData,
elementInteractionTrackingEnabled = options.interactionTrackingEnabled,
elementMaintainsOwnActiveState = options.elementMaintainsOwnActiveState ?? false,
...props
},
ref
) {
const eventCreatorsMerged = useMemo(() => {
let ec = defaultEventCreators;
if (options.eventCreators) {
ec = {
...ec,
...options.eventCreators,
};
}
if (eventCreators) {
ec = {
...ec,
...eventCreators,
};
}
return ec;
}, [eventCreators]);
// Preventing two components with the same reference
const idPostfix = useId();
const formId = `Form-${id}-${idPostfix}`;
const machine = useMemo(
() =>
ElementMachine(formStateStore$, updateStore, id, eventCreatorsMerged, saveMode, formId, CentralSyncHelper.addNonStoreEvent, isConcept$).withConfig(adjustedConfig),
[formStateStore$, updateStore, id, eventCreatorsMerged, saveMode, formId, isConcept$]
);
// Create the element service
const elementService = useInterpret(
machine,
{
devTools: options.devTools,
},
options.observer
);
// Set the default value if set or changed.
useEffect(() => {
if (elementService && typeof defaultValue !== "undefined") {
elementService.send(eventCreatorsMerged.setDefaultValueEvent(defaultValue));
}
}, [defaultValue, elementService, eventCreatorsMerged]);
useEffect(() => {
if (elementService && typeof elementInteractionTrackingEnabled === "boolean") {
elementService.send({
type: "CHANGE_INTERACTION_TRACKING",
value: elementInteractionTrackingEnabled,
});
}
}, [elementService, elementInteractionTrackingEnabled]);
// Override the current value if set or changed.
useEffect(() => {
if (elementService && typeof value !== "undefined") {
elementService.send(eventCreatorsMerged.currentValueChangeEvent(value));
}
}, [elementService, value, eventCreatorsMerged]);
// Process changes on the elements value by the user.
const wrappedOnChange = useCallback(
(value, metaInformation, ...args) => {
// TODO: analyse complex situations where an onChange triggers a parent onChange, where the metaInformation contains machineInformation.
if (readOnly !== true && disabled !== true) {
const machineInformation = {
parentId: id,
storeType,
storeId,
storeMetaData,
};
let enteredValueEvent = eventCreatorsMerged.enteredValueChangeEvent(value, metaInformation, machineInformation, ...args);
// Cache the enteredValue function so others can 'replay' the same event.
if (typeof onChange === "function" && typeof enteredValueEvent?.enteredValue === "function") {
let cache = NO_ARGUMENTS_TO_REPLAY;
const handler = {
apply: (target, thisArg, argumentsList) => {
if (argumentsList[0] === REPLAY_ARGUMENTS) {
return cache;
}
const result = Reflect.apply(target, thisArg, argumentsList);
cache = result;
return result;
},
};
enteredValueEvent.enteredValue = new Proxy(enteredValueEvent.enteredValue, handler);
}
if (elementService) {
// optimize TODO: check with selector whether this it's already focused to limit the amount of FOCUS events
if (options.ignoreOnFocusForUserInput !== false) {
elementService.send({ type: "FOCUS" });
}
elementService.send(enteredValueEvent);
}
if (typeof onChange === "function") {
onChange(value, enteredValueEvent, metaInformation, machineInformation, ...args);
}
}
},
[elementService, onChange, eventCreatorsMerged, readOnly, disabled, storeType, storeId, storeMetaData]
);
// Attach the elementService to the useRef hook
useEffect(() => {
if (send && elementService) {
// eslint-disable-next-line no-param-reassign
send.current = {
service: elementService.send,
eventCreators: eventCreatorsMerged,
onChange: wrappedOnChange,
updateStore,
};
}
}, [elementService, send, eventCreatorsMerged, wrappedOnChange, updateStore]);
// Process onBlur calls.
const wrappedOnBlur = useCallback(
(...args) => {
const machineInformation = {
parentId: id,
storeType,
storeId,
storeMetaData,
};
if (elementService) {
try {
elementService.send({ type: "BLUR" });
// eslint-disable-next-line no-empty
} catch (e) {}
}
if (typeof onBlur === "function") {
onBlur(...args, machineInformation);
}
},
[elementService, onBlur, storeType, storeId, storeMetaData, id]
);
// Process onFocus calls.
const wrappedOnFocus = useCallback(
(...args) => {
const machineInformation = {
parentId: id,
storeType,
storeId,
storeMetaData,
};
if (elementService) {
try {
elementService.send({ type: "FOCUS" });
// eslint-disable-next-line no-empty
} catch (e) {}
}
if (typeof onFocus === "function") {
onFocus(...args, machineInformation);
}
},
[elementService, onFocus, storeType, storeId, storeMetaData, id]
);
const firstRender = useRef(0);
const smartValueCompareIncludedOptions = useMemo(() => smartValueCompare.bind(null, elementMaintainsOwnActiveState), [elementMaintainsOwnActiveState]);
/*
The smart value is 'smart' in the sense that the element can show different values based on the 'state' where the element is in.
As example: in a concurrency field the developer could allow the user to type 10K as input and onBlur show the actual value that the element can format.
*/
const [smartValue, _, valueType] = useSelector(elementService, smartValueSelector, smartValueCompareIncludedOptions);
let forwardValue = smartValue;
// Preventing react from complaining about returning an "uncontrolled" component after controlling it.
// This can only occur at creating or destroying the machine OR (no data entered by the user AND no (default) value set) OR when the value is 'null' and it was set before.
if (typeof smartValue === "undefined" || smartValue === null) {
const number = process.env.NODE_ENV === "development" ? 2 : 1; // 2 because of strict mode in React 18
if (typeof defaultValue !== "undefined" && defaultValue !== null && firstRender.current <= number) {
// On the very first render, when the store does not have a value for the machine, the default value can be used to render the component.
// The events send to the element machine will rerender this element within a few ms, but it does avoid react errors within non-textual components.
forwardValue = defaultValue;
firstRender.current += 1;
} else {
forwardValue = "";
}
}
// To prevent user input while the machine is not initialized yet, the element is not rendered unless the service is ready.
return !elementService ? null : (
<Component
{...props}
id={`${id}-${idPostfix}`}
formStateId={id}
onChange={wrappedOnChange}
onBlur={wrappedOnBlur}
onFocus={wrappedOnFocus}
value={forwardValue}
valueType={valueType}
readOnly={readOnly}
disabled={disabled}
formStateStore$={formStateStore$}
updateStore={updateStore}
storeType={storeType}
storeId={storeId}
storeMetaData={storeMetaData}
ref={ref}
/>
);
}
if (process.env.NEXT_PUBLIC_ALLOW_EXPERIMENT_STORE_CONTEXT === "true") {
hoistNonReactStatic(WithElementMachine, Component);
const displayName = `WithElementMachine(${getDisplayName(Component)})`;
WithElementMachine.displayName = displayName;
const ForwardedWithElementMachine = forwardRef(WithElementMachine);
function CurrentHackyWorkaround(props, ref) {
const { updateStore, formStateStore$ } = useStoreValues();
return <ForwardedWithElementMachine updateStore={updateStore} formStateStore$={formStateStore$} {...props} ref={ref} />;
}
hoistNonReactStatic(CurrentHackyWorkaround, Component);
CurrentHackyWorkaround.displayName = displayName;
return forwardRef(CurrentHackyWorkaround);
}
hoistNonReactStatic(WithElementMachine, Component);
WithElementMachine.displayName = `WithElementMachine(${getDisplayName(Component)})`;
return forwardRef(WithElementMachine);
}
export default withElementMachine;