tweak-tools
Version:
Tweak your React projects until awesomeness
236 lines (208 loc) • 8.72 kB
text/typescript
import { useEffect, useMemo, useCallback, useState, useRef } from 'react'
import { tweakStore } from './store'
import { folder } from './helpers'
import { useDeepMemo, useValuesForPath } from './hooks'
import { useRenderRoot } from './components/Tweak'
import type { FolderSettings, Schema, SchemaToValues, StoreType, OnChangeHandler } from './types'
import shallow from 'zustand/shallow'
type HookSettings = { store?: StoreType }
type SchemaOrFn<S extends Schema = Schema> = S | (() => S)
type FunctionReturnType<S extends Schema> = [
SchemaToValues<S>,
(value: {
[K in keyof Partial<SchemaToValues<S, true>>]: SchemaToValues<S, true>[K]
}) => void,
<T extends keyof SchemaToValues<S, true>>(path: T) => SchemaToValues<S, true>[T]
]
type ReturnType<F extends SchemaOrFn> = F extends SchemaOrFn<infer S>
? F extends Function
? FunctionReturnType<S>
: SchemaToValues<S>
: never
type HookReturnType<F extends SchemaOrFn | string, G extends SchemaOrFn> = F extends SchemaOrFn
? ReturnType<F>
: ReturnType<G>
function parseArgs(
schemaOrFolderName: string | SchemaOrFn,
settingsOrDepsOrSchema?: HookSettings | React.DependencyList | SchemaOrFn,
depsOrSettingsOrFolderSettings?: React.DependencyList | HookSettings | FolderSettings,
depsOrSettings?: React.DependencyList | HookSettings,
depsOrUndefined?: React.DependencyList
) {
let schema: SchemaOrFn
let folderName: string | undefined = undefined
let folderSettings: FolderSettings | undefined
let hookSettings: HookSettings | undefined
let deps: React.DependencyList | undefined
if (typeof schemaOrFolderName === 'string') {
folderName = schemaOrFolderName
schema = settingsOrDepsOrSchema as SchemaOrFn
if (Array.isArray(depsOrSettingsOrFolderSettings)) {
deps = depsOrSettingsOrFolderSettings
} else {
if (depsOrSettingsOrFolderSettings) {
if ('store' in depsOrSettingsOrFolderSettings) {
hookSettings = depsOrSettingsOrFolderSettings as HookSettings
deps = depsOrSettings as React.DependencyList
} else {
folderSettings = depsOrSettingsOrFolderSettings as FolderSettings
if (Array.isArray(depsOrSettings)) {
deps = depsOrSettings as React.DependencyList
} else {
hookSettings = depsOrSettings as HookSettings
deps = depsOrUndefined
}
}
}
}
} else {
schema = schemaOrFolderName as SchemaOrFn
if (Array.isArray(settingsOrDepsOrSchema)) {
deps = settingsOrDepsOrSchema as React.DependencyList
} else {
hookSettings = settingsOrDepsOrSchema as HookSettings
deps = depsOrSettingsOrFolderSettings as React.DependencyList
}
}
return { schema, folderName, folderSettings, hookSettings, deps: deps || [] }
}
/**
*
* @param schemaOrFolderName
* @param settingsOrDepsOrSchema
* @param folderSettingsOrDeps
* @param depsOrUndefined
*/
export function useTweaks<S extends Schema, F extends SchemaOrFn<S> | string, G extends SchemaOrFn<S>>(
schemaOrFolderName: F,
settingsOrDepsOrSchema?: HookSettings | React.DependencyList | G,
depsOrSettingsOrFolderSettings?: React.DependencyList | HookSettings | FolderSettings,
depsOrSettings?: React.DependencyList | HookSettings,
depsOrUndefined?: React.DependencyList
): HookReturnType<F, G> {
// We parse the args
const { folderName, schema, folderSettings, hookSettings, deps } = parseArgs(
schemaOrFolderName,
settingsOrDepsOrSchema,
depsOrSettingsOrFolderSettings,
depsOrSettings,
depsOrUndefined
)
const schemaIsFunction = typeof schema === 'function'
// Keep track of deps to see if they changed and if there's need to recompute.
const depsChanged = useRef(false)
// We will only override the store settings and options when deps have changed
// and it isn't the first render
const firstRender = useRef(true)
// Since the schema object would change on every render, we let the user have
// control over when it should trigger a reset of the hook inputs.
const _schema = useDeepMemo(() => {
depsChanged.current = true
const s = typeof schema === 'function' ? schema() : schema
return folderName ? { [folderName]: folder(s, folderSettings) } : s
}, deps)
// GlobalPanel means that no store was provided, therefore we're using the tweakStore
const isGlobalPanel = !hookSettings?.store
useRenderRoot(isGlobalPanel)
const [store] = useState(() => hookSettings?.store || tweakStore)
/**
* Parses the schema to extract the inputs initial data.
*
* This initial data will be used to initialize the store.
*
* Note that getDataFromSchema recursively
* parses the schema inside nested folder.
*/
const [initialData, mappedPaths] = useMemo(() => store.getDataFromSchema(_schema), [store, _schema])
const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] = useMemo(() => {
const allPaths: string[] = []
const renderPaths: string[] = []
const onChangePaths: Record<string, OnChangeHandler> = {}
const onEditStartPaths: Record<string, (...args: any) => void> = {}
const onEditEndPaths: Record<string, (...args: any) => void> = {}
Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient }) => {
allPaths.push(path)
if (!!onChange) {
onChangePaths[path] = onChange
if (!transient) {
renderPaths.push(path)
}
} else {
renderPaths.push(path)
}
if (onEditStart) {
onEditStartPaths[path] = onEditStart
}
if (onEditEnd) {
onEditEndPaths[path] = onEditEnd
}
})
return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths]
}, [mappedPaths])
// Extracts the paths from the initialData and ensures order of paths.
const paths = useMemo(() => store.orderPaths(allPaths), [allPaths, store])
/**
* Reactive hook returning the values from the store at given paths.
* Essentially it flattens the keys of a nested structure.
* For example { "folder.subfolder.valueKey": value } becomes { valueKey: value }
*
* initalData is going to be returned on the first render. Subsequent renders
* will call the store data.
* */
const values = useValuesForPath(store, renderPaths, initialData)
const set = useCallback(
(values: Record<string, any>) => {
const _values = Object.entries(values).reduce(
(acc, [p, v]) => Object.assign(acc, { [mappedPaths[p].path]: v }),
{}
)
store.set(_values, false)
},
[store, mappedPaths]
)
const get = useCallback((path: string) => store.get(mappedPaths[path].path), [store, mappedPaths])
useEffect(() => {
// We initialize the store with the initialData in useEffect.
// Note that doing this while rendering (ie in useMemo) would make
// things easier and remove the need for initializing useValuesForPath but
// it breaks the ref from Monitor.
// we override the settings when deps have changed and this isn't the first
// render
const shouldOverrideSettings = !firstRender.current && depsChanged.current
store.addData(initialData, shouldOverrideSettings)
firstRender.current = false
depsChanged.current = false
return () => store.disposePaths(paths)
}, [store, paths, initialData])
useEffect(() => {
// let's handle transient subscriptions
const unsubscriptions: (() => void)[] = []
Object.entries(onChangePaths).forEach(([path, onChange]) => {
onChange(store.get(path), path, { initial: true, get: store.get, ...store.getInput(path)! })
const unsub = store.useStore.subscribe(
(s) => {
const input = s.data[path]
// @ts-ignore
const value = input.disabled ? undefined : input.value
return [value, input]
},
([value, input]: any) => onChange(value, path, { initial: false, get: store.get, ...input }),
{ equalityFn: shallow }
)
unsubscriptions.push(unsub)
})
return () => unsubscriptions.forEach((unsub) => unsub())
}, [store, onChangePaths])
useEffect(() => {
const unsubscriptions: Array<() => void> = []
Object.entries(onEditStartPaths).forEach(([path, onEditStart]) =>
unsubscriptions.push(store.subscribeToEditStart(path, onEditStart))
)
Object.entries(onEditEndPaths).forEach(([path, onEditEnd]) =>
unsubscriptions.push(store.subscribeToEditEnd(path, onEditEnd))
)
return () => unsubscriptions.forEach((unsub) => unsub())
}, [onEditStartPaths, onEditEndPaths, store])
if (schemaIsFunction) return [values, set, get] as any
return values as any
}