Merge pull request #4 from webzard-io/runtime

trait: trait framework, state trait, event trait
This commit is contained in:
yz-yu 2021-07-18 23:09:02 +08:00 committed by GitHub
commit 81c123cfe8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2993 additions and 1767 deletions

View File

@ -55,6 +55,10 @@ describe("application", () => {
},
"traits": Array [
Object {
"parsedType": Object {
"name": "test_trait",
"version": "core/v1",
},
"properties": Object {
"width": 2,
},

View File

@ -29,7 +29,7 @@ type ComponentTrait = {
properties: object;
};
type ComponentType = {
type VersionAndName = {
version: string;
name: string;
};
@ -39,20 +39,23 @@ export type RuntimeApplication = Omit<Application, "spec"> & {
parsedVersion: Version;
spec: Omit<ApplicationSpec, "components"> & {
components: Array<
ApplicationComponent & {
parsedType: ComponentType;
Omit<ApplicationComponent, "traits"> & {
parsedType: VersionAndName;
traits: Array<
ComponentTrait & {
parsedType: VersionAndName;
}
>;
}
>;
};
};
type A = RuntimeApplication["spec"]["components"];
const TYPE_REG = /^([a-zA-Z0-9_\d]+\/[a-zA-Z0-9_\d]+)\/([a-zA-Z0-9_\d]+)$/;
function isValidType(v: string): boolean {
return TYPE_REG.test(v);
}
function parseType(v: string): ComponentType {
function parseType(v: string): VersionAndName {
if (!isValidType(v)) {
throw new Error(`Invalid type string: "${v}"`);
}
@ -77,6 +80,12 @@ export function createApplication(
return {
...c,
parsedType: parseType(c.type),
traits: c.traits.map((t) => {
return {
...t,
parsedType: parseType(t.type),
};
}),
};
}),
},

View File

@ -2,3 +2,4 @@ export * from "./component";
export * from "./trait";
export * from "./scope";
export * from "./application";
export * from "./method";

View File

@ -19,14 +19,65 @@
components: [
{
id: "del_btn",
type: "plain/v1/button",
type: "chakra_ui/v1/button",
properties: {
text: {
raw: `{{ del_btn.count < 3 ? '*Markdown Button*' : "**I'm** Done!" }}`,
raw: `{{ del_btn.count > 0 ? 'CLICK TO CONFIRM' : '**DELETE** Button' }}`,
format: "md",
},
isLoading: false,
colorScheme: "twitter",
},
traits: [],
traits: [
{
type: "core/v1/state",
properties: {
key: "count",
initialValue: 0,
},
},
{
type: "core/v1/event",
properties: {
events: [
{
event: "click",
componentId: "$utils",
method: {
name: "alert",
parameters: "{{ 'deleted!' }}",
},
wait: {},
disabled: "{{ del_btn.count === 0 }}",
},
{
event: "click",
componentId: "del_btn",
method: {
name: "setValue",
parameters:
"{{ del_btn.count > 0 ? 0 : del_btn.count + 1 }}",
},
wait: {},
disabled: false,
},
{
event: "click",
componentId: "del_btn",
method: {
name: "setValue",
parameters: "0",
},
wait: {
type: "delay",
time: 2000,
},
disabled: "{{ del_btn.count === 0 }}",
},
],
},
},
],
},
{
id: "debug_text",

View File

@ -5,9 +5,14 @@
"dev": "vite"
},
"dependencies": {
"@chakra-ui/react": "^1.6.5",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"@meta-ui/core": "^0.1.0",
"framer-motion": "^4",
"lodash": "^4.17.21",
"mitt": "^3.0.0",
"nanoid": "^3.1.23",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-markdown": "^6.0.2",

View File

@ -1,9 +1,10 @@
import React, { useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Application,
createApplication,
RuntimeApplication,
} from "@meta-ui/core";
import { merge } from "lodash";
import { registry } from "./registry";
import { setStore, useStore, emitter } from "./store";
@ -30,14 +31,43 @@ const ImplWrapper: React.FC<{
};
}, []);
const mergeState = useCallback(
(partial: any) => {
setStore((cur) => {
return { [c.id]: { ...cur[c.id], ...partial } };
});
},
[c.id]
);
const subscribeMethods = useCallback(
(map: any) => {
handlerMap.current = merge(handlerMap.current, map);
},
[handlerMap.current]
);
// traits
const traitsProps = {};
for (const t of c.traits) {
const tImpl = registry.getTrait(
t.parsedType.version,
t.parsedType.name
).impl;
const tProps = tImpl({
...t.properties,
mergeState,
subscribeMethods,
});
merge(traitsProps, tProps);
}
return (
<Impl
key={c.id}
{...c.properties}
mergeState={(partial: any) => setStore({ [c.id]: partial })}
subscribeMethods={(map: any) => {
handlerMap.current = map;
}}
{...traitsProps}
mergeState={mergeState}
subscribeMethods={subscribeMethods}
/>
);
};
@ -48,15 +78,45 @@ const DebugStore: React.FC = () => {
return <pre>{JSON.stringify(store, null, 2)}</pre>;
};
const DebugEvent: React.FC = () => {
const [events, setEvents] = useState<unknown[]>([]);
useEffect(() => {
const handler = (type: string, event: unknown) => {
setEvents((cur) =>
cur.concat({ type, event, t: new Date().toLocaleString() })
);
};
emitter.on("*", handler);
return () => emitter.off("*", handler);
}, []);
return (
<div
style={{
padding: "0.5em",
border: "2px solid black",
maxHeight: "200px",
overflow: "auto",
}}
>
{events.map((event, idx) => (
<pre key={idx}>{JSON.stringify(event)}</pre>
))}
</div>
);
};
const App: React.FC<{ options: Application }> = ({ options }) => {
const app = createApplication(options);
return (
<div className="App">
<DebugStore />
{app.spec.components.map((c) => {
return <ImplWrapper key={c.id} component={c} />;
})}
<DebugStore />
<DebugEvent />
</div>
);
};

View File

@ -0,0 +1,109 @@
import React, { useEffect, useRef } from "react";
import { createComponent } from "@meta-ui/core";
import {
Button as BaseButton,
ChakraProvider,
ButtonProps as BaseButtonProps,
} from "@chakra-ui/react";
import Text, { TextProps } from "../_internal/Text";
import { ComponentImplementation } from "../../registry";
import { useExpression } from "../../store";
const Button: ComponentImplementation<
BaseButtonProps & {
text: TextProps["value"];
onClick?: () => void;
}
> = ({ text, mergeState, subscribeMethods, onClick, ...rest }) => {
const raw = useExpression(text.raw);
useEffect(() => {
mergeState({ value: raw });
}, [raw]);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
subscribeMethods({
click() {
ref.current?.click();
},
});
}, []);
return (
<ChakraProvider>
<BaseButton {...rest} ref={ref} onClick={onClick}>
<Text value={{ ...text, raw }} />
</BaseButton>
</ChakraProvider>
);
};
export default {
...createComponent({
version: "chakra_ui/v1",
metadata: {
name: "button",
description: "chakra-ui button",
},
spec: {
properties: [
{
name: "text",
type: "object",
properties: {
raw: {
type: "string",
},
format: {
type: "string",
enum: ["plain", "md"],
},
},
},
{
name: "colorScheme",
type: "string",
enum: [
"whiteAlpha",
"blackAlpha",
"gray",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"cyan",
"purple",
"pink",
"linkedin",
"facebook",
"messenger",
"whatsapp",
"twitter",
"telegram",
],
},
{
name: "isLoading",
type: "boolean",
},
],
acceptTraits: [],
state: {
type: "object",
properties: {
value: {
type: "string",
},
},
},
methods: [
{
name: "click",
},
],
},
}),
impl: Button,
};

View File

@ -1,10 +1,10 @@
import React, { useEffect } from "react";
import { createComponent } from "@meta-ui/core";
import { Implementation } from "../../registry";
import { ComponentImplementation } from "../../registry";
import _Text, { TextProps } from "../_internal/Text";
import { useExpression } from "../../store";
const Text: Implementation<TextProps> = ({ value, mergeState }) => {
const Text: ComponentImplementation<TextProps> = ({ value, mergeState }) => {
const raw = useExpression(value.raw);
useEffect(() => {

View File

@ -1,19 +1,17 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef } from "react";
import { createComponent } from "@meta-ui/core";
import Text, { TextProps } from "../_internal/Text";
import { Implementation } from "../../registry";
import { ComponentImplementation } from "../../registry";
import { useExpression } from "../../store";
const Button: Implementation<{ text: TextProps["value"] }> = ({
text,
mergeState,
subscribeMethods,
}) => {
const [count, add] = useState(0);
const Button: ComponentImplementation<{
text: TextProps["value"];
onClick?: () => void;
}> = ({ text, mergeState, subscribeMethods, onClick }) => {
const raw = useExpression(text.raw);
useEffect(() => {
mergeState({ value: raw, count });
}, [raw, count]);
mergeState({ value: raw });
}, [raw]);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
@ -22,10 +20,10 @@ const Button: Implementation<{ text: TextProps["value"] }> = ({
ref.current?.click();
},
});
});
}, []);
return (
<button ref={ref} onClick={() => add(count + 1)}>
<button ref={ref} onClick={onClick}>
<Text value={{ ...text, raw }} />
</button>
);

View File

@ -1,27 +1,46 @@
import React from "react";
import { RuntimeComponent } from "@meta-ui/core";
import { RuntimeComponent, RuntimeTrait } from "@meta-ui/core";
import { setStore } from "./store";
// components
import PlainButton from "./components/plain/Button";
import CoreText from "./components/core/Text";
import ChakraUIButton from "./components/chakra-ui/Button";
// traits
import CoreState from "./traits/core/state";
import CoreEvent from "./traits/core/event";
type ImplementedRuntimeComponent = RuntimeComponent & {
impl: Implementation;
impl: ComponentImplementation;
};
export type Implementation<T = any> = React.FC<
type ImplementedRuntimeTrait = RuntimeTrait & {
impl: TraitImplementation;
};
type SubscribeMethods = <U>(
map: {
[K in keyof U]: (parameters: U[K]) => void;
}
) => void;
type MergeState = (partialState: Parameters<typeof setStore>[0]) => void;
export type ComponentImplementation<T = any> = React.FC<
T & {
mergeState: (partialState: Parameters<typeof setStore>[0]) => void;
subscribeMethods: <U>(
map: {
[K in keyof U]: (parameters: U[K]) => void;
}
) => void;
mergeState: MergeState;
subscribeMethods: SubscribeMethods;
}
>;
export type TraitImplementation<T = any> = (
props: T & {
mergeState: MergeState;
subscribeMethods: SubscribeMethods;
}
) => any;
class Registry {
components: Map<string, Map<string, ImplementedRuntimeComponent>> = new Map();
traits: Map<string, Map<string, ImplementedRuntimeTrait>> = new Map();
registerComponent(c: ImplementedRuntimeComponent) {
if (this.components.get(c.version)?.has(c.metadata.name)) {
@ -36,11 +55,31 @@ class Registry {
}
getComponent(version: string, name: string): ImplementedRuntimeComponent {
const irc = this.components.get(version)?.get(name);
if (!irc) {
const c = this.components.get(version)?.get(name);
if (!c) {
throw new Error(`Component ${version}/${name} has not registered yet.`);
}
return irc;
return c;
}
registerTrait(t: ImplementedRuntimeTrait) {
if (this.traits.get(t.version)?.has(t.metadata.name)) {
throw new Error(
`Already has trait ${t.version}/${t.metadata.name} in this registry.`
);
}
if (!this.traits.has(t.version)) {
this.traits.set(t.version, new Map());
}
this.traits.get(t.version)!.set(t.metadata.name, t);
}
getTrait(version: string, name: string): ImplementedRuntimeTrait {
const t = this.traits.get(version)?.get(name);
if (!t) {
throw new Error(`Trait ${version}/${name} has not registered yet.`);
}
return t;
}
}
@ -48,3 +87,7 @@ export const registry = new Registry();
registry.registerComponent(PlainButton);
registry.registerComponent(CoreText);
registry.registerComponent(ChakraUIButton);
registry.registerTrait(CoreState);
registry.registerTrait(CoreEvent);

View File

@ -8,7 +8,7 @@ export const useStore = create<Record<string, any>>(() => ({}));
export const setStore = useStore.setState;
// TODO: use web worker
function evalInContext(expression: string, ctx: Record<string, any>) {
export function evalInContext(expression: string, ctx: Record<string, any>) {
try {
Object.keys(ctx).forEach((key) => {
// @ts-ignore
@ -46,7 +46,9 @@ export function useExpression(raw: string) {
};
}, [raw]);
const [state, setState] = useState<any>(expression);
const [state, setState] = useState<any>(
evalInContext(expression, useStore.getState())
);
if (!dynamic) {
return state;
@ -65,3 +67,11 @@ export function useExpression(raw: string) {
}
export const emitter = mitt<Record<string, any>>();
// EXPERIMENT: utils
emitter.on("$utils", ({ name, parameters, ...rest }) => {
console.log(name, parameters, rest);
if (name === "alert") {
window.alert(parameters);
}
});

View File

@ -0,0 +1,105 @@
import { useEffect, useMemo, useRef } from "react";
import { createTrait } from "@meta-ui/core";
import { nanoid } from "nanoid";
import { debounce, throttle, delay } from "lodash";
import { TraitImplementation } from "../../registry";
import { emitter, evalInContext, useStore } from "../../store";
const useEventTrait: TraitImplementation<{
events: Array<{
event: string;
componentId: string;
method: {
name: string;
parameters: string;
};
wait: {
type: "debounce" | "throttle" | "delay";
time: number;
};
disabled: boolean | string;
}>;
}> = ({ events }) => {
const hookId = useMemo(() => {
return nanoid();
}, []);
const handlerMap = useRef<Record<string, Array<(parameters?: any) => void>>>(
{}
);
useEffect(() => {
const handler = (s: { name: string; parameters?: any }) => {
if (!handlerMap.current[s.name]) {
// maybe log?
return;
}
handlerMap.current[s.name].forEach((fn) => fn(s.parameters));
};
emitter.on(hookId, handler);
return () => {
emitter.off(hookId, handler);
};
}, []);
useEffect(() => {
// tear down previous handlers
handlerMap.current = {};
// setup current handlers
for (const event of events) {
const handler = () => {
let disabled = false;
const currentStoreState = useStore.getState();
if (typeof event.disabled === "boolean") {
disabled = event.disabled;
} else if (typeof event.disabled === "string") {
disabled = evalInContext(event.disabled, currentStoreState);
}
if (disabled) {
return;
}
emitter.emit(event.componentId, {
name: event.method.name,
parameters: evalInContext(event.method.parameters, currentStoreState),
});
};
if (!handlerMap.current[event.event]) {
handlerMap.current[event.event] = [];
}
handlerMap.current[event.event].push(
event.wait.type === "debounce"
? debounce(handler, event.wait.time)
: event.wait.type === "throttle"
? throttle(handler, event.wait.time)
: event.wait.type === "delay"
? () => delay(handler, event.wait.time)
: handler
);
}
}, [events]);
const hub = useMemo(() => {
return {
// HARDCODE
onClick() {
emitter.emit(hookId, { name: "click" });
},
};
}, []);
return hub;
};
export default {
...createTrait({
version: "core/v1",
metadata: {
name: "event",
description: "export component events with advance features",
},
spec: {
properties: [],
state: {},
methods: [],
},
}),
impl: useEventTrait,
};

View File

@ -0,0 +1,58 @@
import { useEffect } from "react";
import { createTrait } from "@meta-ui/core";
import { TraitImplementation } from "../../registry";
const useStateTrait: TraitImplementation<{
key: string;
initialValue: any;
}> = ({ key, initialValue, mergeState, subscribeMethods }) => {
useEffect(() => {
mergeState({ [key]: initialValue });
subscribeMethods({
setValue(value) {
mergeState({ [key]: value });
},
reset() {
mergeState({ [key]: initialValue });
},
});
}, []);
};
export default {
...createTrait({
version: "core/v1",
metadata: {
name: "state",
description: "add state to component",
},
spec: {
properties: [
{
name: "key",
type: "string",
},
{
name: "initialValue",
type: "any",
},
],
state: {
type: "any",
},
methods: [
{
name: "setValue",
parameters: {
type: "any",
},
},
{
name: "reset",
},
],
},
}),
impl: useStateTrait,
};

4221
yarn.lock

File diff suppressed because it is too large Load Diff