import { actions as xstateActions, createMachine } from "@/import/xstate";
import { MD5 } from "object-hash";
import { AsyncSubject, from, of } from "rxjs";
import { map, switchMap, distinctUntilChanged, tap } from "rxjs/operators";
import defaultEventCreators from "@/shared/lib/machine/eventCreators";
const { pure, choose, send, assign } = xstateActions;
// This file is a simplified copy of the form-machine package, used to be combined with the Automerge package.
// The machine should be production-ready but has not been fully battle-tested.
/**
* Symbol to hash undefined values, equivalent to 'nothing' of the immer package.
* @type {symbol} Hash symbol
*/
const nothing = Symbol.for("undefined-hash");
const elementGuards = {
/**
* Generic guard that checks whether the event contains a(n) (entered) value.
* @param {object} _ XState context, named as `_` to indicate it's not being used.
* @param {{value: *, enteredValue: *, valueIsUndefined: boolean}} event XState event
* @return {boolean} True if a value can be read from the event.
*/
eventMustContainValue: (_, event) => typeof event.value !== "undefined" || typeof event.enteredValue !== "undefined" || event.valueIsUndefined === true,
/**
* Check whether the user 'tainted' the current value by providing input directly to the element.
* @param {{ interaction: { userChangedElement: boolean }, defaultValueSignature: string|undefined, current: {signature: string|undefined} }} context XState context.
* @return {boolean} True if (entered) value can be seen as 'tainted', otherwise the (entered) value must be interpreted as set by the developer/code.
*/
canDefaultValueOverrideCurrentValue: (context) => {
return !context.interaction.userChangedElement && (context.defaultValueSignature === context.input.signature || !context.input.signature);
},
/**
* Required to prevent sending updates to an offline store that is not synced at all with the 'server' yet.
* The idea behind the guard is that events are saved to be run when the store has synced.
*/
canDefaultValueOverrideCurrentValueAndIsStoreInitialized: (context) => {
return (
!context.interaction.userChangedElement &&
(context.defaultValueSignature === context.input.signature || !context.input.signature) &&
context.store.initialValueReceived
);
},
interactionTrackingEnabled: (context) => {
return context.interactionTrackingEnabled;
},
eventValueIsBoolean: (_, event) => {
return typeof event.value === "boolean";
},
};
const elementActions = {
/**
* Set the default value and default entered value.
*/
setDefaultValue: assign(
/**
* Set the default value, the default entered value and its signature.
* @param {object} context XState context.
* @param {{value: *, valueIsUndefined: boolean, enteredValue: *}} event XState event.
*/
(context, event) => {
if (typeof event.value !== "undefined" || event.valueIsUndefined === true) {
const defaultValue = event.value;
const defaultValueSignature = MD5(typeof event.value === "undefined" ? nothing : event.value);
const defaultEnteredValue = event.enteredValue;
return {
defaultValue,
defaultValueSignature,
defaultEnteredValue,
};
}
return {};
}
),
/**
* Tag entered value as an adjustment from 'outside' the user input.
* This could be from anywhere, event other elements triggering an update in this element.
*/
tagAsExternalInput: assign(() => ({
newEnteredValueInputComesFrom: "external"
})),
/**
* Tag entered value as an adjustment from direct user interactions as meant by the developer.
*/
tagAsUserInput: assign(() => ({
newEnteredValueInputComesFrom: "entered"
})),
/**
* Set the entered value to the event value.
*/
setEnteredValue: assign(
/**
* Set the entered value from the event. If the event does not contain an entered value, but does contain a value the value is interpreted as an entered value.
* @param {{current: object}} context XState context.
* @param {{value: *, valueIsUndefined: boolean, enteredValue: *}} event XState event.
*/
(context, event) => {
if (typeof event.value !== "undefined" || typeof event.enteredValue !== "undefined" || event.valueIsUndefined === true) {
let value = typeof event.enteredValue !== "undefined" ? event.enteredValue : event.value;
if (typeof value === "function") {
// Support for functions that want to change the entered value but do not have (performant) access to its value.
value = value(context.enteredValue);
}
return {
enteredValue: value,
};
}
return {};
}
),
/**
* Adjust the current value.
*/
updateContext: assign((context, event) => ({
...event.value,
store: { ...context.store, initialValueReceived: true },
})),
/**
* Affirm that the user has at least once changed the entered value.
* Could be used to decide whether to show validation message to the user.
*/
affirmUserEnteredValueChange: (context) => {
if (context.interaction?.userChangedElement !== true && context.interactionTrackingEnabled) {
context.store.updateStore({
type: "UPDATE_FORM_STATE_VALUE",
ref: `${context.id}.interaction.userChangedElement`,
value: true,
});
}
},
/**
* Affirm that the user has touched (clicked, tapped) on the element.
* Could be used to decide whether to show messages to the user.
*/
affirmUserTouch: (context) => {
if (context.interaction?.userTouchedElement !== true) {
context.store.updateStore({
type: "UPDATE_FORM_STATE_VALUE",
ref: `${context.id}.interaction.userTouchedElement`,
value: true,
});
}
},
setInteractionTracking: assign((context, event) => ({
interactionTrackingEnabled: event.value,
})),
/**
* XState action creator calling the actions to set the entered and current value and publish these values to subscribed machines.
*/
setEnteredValueAndPublish: pure(() => ["setEnteredValue", "publishValue", "saveEventToStoreAdjustmentsQueue"]),
/**
* XState action creator calling setting the default value action and the broadcast action to convert the default value as entered & current value.
* Calls the broadcast action only if the default value is allowed to override the entered & current value (through the appropriate condition).
*/
handleDefaultValueChange: choose([
{
cond: "canDefaultValueOverrideCurrentValueAndIsStoreInitialized",
actions: ["setDefaultValue", "broadcastEventValueAsCurrentValue"],
},
{
cond: "canDefaultValueOverrideCurrentValue",
actions: ["setDefaultValue", "saveEventToStoreAdjustmentsQueue"],
},
{
actions: "setDefaultValue",
},
]),
saveEventToStoreAdjustmentsQueue: assign((context, event) => {
if (typeof context.store.lastSendSignature === "undefined") {
const adjustments = context.store.storeAdjustmentsQueue.concat([event]);
context.store.storeAsyncObserver.next(adjustments);
return {
store: {
...context.store,
storeAdjustmentsQueue: adjustments,
},
};
}
return context;
}),
};
const elementDelays = {
/**
* Debounce delay of the entered value in ms, to go from 'mutating' into 'hold' state.
* Influences the saveMode when set to 'onDebounceInput', as after the timeout the current value is published to the store
*/
DEBOUNCE_ENTERED_VALUE: 400,
/**
* Waiting time in which no new entered value was inserted by the user, while in the 'hold' state.
*/
INACTIVITY_TIME: 3000,
};
const defaultSubscribedValue = {
interaction: {
userTouchedElement: false, // User touched the element at least once.
userChangedElement: false, // User (tried) to change the value of the element at least once.
},
// Elements distinguish the current value into two parts: human input and machine input. Machine input is the .value, where enteredValue represents the human input.
// This enables an element to variate the value shown to the user, based on user behavior or state defined by the developer or element code.
input: {
signature: undefined, // Hash of the value, used to verify whether a value is set/adjusted and to verify whether the value changed.
value: "", // The core subject: the value which can either be a form value to be transferred to another source or a read-only attributes, e.g. mathematical overview of elements values.
},
};
const defaultIsConcept$ = of(false);
/**
* Element machine
* @param {Observer} formStateStore$ Observer to the store containing the form state.
* @param {function} updateStore Function to update the store
* @param {string} formStateReferenceId Reference id, referring to a specific item within the form state store.
* @param {{currentValueChangeEvent: function}} [eventCreators] Object containing the event creators to submit an event.
* @param {"onDebouncedInput"|"none"|"onBlur"|"onBlurAndInactivity"} [saveMode]
* @param {string} [machineId] Alternative name for the machine itself, not being used for any other id reference.
* @returns {object} Element machine in xstate object.
*/
const elementMachine = (
formStateStore$,
updateStore,
formStateReferenceId,
eventCreators = defaultEventCreators,
saveMode = "onDebouncedInput",
machineId,
telemetryCallback,
isConcept$ = defaultIsConcept$,
) => {
if (typeof formStateReferenceId === "undefined" || formStateReferenceId === null) {
throw Error("Element machine is initialized without unique id.");
}
const storeAsyncObserver = new AsyncSubject();
const storeValueObserver = formStateStore$.pipe(
map((obj) => eventCreators.subscribedValueChangeEvent(formStateReferenceId, obj, defaultSubscribedValue)),
// tap((obj) => { if (Array.isArray(obj.value.input.value)) {console.log(obj) }}),
distinctUntilChanged(
(prev, curr) =>
prev.value.interaction?.userChangedElement === curr.value.interaction?.userChangedElement &&
prev.value.interaction?.userTouchedElement === curr.value.interaction?.userTouchedElement &&
prev.value.input?.signature === curr.value.input?.signature
)
);
return createMachine(
{
id: machineId || `Element-${formStateReferenceId}`,
context: () => ({
...defaultSubscribedValue,
id: formStateReferenceId,
store: {
updateStore, // Function to update the (remote) storage, e.g. automerge document
lastSendSignature: undefined, // Copies the last sent signature of the value submitted to the store, to prevent message spam and also preventing typing user from seeing their work be overwritten with 'old' values.
initialValueReceived: false, // Check whether the store is initialized to prevent default changes to override actual server values.
storeAsyncObserver, // Observer to execute the storeAdjustmentsQueue once.
storeAdjustmentsQueue: [], // Save mutation actions to the store to replay the events when the store is initialized.
},
enteredValue: undefined, // The entered value (human input).
// Default data fields, used to form the input.value when input.value (either through store or user) is set.
defaultValueSignature: undefined, // Hash of the default value.
defaultValue: undefined, // The default value itself.
defaultEnteredValue: undefined, // Same distinguish as current value (human input and machine input), but for default value.
// Extra elements attributes to help the developer instruct an element.
imposedAttributes: {
disabled: false,
readonly: false,
},
// Indicate whether the 'new' value (the event triggered after setting this value) is either 'external' or 'entered'.
// Reasoning: in order to block re-renders from elements with their own value store it must be known where-from a change was initialized.
newEnteredValueInputComesFrom: "external",
interactionTrackingEnabled: true,
isConcept: false,
// Save mode can be "none", "onBlur", "onDebouncedInput".
// "none", the value is never published to any store, could be used for initialized read-only elements.
// "onBlur", only publish a value to the registered store on a onBlur event. Could be used for inline edit elements that save when clicked outside the element.
// "onBlurAndInactivity", only publish a value to the registered store on a onBlur event OR after time of inactivity.
// "onDebounceInput", publish a value to the registered store when the user stops/pauses typing.
saveMode,
}),
predictableActionArguments: true,
preserveActionOrder: true,
type: "parallel",
exit: ["sendTelemetry"],
on: {
CHANGE_INTERACTION_TRACKING: {
cond: "eventValueIsBoolean",
description: "Enable or disable the interaction tracking of the element",
actions: "setInteractionTracking",
},
CHANGE_IS_CONCEPT: {
cond: "eventValueIsBoolean",
description: "Change isConcept flag within the context",
actions: "setIsConcept"
},
},
invoke: {
src: () => isConcept$.pipe(
map((value) => ({ type: 'CHANGE_IS_CONCEPT', value })),
),
},
states: {
interaction: {
invoke: {
// Connect to the store.
src: () => storeValueObserver,
},
id: "Interaction",
initial: "unfocused",
description: "The process of adjusting the element's value by introducing mutations through the user or developer.",
on: {
CURRENT_VALUE_CHANGE: {
cond: "eventMustContainValue",
description:
"This action overrides the entered and current value without debounce. Can be used for developers to directly adjust the value. Note: this 'sets' the value, it is not changing the 'default'. Use the SET_DEFAULT_VALUE event for the latter.",
actions: "setEnteredValueAndPublish",
},
SUBSCRIBED_VALUE_CHANGE: {
description: "Update the element machine with the latest (remote) values.",
actions: [
pure((context, event) => {
const actions = ["updateContext"];
if (context.store.lastSendSignature !== event.value.input.signature && event.value.input.signature !== context.input.signature) {
actions.push("tagAsExternalInput");
actions.push(
assign((con) => {
return {
store: {
...con.store,
targetValueCache: event.value.input.value,
},
};
})
);
// Overwrite the entered value if the last value sent is not generated from the entered value (user input).
const { enteredValue } = eventCreators.currentValueChangeEvent(
event.value.input.value,
typeof event.value.input.value === "undefined" && event.value.input.signature
);
actions.push(assign({ enteredValue }));
}
actions.push("sendTelemetry");
return actions;
}),
(context) => context.store.storeAsyncObserver.complete(),
],
},
SET_DEFAULT_VALUE: {
actions: "handleDefaultValueChange",
description: "Set the value of the default value and under the right condition also change the current and entered value to the default value.",
},
},
states: {
unfocused: {
description: "The user is not interaction with the element in any way.",
on: {
FOCUS: {
target: "focused",
cond: "interactionTrackingEnabled",
actions: "affirmUserTouch",
description: "This user selected/touched/clicked on the element, register this and change the state.",
},
ENTERED_VALUE_CHANGE: {
cond: "eventMustContainValue",
description:
"This action is triggered by developers to change the entered value (as if the user did change it) without user interaction (excludes registering an interaction within the context).",
actions: ["tagAsExternalInput", "broadcastEnteredValueAsCurrentValue"],
},
},
},
focused: {
description: "The user started engagement with the element and might consider adjusting its value",
initial: "entry",
on: {
BLUR: {
target: "unfocused",
description: "User moves away from the element.",
},
},
exit: "broadcastEnteredValueAsCurrentValue", // Save the entered value as the new current value.
states: {
mutating: {
on: {
ENTERED_VALUE_CHANGE: {
cond: "eventMustContainValue",
description: "The user is active changing the entered value.",
target: "mutating",
actions: ["tagAsUserInput", "setEnteredValue", "sendTelemetry"],
},
},
description: "The user is in active engagement with the element, providing input in pace",
after: {
// After the debounce delay expired the user is recorded as 'paused'.
DEBOUNCE_ENTERED_VALUE: "hold",
},
},
hold: {
on: {
ENTERED_VALUE_CHANGE: {
cond: "eventMustContainValue",
description: "The user resumed providing input (entered value) for the element.",
target: "mutating",
actions: ["tagAsUserInput", "setEnteredValue", "sendTelemetry"],
},
},
after: {
// After some time of inactivity the user is recorded as 'paused'.
INACTIVITY_TIME: "entry",
},
description: "The user is in active engagement with the element, but currently pausing with providing input.",
// When the interaction save mode is set to 'onDebouncedInput' the entered value is submitted as current value, triggering a publish action to the store.
entry: pure((context) => (context.saveMode === "onDebouncedInput" ? ["tagAsUserInput", "broadcastEnteredValueAsCurrentValue"] : [])),
},
entry: {
on: {
ENTERED_VALUE_CHANGE: {
cond: "eventMustContainValue",
description: "The user started providing (first) input. Record the interaction, mark the starting point and new entered value.",
target: "mutating",
actions: ["tagAsUserInput", "setEnteredValue", "sendTelemetry", "affirmUserEnteredValueChange"],
},
},
description: "The user has not provided input for the element since their last entry on the element.",
entry: pure((context) => (context.saveMode === "onBlurAndInactivity" ? ["broadcastEnteredValueAsCurrentValue"] : [])),
},
},
},
},
},
replay: {
id: "Replay",
initial: "active",
description:
"State representing the attachment to the store. If the store is not initialized yet, the events are stored to be re-run after attachment to the store.",
states: {
active: {
description: "Storing events",
invoke: {
// AsyncObserver waits till the complete() function is being called, providing all events at once.
src: (context) =>
context.store.storeAsyncObserver.pipe(
switchMap((val) => from(val)) // Convert array into separate items
),
onDone: "inactive",
},
},
inactive: {
description: "Attached to store, events are neither stored nor replayed",
final: true,
},
},
},
},
},
{
guards: elementGuards,
actions: {
...elementActions,
/**
* Indicate whether the referred id is within 'concept' mode (so could be cancelled).
*/
setIsConcept: assign((context, event) => ({
isConcept: event.value,
})),
/**
* Send telemetry to a provided function if set.
*/
sendTelemetry: (context, event) => {
if (typeof telemetryCallback === "function") {
let action = event?.type === "SUBSCRIBED_VALUE_CHANGE" || context.isConcept ? "inSync" : "change";
if (event?.type === "xstate.stop") {
action = "remove";
}
void telemetryCallback({
time: Date.now(),
id: context.id,
saveMode: context.saveMode,
initialValueReceived: context.store.initialValueReceived,
action,
});
}
},
/**
* XState action creator to call the CURRENT_VALUE_CHANGE event creator. This sends the value from the current event as a new current value. Used to set the default value as new current value.
*/
broadcastEventValueAsCurrentValue: send((context, event) => {
// Grab the event data
const { value, valueIsUndefined, enteredValue } = event;
// Call the element model CURRENT_VALUE_CHANGE event creator.
return eventCreators.currentValueChangeEvent(value, valueIsUndefined, enteredValue);
}),
/**
* Call the CURRENT_VALUE_CHANGE event creator, forwarding an entered value (human input from an element) as the new current value.
* Setting the current value through this event creator provides developers a universal way to generate/calculate/pick a current value for API's etc that fits the entered value (human input).
*/
broadcastEnteredValueAsCurrentValue: pure((context, event) => {
let enteredValue = typeof event.enteredValue !== "undefined" || event.valueIsUndefined ? event.enteredValue : context.enteredValue;
if (typeof event.enteredValue === "function") {
enteredValue = event.enteredValue(context.enteredValue);
}
const newEvent = eventCreators.currentValueChangeEvent(undefined, true, enteredValue);
// Do a simple Object.is comparison to prevent obsolete events.
if (newEvent.value !== context.input.value) {
return [send(newEvent)];
}
return [];
}),
/**
* Publish a value to the automerge document when there are changes.
*/
publishValue: pure((context, event) => {
const actions = [];
const currentTargetValueSignature = context.input.signature;
const nextTargetValue = event.value;
let nextTargetValueForSignature = event.value;
if (typeof nextTargetValueForSignature === "undefined" && event.valueIsUndefined === true) {
// Set the received value to the undefined symbol, so the MD5 hash is equivalent to other undefined values.
nextTargetValueForSignature = nothing;
}
const nextTargetValueSignature = typeof nextTargetValueForSignature !== "undefined" ? MD5(nextTargetValueForSignature) : "";
const saveObj = {};
// Compare the previous and new value whether they have changed (objects are deep compared)
if (nextTargetValueSignature && nextTargetValueSignature !== currentTargetValueSignature) {
// Only publish if the saveMode is not set to none and a value has already been received from the store.
if (context.saveMode !== "none" && context.store.initialValueReceived) {
context.store.updateStore(
eventCreators.updateFormStateValueEvent({
id: context.id,
value: nextTargetValue,
signature: nextTargetValueSignature,
previousValue: context.input.value,
previousSignature: currentTargetValueSignature,
})
);
saveObj.lastSendSignature = nextTargetValueSignature; // Save the signature for comparison
actions.push("sendTelemetry");
}
}
actions.push(
assign({
store: {
...context.store,
...saveObj,
targetValueCache: nextTargetValue, // Store to show the value faster to the user (less 'save' lag)
},
})
);
return actions;
}),
},
delays: elementDelays,
}
);
};
export default elementMachine;