mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Stf: stable release
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@fumadocs/story': patch
|
||||
---
|
||||
|
||||
Support namespace in data engine, remove `attachedData()` API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@fumari/stf': major
|
||||
---
|
||||
|
||||
stable release
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumari/json-schema-to-typescript": "^2.0.0",
|
||||
"@fumari/stf": "workspace:^",
|
||||
"@fumari/stf": "workspace:*",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ajv2020 } from 'ajv/dist/2020';
|
||||
import { createContext, ReactNode, use, useMemo, useState } from 'react';
|
||||
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 } from '@fumari/stf';
|
||||
import { FieldKey, useDataEngine, useFieldValue } from '@fumari/stf';
|
||||
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
|
||||
interface SchemaContextType extends SchemaScope {
|
||||
references: Record<string, ParsedSchema>;
|
||||
@@ -94,53 +95,54 @@ export function useFieldInfo(
|
||||
} {
|
||||
const { ajv } = use(SchemaContext)!;
|
||||
const engine = useDataEngine();
|
||||
const attachedData = engine.attachedData<FieldInfo>('field-info');
|
||||
const [info, setInfo] = useState<FieldInfo>(() => {
|
||||
const value = engine.get(fieldName);
|
||||
const initialInfo = attachedData.get(fieldName);
|
||||
if (initialInfo) return initialInfo;
|
||||
const fieldData = engine.namespace(
|
||||
`field-info:${stringifyFieldKey(fieldName)}`,
|
||||
(): FieldInfo => {
|
||||
const value = engine.get(fieldName);
|
||||
const out: FieldInfo = {
|
||||
oneOf: -1,
|
||||
};
|
||||
const union = getUnion(schema);
|
||||
if (union) {
|
||||
const [members, field] = union;
|
||||
|
||||
const out: FieldInfo = {
|
||||
oneOf: -1,
|
||||
};
|
||||
const union = getUnion(schema);
|
||||
if (union) {
|
||||
const [members, field] = union;
|
||||
out.oneOf = members.findIndex((item) => ajv.validate(item, value));
|
||||
if (out.oneOf === -1) out.oneOf = 0;
|
||||
out.unionField = field;
|
||||
}
|
||||
|
||||
out.oneOf = members.findIndex((item) => ajv.validate(item, value));
|
||||
if (out.oneOf === -1) out.oneOf = 0;
|
||||
out.unionField = field;
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
const types = schema.type;
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
const types = schema.type;
|
||||
out.selectedType =
|
||||
types.find((type) => {
|
||||
schema.type = type;
|
||||
const match = ajv.validate(schema, value);
|
||||
schema.type = types;
|
||||
|
||||
out.selectedType =
|
||||
types.find((type) => {
|
||||
schema.type = type;
|
||||
const match = ajv.validate(schema, value);
|
||||
schema.type = types;
|
||||
return match;
|
||||
}) ?? types.at(0);
|
||||
}
|
||||
|
||||
return match;
|
||||
}) ?? types.at(0);
|
||||
}
|
||||
if (schema.allOf) {
|
||||
const merged = mergeAllOf(schema);
|
||||
|
||||
if (schema.allOf) {
|
||||
const merged = mergeAllOf(schema);
|
||||
if (typeof merged !== 'boolean')
|
||||
out.intersection = {
|
||||
merged,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof merged !== 'boolean')
|
||||
out.intersection = {
|
||||
merged,
|
||||
};
|
||||
}
|
||||
|
||||
return out;
|
||||
return out;
|
||||
},
|
||||
);
|
||||
const [info, setInfo] = useFieldValue<FieldInfo>([], {
|
||||
stf: fieldData,
|
||||
});
|
||||
|
||||
attachedData.set(fieldName, info);
|
||||
return {
|
||||
info,
|
||||
updateInfo: (value) => {
|
||||
updateInfo(value) {
|
||||
const updated = {
|
||||
...info,
|
||||
...value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { objectGet, objectSet, stringifyFieldKey } from './utils';
|
||||
import { fieldKeyStartsWith, objectGet, objectSet, stringifyFieldKey } from './utils';
|
||||
import type { FieldKey } from './types';
|
||||
import { Stf, useDataEngine } from './stf';
|
||||
|
||||
@@ -21,18 +21,31 @@ export interface DataEngineListener {
|
||||
/**
|
||||
* when `init(field)` is called
|
||||
*/
|
||||
onInit?: (key: FieldKey) => void;
|
||||
onInit?: (key: FieldKey, ctx: OnInitContext) => void;
|
||||
/**
|
||||
* when `delete(field)` is called
|
||||
*/
|
||||
onDelete?: (key: FieldKey) => void;
|
||||
onDelete?: (key: FieldKey, ctx: OnDeleteContext) => void;
|
||||
}
|
||||
|
||||
interface OnUpdateContext {
|
||||
export interface OnUpdateContext {
|
||||
/**
|
||||
* An update is swallow if the change doesn't affect the values of children fields.
|
||||
*/
|
||||
swallow: boolean;
|
||||
|
||||
/** custom data */
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OnDeleteContext {
|
||||
/** custom data */
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OnInitContext {
|
||||
/** custom data */
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class ListenerManager {
|
||||
@@ -67,29 +80,30 @@ class ListenerManager {
|
||||
if (set) for (const v of set) v.onUpdate?.(field, ctx);
|
||||
} else {
|
||||
for (const [k, listeners] of this.indexed.entries()) {
|
||||
if (updatedKey.length !== 0 && k !== updatedKey && !k.startsWith(updatedKey + '.'))
|
||||
continue;
|
||||
if (!fieldKeyStartsWith(k, updatedKey)) continue;
|
||||
|
||||
for (const v of listeners) v.onUpdate?.(field, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onInit(field: FieldKey) {
|
||||
for (const v of this.unindexed) v.onInit?.(field);
|
||||
onInit(field: FieldKey, ctx: OnInitContext) {
|
||||
for (const v of this.unindexed) v.onInit?.(field, ctx);
|
||||
const set = this.indexed.get(stringifyFieldKey(field));
|
||||
if (set) for (const v of set) v.onInit?.(field);
|
||||
if (set) for (const v of set) v.onInit?.(field, ctx);
|
||||
}
|
||||
|
||||
onDelete(field: FieldKey) {
|
||||
for (const v of this.unindexed) v.onDelete?.(field);
|
||||
onDelete(field: FieldKey, ctx: OnDeleteContext) {
|
||||
for (const v of this.unindexed) v.onDelete?.(field, ctx);
|
||||
const set = this.indexed.get(stringifyFieldKey(field));
|
||||
if (set) for (const v of set) v.onDelete?.(field);
|
||||
if (set) for (const v of set) v.onDelete?.(field, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
export class DataEngine {
|
||||
private data: NonNullable<object>;
|
||||
private attachedDataMap = new Map<string, unknown>();
|
||||
/** namespace -> engine */
|
||||
private namespaces = new Map<string, DataEngine>();
|
||||
private readonly listeners = new ListenerManager();
|
||||
|
||||
constructor(defaultValues: DefaultValue<NonNullable<object>> = {}) {
|
||||
@@ -114,10 +128,11 @@ export class DataEngine {
|
||||
* @param defaultValue the initial value, the field is also created for `undefined`
|
||||
* @returns the value of initialized field, or the current value of field if already initialized
|
||||
*/
|
||||
init(key: FieldKey, defaultValue?: DefaultValue): unknown {
|
||||
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 parentUpdateCtx = { swallow: true, ...ctx };
|
||||
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
const propKey = key[i];
|
||||
@@ -127,8 +142,8 @@ export class DataEngine {
|
||||
if (propValue !== undefined) return propValue;
|
||||
cur[propKey] = getDefaultValue(defaultValue);
|
||||
|
||||
this.listeners.onUpdate(currentKey, { swallow: true });
|
||||
this.listeners.onInit(key);
|
||||
this.listeners.onUpdate(currentKey, parentUpdateCtx);
|
||||
this.listeners.onInit(key, ctx);
|
||||
return cur[propKey];
|
||||
} else if (typeof propValue === 'object' && propValue !== null) {
|
||||
cur = propValue as Record<string, unknown>;
|
||||
@@ -139,28 +154,30 @@ export class DataEngine {
|
||||
);
|
||||
|
||||
cur = cur[propKey] = {};
|
||||
this.listeners.onUpdate(currentKey, { swallow: true });
|
||||
this.listeners.onUpdate(currentKey, parentUpdateCtx);
|
||||
}
|
||||
currentKey.push(propKey);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: FieldKey): unknown | undefined {
|
||||
delete(key: FieldKey, ctx: OnDeleteContext = {}): unknown | undefined {
|
||||
if (key.length === 0) return;
|
||||
const parentKey = key.slice(0, -1);
|
||||
const prop = key[key.length - 1];
|
||||
const parent = this.get(parentKey);
|
||||
|
||||
if (Array.isArray(parent) && typeof prop === 'number') {
|
||||
const [deleted] = parent.splice(prop, 1);
|
||||
this.listeners.onUpdate(parentKey, { swallow: false });
|
||||
this.listeners.onDelete(key);
|
||||
return deleted;
|
||||
const deleted: unknown[] = parent.splice(prop, 1);
|
||||
if (deleted.length === 0) return;
|
||||
this.listeners.onUpdate(parentKey, { swallow: false, ...ctx });
|
||||
this.listeners.onDelete(key, ctx);
|
||||
|
||||
return deleted[0];
|
||||
} else if (typeof parent === 'object' && parent !== null) {
|
||||
const temp = (parent as Record<string, unknown>)[prop];
|
||||
delete parent[prop as never];
|
||||
this.listeners.onUpdate(parentKey, { swallow: true });
|
||||
this.listeners.onDelete(key);
|
||||
this.listeners.onUpdate(parentKey, { swallow: true, ...ctx });
|
||||
this.listeners.onDelete(key, ctx);
|
||||
return temp;
|
||||
}
|
||||
}
|
||||
@@ -173,40 +190,31 @@ export class DataEngine {
|
||||
* update the value of field if it exists
|
||||
* @returns if the field is updated
|
||||
*/
|
||||
update(key: FieldKey, value: unknown): boolean {
|
||||
update(key: FieldKey, value: unknown, ctx?: Partial<OnUpdateContext>): boolean {
|
||||
try {
|
||||
this.data = objectSet(this.data, key, value) as NonNullable<object>;
|
||||
this.listeners.onUpdate(key, { swallow: false });
|
||||
this.listeners.onUpdate(key, { swallow: false, ...ctx });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
attachedData<T>(namespace: string) {
|
||||
return {
|
||||
get: (field: FieldKey): T | undefined => {
|
||||
return this.attachedDataMap.get(`${namespace}:${stringifyFieldKey(field)}`) as
|
||||
| T
|
||||
| undefined;
|
||||
},
|
||||
set: (field: FieldKey, value: T) => {
|
||||
this.attachedDataMap.set(`${namespace}:${stringifyFieldKey(field)}`, value);
|
||||
},
|
||||
delete: (field?: FieldKey) => {
|
||||
if (field) {
|
||||
this.attachedDataMap.delete(`${namespace}:${stringifyFieldKey(field)}`);
|
||||
} else {
|
||||
for (const key of this.attachedDataMap.keys()) {
|
||||
if (key.startsWith(`${namespace}:`)) this.attachedDataMap.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
/**
|
||||
* create an isolated data engine
|
||||
*/
|
||||
namespace(namespace: string, initialValue?: DefaultValue<NonNullable<object>>) {
|
||||
let child = this.namespaces.get(namespace);
|
||||
if (!child) {
|
||||
child = new DataEngine(initialValue);
|
||||
this.namespaces.set(namespace, child);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
reset(data: Record<string, unknown>) {
|
||||
this.attachedDataMap.clear();
|
||||
this.namespaces.clear();
|
||||
this.update([], data);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +222,7 @@ export class DataEngine {
|
||||
export function useFieldValue<V = unknown>(
|
||||
key: FieldKey,
|
||||
options: {
|
||||
stf?: Stf;
|
||||
stf?: Stf | DataEngine;
|
||||
defaultValue?: DefaultValue;
|
||||
|
||||
/**
|
||||
@@ -233,6 +241,7 @@ export function useFieldValue<V = unknown>(
|
||||
|
||||
useListener({
|
||||
field: key,
|
||||
stf: options.stf,
|
||||
onUpdate() {
|
||||
const computed = compute(engine.get(key));
|
||||
if (isChanged(value, computed)) setValue(computed);
|
||||
@@ -246,7 +255,7 @@ export function useFieldValue<V = unknown>(
|
||||
return [value, (newValue: unknown) => engine.update(key, newValue)] as const;
|
||||
}
|
||||
|
||||
export function useListener(listener: DataEngineListener & { stf?: Stf }) {
|
||||
export function useListener(listener: DataEngineListener & { stf?: Stf | DataEngine }) {
|
||||
const engine = useDataEngine(listener.stf);
|
||||
const listenerRef = useRef(listener);
|
||||
listenerRef.current = listener;
|
||||
|
||||
@@ -34,8 +34,9 @@ export function useStf(options: {
|
||||
);
|
||||
}
|
||||
|
||||
export function useDataEngine(stf?: Stf) {
|
||||
if (stf) return stf.dataEngine;
|
||||
export function useDataEngine(instance?: Stf | DataEngine) {
|
||||
if (instance instanceof DataEngine) return instance;
|
||||
if (instance) return instance.dataEngine;
|
||||
return use(Context)!.dataEngine;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import { FieldKey } from './types';
|
||||
|
||||
/**
|
||||
* test if array a starts with array b, only compare values via `===`.
|
||||
*/
|
||||
export function arrayStartsWith(a: unknown[], b: unknown[]): boolean {
|
||||
if (b.length > a.length) return false;
|
||||
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
import type { FieldKey } from './types';
|
||||
|
||||
export function objectGet(obj: unknown, key: (string | number)[]): unknown | undefined {
|
||||
let cur = obj;
|
||||
@@ -85,3 +72,10 @@ export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
export function stringifyFieldKey(fieldKey: FieldKey) {
|
||||
return fieldKey.map((v) => `${typeof v}:${v}`).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns if `a` starts with `b`.
|
||||
*/
|
||||
export function fieldKeyStartsWith(a: string, b: string): boolean {
|
||||
return b.length === 0 || a === b || a.startsWith(b + '.');
|
||||
}
|
||||
|
||||
@@ -470,28 +470,29 @@ function useFieldInfo(
|
||||
updateInfo: (value: Partial<FieldInfo>) => void;
|
||||
} {
|
||||
const engine = useDataEngine();
|
||||
const attachedData = engine.attachedData<FieldInfo>('field-info');
|
||||
const [info, setInfo] = useState<FieldInfo>(() => {
|
||||
const initialInfo = attachedData.get(fieldName);
|
||||
if (initialInfo) return initialInfo;
|
||||
const fieldData = engine.namespace(
|
||||
`field-info:${stringifyFieldKey(fieldName)}`,
|
||||
(): FieldInfo => {
|
||||
const out: FieldInfo = {
|
||||
unionIndex: 0,
|
||||
};
|
||||
|
||||
const out: FieldInfo = {
|
||||
unionIndex: 0,
|
||||
};
|
||||
if (node.type === 'union') {
|
||||
// Try to find which union type matches the current value
|
||||
const matchingIndex = node.types.findIndex(validate);
|
||||
out.unionIndex = matchingIndex === -1 ? 0 : matchingIndex;
|
||||
}
|
||||
|
||||
if (node.type === 'union') {
|
||||
// Try to find which union type matches the current value
|
||||
const matchingIndex = node.types.findIndex(validate);
|
||||
out.unionIndex = matchingIndex === -1 ? 0 : matchingIndex;
|
||||
}
|
||||
|
||||
return out;
|
||||
return out;
|
||||
},
|
||||
);
|
||||
const [info, setInfo] = useFieldValue<FieldInfo>([], {
|
||||
stf: fieldData,
|
||||
});
|
||||
|
||||
attachedData.set(fieldName, info);
|
||||
return {
|
||||
info,
|
||||
updateInfo: (value) => {
|
||||
updateInfo(value) {
|
||||
const updated = {
|
||||
...info,
|
||||
...value,
|
||||
|
||||
Generated
+101
-14
Reference in New Issue
Block a user