import PropTypes from "prop-types"
import React, { useContext, useEffect, useRef, useState } from "react"
import { get, set } from "./get-set"
import {
getPath,
getPatterns,
returnValue,
standardExtract,
targetIds
} from "./observable-state-helpers"
import Events from "./emitter"
import { ensureArray } from "./event-bus"
import { debounce } from "../../example/src/utils"
let stateId = 0
let nextId = 0
let refreshId = 0
const IndexContext = React.createContext(0)
const useProperty = Symbol("useProperty")
const useEvent = Symbol("useEvent")
const emit = Symbol("emit")
export function useIndex() {
return useContext(IndexContext)
}
function Dummy({ children }) {
return <>{children}</>
}
function withDefault(v, d) {
return v !== undefined ? v : d
}
function noop() {}
noop.refresh = noop
/**
* @callback TransformValue
* @param {any} value - the value to be transformed
* @returns {any} the transformed value
*/
/**
* @callback Extractor
* @param {any} event - the parameter of the event handler
* @returns {any} the extracted value
*/
/**
* Creates a state with a given name, the state is created
* unbound
* @param {string} name - a name for the state
* @returns {State}
* @example
*
* const state = createState("global")
* const {Bind, bind} = state
* const Input = bind({component: <TextField variant="outlined"/>})
* const stateObject = {title: "global"}
*
* function App() {
* return <Bind target={stateObject}>
* <Inner/>
* </Bind>
* }
*
* function Inner() {
* return <div>
* <Input property="title" label="title"/>
* <Input property="somethingElse" label="other" defaultValue=""/>
* </div>
* }
*/
export function createState(name) {
return new State(name)
}
function getTargetFrom(property, target, path, stack) {
for (let i = 0; i < property.length && i < stack.length - 1; i++) {
if (property[i] === "^") {
let step = stack[stack.length - 2 - i]
target = step.target
path = step.path
} else {
break
}
}
return [property.replace(/^\^*/g, ""), target, path]
}
function useRefresh() {
const [id, refresh] = useState(-1)
const currentRefresh = useRef()
React.useEffect(() => {
return () => {
currentRefresh.current = noop
}
}, [])
currentRefresh.current = refresh
_refresh.id = id
return _refresh
function _refresh() {
currentRefresh.current(refreshId++)
}
}
function useClearableState(initial) {
const [value, setValue] = React.useState(initial)
const setter = useRef()
setter.current = setValue
React.useEffect(() => {
return () => {
setter.current = noop
}
}, [])
return [value, _setValue]
function _setValue(v) {
setter.current(v)
}
}
const useTargetContext = Symbol("useTargetContext")
/**
* A class representing a unique state
* @hideconstructor
*/
class State {
[useTargetContext]() {
return useContext(this.context)
}
[useProperty](property, handler, target) {
let { target: existingTarget, path, stack } = this[useTargetContext]()
target = target || existingTarget
;[property, target, path] = getTargetFrom(property, target, path, stack)
if (handler) {
this[useEvent](
getPatterns(target, [...path, ...getPath(property)]),
handler
)
}
return { value: get(target, property), target, path, property }
}
/**
* Provides a handler that is called when a property value has been
* updated
* @param {string} property - the property that is updated
* @param {function} handler - the handler for the property change, it will be
* called with the updated value
* @param {object} [target] - an optional target override
* @example
* import debounce from 'lodash/debounce'
*
* function Component() {
* const saveProfile = React.useMemo(()=>debounce(_saveProfile, 300), [])
* globalState.useChange("profile.**", saveProfile)
*
* function saveProfile(profile) {
* localStorage.setItem("profile", JSON.stringify(profile))
* }
* }
*/
useChange(property, handler, target) {
let { target: existingTarget, path, stack } = this[useTargetContext]()
target = target || existingTarget
;[property, target, path] = getTargetFrom(property, target, path, stack)
this[useEvent](
getPatterns(target, [...path, ...getPath(property)]),
() => {
const value = get(target, property)
handler(value)
}
)
}
/**
* Creates a calculation that depends on other property values
* and is automatically updated when they change
* @param {string} property - the property to be created
* @param {string[]} dependencies - an array of dependencies for the property,
* the update function will be passed the values of these
* @param {function} fn - a function that will be called with the dependency
* values and returns the updated calculation
* @param {any} [target] - an override for the current target (rarely used)
* @example
* function Component() {
* globalState.useCalculation("profile.fullName", ["profile.firstName", "profile.lastName"],
* (firstName, lastName) => `${firstName} ${lastName}`
* )
* }
*
* function Label({value, ...props}) {
* return <div {...props}>{value}</label>
* }
*
* const BoundLabel = globalState.bind({component: <Label className={"label"}/> })
*
* function FullNameLabelExample() {
* return <Label property="profile.fullName" />
* }
*/
useCalculation(property, dependencies, fn, target) {
let { target: existingTarget, path, stack } = this[useTargetContext]()
target = target || existingTarget
;[property, target, path] = getTargetFrom(property, target, path, stack)
const update = React.useMemo(() => _update.bind(this), [])
for (let dependency of dependencies) {
this[useEvent](
getPatterns(target, [...path, ...getPath(dependency)]),
update
)
}
update()
function _update() {
const values = dependencies.map((d) => {
return get(target, d)
})
const newValue = fn.apply(this, values)
console.log(newValue)
set(target, property, newValue)
this[emit](target, path, property, newValue)
}
}
/**
* Provides a way of creating binding values
* for a component
* @param {string} property - the property of the current state to bind
* @param {any} [defaultValue] - the default value for the property
* @param {TransformValue} [transformIn] - a function to transform inbound values
* @param {TransformValue} [transformOut] - a function to transform outbound values
* @param {any} [updateOnBlur] - set if the component should only update when it blurs
* @param {Extractor} [extract] - a function that transforms event values to real values - default
* version will extract from event.target.value if available, otherwise the value itself
* @param {Function} onChange
* @param {string} [attribute="value"] - the attribute to bind to
* @param {string} [event="onChange"] - the event to be bound for changes
* @param {string} [blurEvent="onBlur"] - the event for blurring
* @param {object} [target] - an override for the target
* @returns {object} an object containing the specified value and change function
* @example
*
* const {props} = state.useBinding()
* return <input {...props}/>
*/
useBinding(
property,
{
defaultValue,
transformIn = returnValue,
transformOut = returnValue,
updateOnBlur,
extract = standardExtract,
onChange = noop,
attribute = "value",
event = "onChange",
blurEvent = "onBlur",
target
} = {}
) {
const changed = useRef(false)
let rawValue, path
;({ value: rawValue, target, property, path } = this[useProperty](
property,
update,
target
))
const value = useRef(transformIn(withDefault(rawValue, defaultValue)))
const [localValue, setLocalValue] = useClearableState(value.current)
const [updateValue, blur] = React.useMemo(() => {
return [_updateValue.bind(this), _blur.bind(this)]
}, [])
const refresh = useRefresh()
return {
[attribute]: localValue,
[event]: updateValue,
[blurEvent]: blur
}
function update() {
let newValue = transformIn(get(target, property, defaultValue))
if (newValue !== value.current) {
value.current = newValue
setLocalValue(value.current)
}
refresh()
}
function _updateValue(...params) {
let currentValue = extract(...params)
const newValue = transformOut(currentValue)
if (updateOnBlur) {
value.current = newValue
changed.current = true
setLocalValue(currentValue)
} else {
set(target, property, newValue)
onChange(newValue)
this[emit](target, path, property, newValue)
}
}
function _blur() {
if (changed.current) {
changed.current = false
set(target, property, value.current)
onChange(value.current)
this[emit](target, path, property, value.current)
}
}
}
constructor(name) {
this.name = name
this.id = stateId++
this.context = React.createContext({
target: null,
path: [],
stack: []
})
this.events = new Events()
this[useEvent] = (pattern, handler, context) => {
if (context) {
handler = handler.bind(context)
}
useEffect(() => {
ensureArray(pattern).forEach((pattern) =>
this.events.on(pattern, handler)
)
return () => {
ensureArray(pattern).forEach((pattern) =>
this.events.off(pattern, handler)
)
}
}, [pattern])
}
this[emit] = (target, path, property, value) => {
this.events.emit(
`${[...path, ...getPath(property)].filter(Boolean).join(".")}`,
value
)
}
this.Bind = this.Bind.bind(this)
this.Bound = this.Bound.bind(this)
this.bind = this.bind.bind(this)
this.useState = this.useState.bind(this)
this.useCurrentPath = this.useCurrentPath.bind(this)
this.useCurrentTarget = this.useCurrentTarget.bind(this)
}
Bind = Bind
Bound = Bound
/**
* Provides access to information in the state that will be updated
* any time a state change would affect it
* @param {string} property - the property path of the state required
* @param {any} [defaultValue] - a default value for the state
* @param {object} [target] - an override for the standard state
* @returns {Array} an array containing the state value and an update function
* @example
*
* const [name, setName] = state.useState("person.firstName")
* return <div onClick={clearFirstName}>{name}</div>
* function clearFirstName() {
* setName("")
* }
*/
useState(property = "", defaultValue, target) {
const updateValue = React.useMemo(() => {
const updateMany = _updateMany.bind(this)
const updateValue = _updateValue.bind(this)
updateValue.set = updateMany
return updateValue
}, [])
let value, path
({ value, path, property, target } = this[useProperty](property, update, target))
const refresh = useRefresh()
return [withDefault(value, defaultValue), updateValue, refresh.id]
function update() {
refresh()
}
function _updateValue(newValue) {
if (typeof newValue === "function") {
newValue = newValue(get(target, property, defaultValue))
}
set(target, property, newValue)
this[emit](target, path, property, newValue)
}
function _updateMany(newValue) {
recurseSet.call(this, newValue, value, [
...path,
...getPath(property)
])
}
}
/**
* @function Setter
* @param {any} value - the value to set
*
*/
/**
* Returns a setter for properties
* @param {string} property - the property to set
* @param {any} [target] - an override for the current value
* @returns {Setter} - a value to set other values
* @example
*
* function Component() {
* const setValue = someState.useSetter("some.object.property")
* return <Button onClick={clear}>Clear</Button>
* function clear() {
* setValue({})
* }
* }
*
*/
useSetter(property = "", target) {
let path
;({ property, target, path } = this[useProperty](
property,
null,
target
))
return React.useMemo(() => {
const updateMany = _updateMany.bind(this)
const updateValue = _updateValue.bind(this)
updateValue.set = updateMany
return updateValue
}, [])
function _updateValue(newValue) {
if (typeof newValue === "function") {
newValue = newValue(get(target, property))
}
set(target, property, newValue)
this[emit](target, path, property, newValue)
}
function _updateMany(newValue) {
recurseSet.call(this, newValue, get(target, property, {}), [
...path,
...getPath(property)
])
}
}
/**
* Causes the caller to refresh if any of the paths change
* @param {Array.<string>|string} paths - the paths to refresh on
* @returns {number} the current unique id of the refresh
* @example
* function Component() {
* const [style] = globalState.useState("style")
* // Update if any sub property of style changes
* globalState.useRefresh("style.**")
* return <div style={{...style}}>Some Content</div>
* }
*/
useRefresh(...paths) {
const { target, path } = this[useTargetContext]()
const patterns = []
for (let p of paths.flat(Infinity)) {
patterns.push(...getPatterns(target, [...path, ...getPath(p)]))
}
const refresh = useRefresh()
this[useEvent](Array.from(new Set(patterns)), refresh)
return refresh.id
}
/**
* Returns a bound component, the properties of the bound
* component are extended on use
* @see Bound
* @param {BoundProps} bindingProps - the properties of the binding
* @returns {Function} a bound component
* @example
*
* const Input = state.bind({component: <input style={{fontSize: '120%'}} />})
*
* function Example() {
* return <div>
* <Input property="firstName"/>
* <Input property="lastName"/>
* </div>
* }
*/
bind(bindingProps) {
const self = this
return function ({ state = self, ...props }) {
return <state.Bound {...bindingProps} {...props} />
}
}
/**
* Returns the current target of the the context
* @returns {object} the target
* @example
*
* const current = state.useCurrentTarget()
* const copy = JSON.parse(JSON.stringify(current))
*/
useCurrentTarget() {
const { target } = this[useTargetContext]()
return target
}
/**
* Returns the current path of the context
* @returns {Array<string>} the current path to the bound target
*/
useCurrentPath() {
const { path } = this[useTargetContext]()
return path
}
}
/**
* @interface BoundProps
* @property {object} [component=<input/>] - the component to be bound as an executed JSX expression
* @property {string} [property] - the property to which the component should be bound
* @property {any} [defaultValue] - a default value for the property
* @property {TransformValue} [transformIn] - a function to transform inbound values
* @property {TransformValue} [transformOut] - a function to transform outbound values
* @property {any} [updateOnBlur] - set if the component should only update when it blurs
* @property {Extractor} [extract] - a function that transforms event values to real values - default
* version will extract from event.target.value if available, otherwise the value itself
* @property {string} [attribute="value"] - the attribute to bind to
* @property {string} [event="onChange"] - the event to be bound for changes
* @property {string} [blurEvent="onBlur"] - the event for blurring
* @property {object} [target] - an override for the target
*/
/**
* Returns a component bound to the state model
* @function Bound
* @memberOf State
* @param {BoundProps} props
* @returns {Function} a component to be rendered
* @instance
* @example
*
* function SubComponent() {
* return <div>
* <Bound component={<TextField variant="outlined"/>} property="name"/>
* </div>
* }
*
*/
function Bound({
component = <input />,
property,
defaultValue,
transformIn,
transformOut,
extract,
attribute,
updateOnBlur,
blurEvent,
event,
target,
...other
}) {
const Component = (component && component.type) || Dummy
const props = (component && component.props) || {}
const extraProps = this.useBinding(property, {
defaultValue,
transformIn,
transformOut,
extract,
attribute,
event,
target,
blurEvent,
updateOnBlur
})
return <Component {...extraProps} {...props} {...other} />
}
Bound.propTypes = {
attribute: PropTypes.string,
blurEvent: PropTypes.any,
component: PropTypes.object,
defaultValue: PropTypes.any,
event: PropTypes.string,
extract: PropTypes.func,
property: PropTypes.string,
target: PropTypes.object,
transformIn: PropTypes.func,
transformOut: PropTypes.func,
updateOnBlur: PropTypes.any
}
Bound.defaultProps = {
component: <input />
}
function recurseSet(newValue, target, path = []) {
for (let [key, updatedValue] of Object.entries(newValue)) {
if (typeof updatedValue === "object" && !Array.isArray(updatedValue)) {
recurseSet.call(this, updatedValue, get(target, key, {}), [
...path,
key
])
} else {
set(target, key, updatedValue)
this[emit](target, path, `${key}`, updatedValue)
}
}
}
/**
* Used to notify of events
* @callback ChangeEvent
* @param {object} target - the target that has been changed
*/
/**
* @interface BindProps
* @property {object} [target] - the target of the binding
* @property {string} [property] - the property of the current binding to use
* @property {ChangeEvent} [onChange] - called when any child of the binding changes
* @property {Function|Array} [children] - the children of this binding
*/
/**
* A binding target, linking the state to an object
* @method Bind
* @memberOf State
* @instance
* @param {BindProps} props - properties
* @returns {Function} the JS component
* @example
*
* const state = createState("global")
* let someState = {id: 1234, name: "Mike"}
*
* function App() {
* return <state.Bind target={someState}>
* <InnerComponents/>
* </state.Bind>
* }
*
*/
function Bind({ target, property = "", onChange = () => {}, children }) {
const self = this
const innerId = React.useRef(refreshId++)
let { target: existingTarget, path, stack } = this[useTargetContext]()
if (target && !targetIds.has(target)) {
targetIds.set(target, nextId++)
path = [`${targetIds.get(target)}`]
} else if (target) {
path = [`${targetIds.get(target)}`]
} else {
target = existingTarget
}
const [finalTarget, setFinalTarget] = useClearableState(target)
this[useEvent](`${targetIds.get(finalTarget)}`, update)
let updatedPath = [...path, ...getPath(property)]
this[useEvent](
getPatterns(finalTarget, updatedPath).map((p) => `${p}.**`),
() => onChange(finalTarget)
)
const [subTarget, , , id] = this.useState(property, {}, finalTarget)
if (Array.isArray(subTarget)) {
return <ArrayContents key={id} />
} else {
if (typeof subTarget !== "object")
throw new Error("You must bind to an object or an array")
return (
<this.context.Provider
key={`${id}:${innerId.current}`}
value={{
target: subTarget,
path: updatedPath,
stack: [...stack, { target: subTarget, path: updatedPath }]
}}
>
{children}
</this.context.Provider>
)
}
function update(newValue) {
targetIds.set(newValue, targetIds.get(target))
innerId.current = refreshId++
setFinalTarget(newValue)
}
function ArrayContents() {
let output = []
for (let i = 0; i < subTarget.length; i++) {
output.push(<Item key={i} index={i} />)
}
return output
}
function Item({ index }) {
return (
<IndexContext.Provider value={index}>
<self.Bind property={`${property}.${index}`}>
{children}
</self.Bind>
</IndexContext.Provider>
)
}
}
Bind.propTypes = {
children: PropTypes.any,
onChange: PropTypes.func,
property: PropTypes.string.isRequired,
target: PropTypes.object
}
Bind.defaultProps = {
onChange: () => {},
property: ""
}
Source