mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
STF: support cleaning namespace information
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
'@fumadocs/story': patch
|
||||
'@fumari/stf': patch
|
||||
---
|
||||
|
||||
support cleaning namespace information
|
||||
@@ -236,6 +236,7 @@ export default function PlaygroundClient({
|
||||
if (stf.dataEngine.getData() === defaultValues) return;
|
||||
|
||||
stf.dataEngine.reset(defaultValues);
|
||||
stf.dataEngine.clearNamespaces();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
|
||||
}, [defaultValues]);
|
||||
|
||||
@@ -427,7 +428,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
const field = useResolvedSchema(_field);
|
||||
const [isJson, setIsJson] = useState(false);
|
||||
|
||||
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} />;
|
||||
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
|
||||
|
||||
if (isJson)
|
||||
return (
|
||||
@@ -454,6 +455,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
field={field}
|
||||
fieldName={['body']}
|
||||
collapsible={false}
|
||||
isRequired
|
||||
name={
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -267,7 +267,7 @@ export function FieldSet({
|
||||
const { readOnly, writeOnly } = useSchemaScope();
|
||||
const field = useResolvedSchema(_field);
|
||||
const [show, setShow] = useState(!collapsible);
|
||||
const { info, updateInfo } = useFieldInfo(fieldName, field);
|
||||
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
|
||||
const id = stringifyFieldKey(fieldName);
|
||||
const dataEngine = useDataEngine();
|
||||
const [isDefined] = useFieldValue(fieldName, {
|
||||
@@ -335,6 +335,7 @@ export function FieldSet({
|
||||
field={union[info.oneOf]}
|
||||
depth={depth + 1}
|
||||
slotType={showSelect ? false : slotType}
|
||||
collapsible={collapsible}
|
||||
toolbar={
|
||||
<>
|
||||
{showSelect && (
|
||||
@@ -374,6 +375,7 @@ export function FieldSet({
|
||||
...field,
|
||||
type: info.selectedType,
|
||||
}}
|
||||
collapsible={collapsible}
|
||||
depth={depth + 1}
|
||||
slotType={showSelect ? false : slotType}
|
||||
toolbar={
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createContext, ReactNode, use, useMemo } from 'react';
|
||||
import { getDefaultValue } from '@/playground/get-default-values';
|
||||
import type { ParsedSchema } from '@/utils/schema';
|
||||
import { mergeAllOf } from '@/utils/merge-schema';
|
||||
import { FieldKey, useDataEngine, useFieldValue } from '@fumari/stf';
|
||||
import { FieldKey, useDataEngine, useFieldValue, useNamespace } from '@fumari/stf';
|
||||
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
|
||||
interface SchemaContextType extends SchemaScope {
|
||||
@@ -89,15 +89,16 @@ export function useSchemaScope(): SchemaScope {
|
||||
export function useFieldInfo(
|
||||
fieldName: FieldKey,
|
||||
schema: Exclude<ParsedSchema, boolean>,
|
||||
depth = 0,
|
||||
): {
|
||||
info: FieldInfo;
|
||||
updateInfo: (value: Partial<FieldInfo>) => void;
|
||||
} {
|
||||
const { ajv } = use(SchemaContext)!;
|
||||
const engine = useDataEngine();
|
||||
const fieldData = engine.namespace(
|
||||
`field-info:${stringifyFieldKey(fieldName)}`,
|
||||
(): FieldInfo => {
|
||||
const fieldData = useNamespace({
|
||||
namespace: `field-info:${depth}:${stringifyFieldKey(fieldName)}`,
|
||||
initial(): FieldInfo {
|
||||
const value = engine.get(fieldName);
|
||||
const out: FieldInfo = {
|
||||
oneOf: -1,
|
||||
@@ -116,12 +117,8 @@ export function useFieldInfo(
|
||||
|
||||
out.selectedType =
|
||||
types.find((type) => {
|
||||
schema.type = type;
|
||||
const match = ajv.validate(schema, value);
|
||||
schema.type = types;
|
||||
|
||||
return match;
|
||||
}) ?? types.at(0);
|
||||
return ajv.validate({ ...schema, type }, value);
|
||||
}) ?? types[0];
|
||||
}
|
||||
|
||||
if (schema.allOf) {
|
||||
@@ -132,10 +129,9 @@ export function useFieldInfo(
|
||||
merged,
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
});
|
||||
const [info, setInfo] = useFieldValue<FieldInfo>([], {
|
||||
stf: fieldData,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fieldKeyStartsWith, objectGet, objectSet, stringifyFieldKey } from './utils';
|
||||
import {
|
||||
fieldKeyStartsWith,
|
||||
isPlainObject,
|
||||
objectGet,
|
||||
objectSet,
|
||||
stringifyFieldKey,
|
||||
} from './utils';
|
||||
import type { FieldKey } from './types';
|
||||
import { Stf, useDataEngine } from './stf';
|
||||
|
||||
@@ -28,6 +34,10 @@ export interface DataEngineListener {
|
||||
* when `field` is specified, this is also fired when parent is deleted.
|
||||
*/
|
||||
onDelete?: (key: FieldKey, ctx: OnDeleteContext) => void;
|
||||
/**
|
||||
* For listener attached to a namespace's engine, this is fired when the namespace is removed.
|
||||
*/
|
||||
onUnmount?: () => void;
|
||||
}
|
||||
|
||||
export interface OnUpdateContext {
|
||||
@@ -105,12 +115,16 @@ class ListenerManager {
|
||||
for (const v of listeners) v.onDelete?.(field, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
onUnmount() {
|
||||
for (const v of this.unindexed) v.onUnmount?.();
|
||||
for (const listeners of this.indexed.values()) for (const v of listeners) v.onUnmount?.();
|
||||
}
|
||||
}
|
||||
|
||||
export class DataEngine {
|
||||
private data: NonNullable<object>;
|
||||
/** namespace -> engine */
|
||||
private namespaces = new Map<string, DataEngine>();
|
||||
private readonly namespaces = new Map<string, DataEngine>();
|
||||
private readonly listeners = new ListenerManager();
|
||||
|
||||
constructor(defaultValues: DefaultValue<NonNullable<object>> = {}) {
|
||||
@@ -137,9 +151,9 @@ export class DataEngine {
|
||||
*/
|
||||
init(key: FieldKey, defaultValue?: DefaultValue, ctx: OnInitContext = {}): unknown {
|
||||
if (key.length === 0) return this.data;
|
||||
let cur = this.data as Record<string, unknown>;
|
||||
const currentKey: FieldKey = [];
|
||||
const parentKey: FieldKey = [];
|
||||
const parentUpdateCtx = { swallow: true, ...ctx };
|
||||
let cur = this.data as Record<string, unknown>;
|
||||
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
const propKey = key[i];
|
||||
@@ -149,21 +163,24 @@ export class DataEngine {
|
||||
if (propValue !== undefined) return propValue;
|
||||
cur[propKey] = getDefaultValue(defaultValue);
|
||||
|
||||
this.listeners.onUpdate(currentKey, parentUpdateCtx);
|
||||
this.listeners.onUpdate(parentKey, parentUpdateCtx);
|
||||
this.listeners.onInit(key, ctx);
|
||||
return cur[propKey];
|
||||
} else if (typeof propValue === 'object' && propValue !== null) {
|
||||
cur = propValue as Record<string, unknown>;
|
||||
} else if (isPlainObject(propValue)) {
|
||||
cur = propValue;
|
||||
parentKey.push(propKey);
|
||||
} else {
|
||||
if (propValue !== undefined)
|
||||
console.warn(
|
||||
`the original value of field ${currentKey.join('.')} is overidden, this might be unexpected.`,
|
||||
);
|
||||
|
||||
cur = cur[propKey] = {};
|
||||
this.listeners.onUpdate(currentKey, parentUpdateCtx);
|
||||
|
||||
this.listeners.onUpdate(parentKey, parentUpdateCtx);
|
||||
parentKey.push(propKey);
|
||||
if (propValue !== undefined) {
|
||||
console.warn(
|
||||
`the original value of field ${parentKey.join('.')} is overidden, this might be unexpected.`,
|
||||
);
|
||||
this.listeners.onUpdate(parentKey, parentUpdateCtx);
|
||||
}
|
||||
}
|
||||
currentKey.push(propKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +198,9 @@ export class DataEngine {
|
||||
// it will change children's field value when removed at middle
|
||||
this.listeners.onUpdate(parentKey, { swallow: false, ...ctx });
|
||||
return deleted[0];
|
||||
} else if (typeof parent === 'object' && parent !== null) {
|
||||
const temp = (parent as Record<string, unknown>)[prop];
|
||||
delete parent[prop as never];
|
||||
} else if (isPlainObject(parent)) {
|
||||
const temp = parent[prop];
|
||||
delete parent[prop];
|
||||
this.listeners.onDelete(key, ctx);
|
||||
this.listeners.onUpdate(parentKey, { swallow: true, ...ctx });
|
||||
return temp;
|
||||
@@ -221,10 +238,16 @@ export class DataEngine {
|
||||
return child;
|
||||
}
|
||||
|
||||
reset(data: Record<string, unknown>) {
|
||||
this.namespaces.clear();
|
||||
reset(data: NonNullable<object>) {
|
||||
this.update([], data);
|
||||
}
|
||||
|
||||
clearNamespaces() {
|
||||
for (const [name, engine] of this.namespaces) {
|
||||
this.namespaces.delete(name);
|
||||
engine.listeners.onUnmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useFieldValue<V = unknown>(
|
||||
@@ -243,13 +266,19 @@ export function useFieldValue<V = unknown>(
|
||||
isChanged?: (prev: V, next: V) => boolean;
|
||||
} = {},
|
||||
) {
|
||||
const engine = useDataEngine(options.stf);
|
||||
const { compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
|
||||
const { stf, compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
|
||||
const engine = useDataEngine(stf);
|
||||
const [value, setValue] = useState<V>(() => compute(engine.init(key, defaultValue)));
|
||||
const prevEngineRef = useRef(engine);
|
||||
|
||||
if (prevEngineRef.current !== engine) {
|
||||
setValue(compute(engine.init(key, defaultValue)));
|
||||
prevEngineRef.current = engine;
|
||||
}
|
||||
|
||||
useListener({
|
||||
field: key,
|
||||
stf: options.stf,
|
||||
stf,
|
||||
onInit() {
|
||||
const computed = compute(engine.get(key));
|
||||
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
|
||||
@@ -275,6 +304,9 @@ export function useListener(listener: DataEngineListener & { stf?: Stf | DataEng
|
||||
useEffect(() => {
|
||||
const internal: DataEngineListener = {
|
||||
field: listener.field,
|
||||
onUnmount(...args) {
|
||||
return listenerRef.current.onUnmount?.(...args);
|
||||
},
|
||||
onDelete(...args) {
|
||||
return listenerRef.current.onDelete?.(...args);
|
||||
},
|
||||
@@ -292,3 +324,22 @@ export function useListener(listener: DataEngineListener & { stf?: Stf | DataEng
|
||||
};
|
||||
}, [engine, listener.field]);
|
||||
}
|
||||
|
||||
export function useNamespace(options: {
|
||||
namespace: string;
|
||||
initial?: DefaultValue<NonNullable<object>>;
|
||||
stf?: Stf | DataEngine;
|
||||
}) {
|
||||
const { namespace, stf, initial } = options;
|
||||
const engine = useDataEngine(stf);
|
||||
const [value, setValue] = useState(() => engine.namespace(namespace, initial));
|
||||
|
||||
useListener({
|
||||
stf: value,
|
||||
onUnmount() {
|
||||
setValue(engine.namespace(namespace, initial));
|
||||
},
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ export function objectGet(obj: unknown, key: (string | number)[]): unknown | und
|
||||
let cur = obj;
|
||||
|
||||
for (const prop of key) {
|
||||
if (typeof cur !== 'object' || cur === null || !(prop in cur)) return;
|
||||
|
||||
cur = cur[prop as keyof typeof cur];
|
||||
if (!isPlainObject(cur) || !(prop in cur)) return;
|
||||
cur = cur[prop];
|
||||
}
|
||||
|
||||
return cur;
|
||||
@@ -23,8 +22,8 @@ export function objectSet(obj: unknown, key: FieldKey, value: unknown): unknown
|
||||
}
|
||||
|
||||
const parent = objectGet(obj, key.slice(0, -1));
|
||||
if (typeof parent !== 'object' || parent === null) throw new Error('missing parent object');
|
||||
(parent as Record<string, unknown>)[key[key.length - 1]] = value;
|
||||
if (!isPlainObject(parent)) throw new Error('missing parent object');
|
||||
parent[key[key.length - 1]] = value;
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -55,22 +54,21 @@ export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a as object);
|
||||
const keysB = Object.keys(b as object);
|
||||
if (!isPlainObject(a) || !isPlainObject(b)) {
|
||||
return false;
|
||||
}
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return keysA.every(
|
||||
(key) =>
|
||||
Object.prototype.hasOwnProperty.call(b, key) &&
|
||||
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
||||
);
|
||||
return keysA.every((key) => key in b && deepEqual(a[key], b[key]));
|
||||
}
|
||||
|
||||
export function stringifyFieldKey(fieldKey: FieldKey) {
|
||||
return fieldKey.map((v) => `${typeof v}:${v}`).join('.');
|
||||
return fieldKey.map((v) => (typeof v === 'string' ? `_${v}` : `n${v}`)).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,3 +77,16 @@ export function stringifyFieldKey(fieldKey: FieldKey) {
|
||||
export function fieldKeyStartsWith(a: string, b: string): boolean {
|
||||
return b.length === 0 || a === b || a.startsWith(b + '.');
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
prototype === null ||
|
||||
prototype === Object.prototype ||
|
||||
Object.getPrototypeOf(prototype) === null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { type ComponentProps, type HTMLAttributes, type ReactNode, useState } from 'react';
|
||||
import { ChevronRight, Plus, Trash2, X } from 'lucide-react';
|
||||
import { FieldKey, useArray, useDataEngine, useFieldValue, useObject } from '@fumari/stf';
|
||||
import {
|
||||
FieldKey,
|
||||
useArray,
|
||||
useDataEngine,
|
||||
useFieldValue,
|
||||
useNamespace,
|
||||
useObject,
|
||||
} from '@fumari/stf';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -226,30 +233,30 @@ export function FieldInput({
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldSet(
|
||||
props: HTMLAttributes<HTMLElement> & {
|
||||
isRequired?: boolean;
|
||||
name?: ReactNode;
|
||||
field: TypeNode;
|
||||
fieldName: FieldKey;
|
||||
export function FieldSet({
|
||||
depth = 0,
|
||||
field: _field,
|
||||
fieldName,
|
||||
toolbar,
|
||||
name,
|
||||
isRequired,
|
||||
slotType,
|
||||
collapsible = true,
|
||||
...rest
|
||||
}: HTMLAttributes<HTMLElement> & {
|
||||
isRequired?: boolean;
|
||||
name?: ReactNode;
|
||||
field: TypeNode;
|
||||
fieldName: FieldKey;
|
||||
depth?: number;
|
||||
|
||||
slotType?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
collapsible?: boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
field,
|
||||
fieldName,
|
||||
toolbar,
|
||||
name,
|
||||
isRequired,
|
||||
slotType,
|
||||
collapsible = true,
|
||||
...rest
|
||||
} = props;
|
||||
slotType?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
const field = _field.type === 'intersection' ? _field.intersection : _field;
|
||||
const [show, setShow] = useState(!collapsible);
|
||||
const { info, updateInfo } = useFieldInfo(fieldName, field);
|
||||
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
|
||||
const id = stringifyFieldKey(fieldName);
|
||||
const dataEngine = useDataEngine();
|
||||
const onShow = () => {
|
||||
@@ -267,6 +274,7 @@ export function FieldSet(
|
||||
<FieldSet
|
||||
{...rest}
|
||||
name={name}
|
||||
depth={depth + 1}
|
||||
fieldName={fieldName}
|
||||
isRequired={isRequired}
|
||||
field={selectedType}
|
||||
@@ -297,10 +305,6 @@ export function FieldSet(
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'intersection') {
|
||||
return <FieldSet {...props} field={field.intersection} />;
|
||||
}
|
||||
|
||||
if (field.type === 'object') {
|
||||
return (
|
||||
<fieldset
|
||||
@@ -465,14 +469,15 @@ interface FieldInfo {
|
||||
function useFieldInfo(
|
||||
fieldName: FieldKey,
|
||||
node: TypeNode,
|
||||
depth = 0,
|
||||
): {
|
||||
info: FieldInfo;
|
||||
updateInfo: (value: Partial<FieldInfo>) => void;
|
||||
} {
|
||||
const engine = useDataEngine();
|
||||
const fieldData = engine.namespace(
|
||||
`field-info:${stringifyFieldKey(fieldName)}`,
|
||||
(): FieldInfo => {
|
||||
const fieldData = useNamespace({
|
||||
namespace: `field-info:${depth}:${stringifyFieldKey(fieldName)}`,
|
||||
initial(): FieldInfo {
|
||||
const out: FieldInfo = {
|
||||
unionIndex: 0,
|
||||
};
|
||||
@@ -485,7 +490,7 @@ function useFieldInfo(
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
});
|
||||
const [info, setInfo] = useFieldValue<FieldInfo>([], {
|
||||
stf: fieldData,
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export function WithControl({ presets, displayName, Component }: WithControlProp
|
||||
if (preset) {
|
||||
setVariant(value);
|
||||
stf.dataEngine.reset(preset.defaultValues ?? {});
|
||||
stf.dataEngine.clearNamespaces();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user