Merge pull request #47 from webzard-io/refactor/trait

Refactor trait new implementation
This commit is contained in:
yz-yu 2021-09-02 22:02:12 +08:00 committed by GitHub
commit c2670c9a50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 466 additions and 177 deletions

View File

@ -44,8 +44,11 @@
componentId: 'test_btn',
method: {
name: 'setValue',
parameters:
'{{ test_btn.count > 0 ? 0 : test_btn.count + 1 }}',
parameters: {
key: 'count',
value:
'{{ test_btn.count > 0 ? 0 : test_btn.count + 1 }}',
},
},
wait: {},
disabled: false,

View File

@ -43,7 +43,10 @@
componentId: 'btn',
method: {
name: 'setValue',
parameters: '{{ btn.count + 1 }}',
parameters: {
key: 'count',
value: '{{ btn.count + 1 }}',
},
},
wait: {},
disabled: false,

View File

@ -53,8 +53,11 @@
componentId: 'del_btn',
method: {
name: 'setValue',
parameters:
'{{ del_btn.count > 0 ? 0 : del_btn.count + 1 }}',
parameters: {
key: 'count',
value:
'{{ del_btn.count > 0 ? 0 : del_btn.count + 1 }}',
},
},
wait: {},
disabled: false,
@ -64,7 +67,10 @@
componentId: 'del_btn',
method: {
name: 'setValue',
parameters: '0',
parameters: {
key: 'count',
value: '0',
},
},
wait: {
type: 'delay',

View File

@ -45,7 +45,10 @@
componentId: 'fetch_btn',
method: {
name: 'setValue',
parameters: `{{ fetch_btn.token ? "":"01f0f6265bmsh0efd88b5c7dfa93p136d2ajsn8be6074b61b2" }}`,
parameters: {
key: 'count',
value: `{{ fetch_btn.token ? "":"01f0f6265bmsh0efd88b5c7dfa93p136d2ajsn8be6074b61b2" }}`,
},
},
wait: {},
disabled: false,

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>meta-ui runtime example: input validation component</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import renderApp from '../../src/main.tsx';
renderApp({
version: 'example/v1',
metadata: {
name: 'inputValidation',
description: 'input validation example',
},
spec: {
components: [
{
id: 'emailInput',
type: 'chakra_ui/v1/input',
properties: {
size: 'lg',
left: {
type: 'addon',
children: '邮箱',
},
},
traits: [
{
type: 'core/v1/validation',
properties: {
value: '{{ emailInput.value || "" }}',
maxLength: 20,
minLength: 10,
rule: 'email',
},
},
],
},
{
id: 'emailValidationText',
type: 'core/v1/text',
properties: {
value: {
raw: '{{ emailInput.validationResult.errorMsg }}',
format: 'plain',
},
},
traits: [],
},
{
id: 'phoneInput',
type: 'chakra_ui/v1/input',
properties: {
size: 'lg',
left: {
type: 'addon',
children: '手机',
},
},
traits: [
{
type: 'core/v1/validation',
properties: {
value: '{{ phoneInput.value || "" }}',
maxLength: 100,
minLength: 0,
rule: 'phoneNumber',
},
},
],
},
{
id: 'phoneValidationText',
type: 'core/v1/text',
properties: {
value: {
raw: '{{ phoneInput.validationResult.errorMsg }}',
format: 'plain',
},
},
traits: [],
},
{
id: 'submitButton',
type: 'plain/v1/button',
properties: {
text: {
raw: '提交',
format: 'plain',
},
},
traits: [
{
type: 'core/v1/event',
properties: {
events: [
{
event: 'click',
componentId: '$utils',
method: {
name: 'alert',
parameters:
'{{ `邮箱:${ emailInput.value } 手机号:${ phoneInput.value }` }}',
},
wait: {},
disabled:
'{{ !emailInput.validationResult.isValid || !phoneInput.validationResult.isValid }}',
},
],
},
},
],
},
],
},
});
</script>
</body>
</html>

View File

@ -142,7 +142,10 @@
componentId: 'router',
method: {
name: 'setValue',
parameters: '{{router.index + 1}}',
parameters: {
key: 'index',
value: '{{router.index + 1}}',
},
},
wait: {},
disabled: false,
@ -182,7 +185,10 @@
componentId: 'router',
method: {
name: 'setValue',
parameters: '2',
parameters: {
key: 'index',
value: '2',
},
},
wait: {},
disabled: false,

View File

@ -139,7 +139,10 @@
componentId: 'router',
method: {
name: 'setValue',
parameters: '{{router.index + 1}}',
parameters: {
key: 'index',
value: '{{router.index + 1}}',
},
},
wait: {},
disabled: false,
@ -179,7 +182,10 @@
componentId: 'router',
method: {
name: 'setValue',
parameters: '2',
parameters: {
key: 'index',
value: '2',
},
},
wait: {},
disabled: false,

View File

@ -7,11 +7,13 @@ import React, {
} from 'react';
import {
Application,
ComponentTrait,
createApplication,
RuntimeApplication,
Trait,
} from '@meta-ui/core';
import { merge } from 'lodash';
import { registry } from './registry';
import { registry, TraitResult } from './registry';
import { stateStore, deepEval } from './store';
import { apiService } from './api-service';
import { ContainerPropertySchema } from './traits/core/slot';
@ -19,11 +21,18 @@ import { Static } from '@sinclair/typebox';
import { watch } from '@vue-reactivity/watch';
import _ from 'lodash';
import copy from 'copy-to-clipboard';
import { globalHandlerMap } from './handler';
type ArrayElement<ArrayType extends readonly unknown[]> =
ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
type ApplicationComponent = RuntimeApplication['spec']['components'][0];
type ApplicationTrait = ArrayElement<ApplicationComponent['traits']>;
const ImplWrapper = React.forwardRef<
HTMLDivElement,
{
component: RuntimeApplication['spec']['components'][0];
component: ApplicationComponent;
slotsMap: SlotsMap | undefined;
targetSlot: { id: string; slot: string } | null;
app: RuntimeApplication;
@ -40,7 +49,11 @@ const ImplWrapper = React.forwardRef<
c.parsedType.name
).impl;
const handlerMap = useRef<Record<string, (parameters?: any) => void>>({});
if (!globalHandlerMap.has(c.id)) {
globalHandlerMap.set(c.id, {});
}
let handlerMap = globalHandlerMap.get(c.id)!;
useEffect(() => {
const handler = (s: {
componentId: string;
@ -50,11 +63,11 @@ const ImplWrapper = React.forwardRef<
if (s.componentId !== c.id) {
return;
}
if (!handlerMap.current[s.name]) {
if (!handlerMap[s.name]) {
// maybe log?
return;
}
handlerMap.current[s.name](s.parameters);
handlerMap[s.name](s.parameters);
};
apiService.on('uiMethod', handler);
return () => {
@ -68,91 +81,81 @@ const ImplWrapper = React.forwardRef<
},
[c.id]
);
const subscribeMethods = useCallback((map: any) => {
handlerMap = { ...handlerMap, ...map };
globalHandlerMap.set(c.id, handlerMap);
}, []);
const subscribeMethods = useCallback(
(map: any) => {
handlerMap.current = merge(handlerMap.current, map);
},
[handlerMap.current]
);
// result returned from traits
const [traitResults, setTraitResults] = useState<TraitResult[]>([]);
// traits
const [traitPropertiesMap, setTraitPropertiesMap] = useState<
Map<typeof c['traits'][0], object>
>(
c.traits.reduce((prev, cur) => {
prev.set(cur, deepEval(cur.properties).result);
return prev;
}, new Map())
);
// eval traits' properties then excecute traits
useEffect(() => {
function excecuteTrait(
trait: ApplicationTrait,
traitProperty: ApplicationTrait['properties']
) {
const tImpl = registry.getTrait(
trait.parsedType.version,
trait.parsedType.name
).impl;
const traitResult = tImpl({
...traitProperty,
componentId: c.id,
mergeState,
subscribeMethods,
});
setTraitResults(results => results.concat(traitResult));
}
const stops: ReturnType<typeof watch>[] = [];
for (const t of c.traits) {
const { stop, result } = deepEval(t.properties, ({ result }) => {
setTraitPropertiesMap(
new Map(traitPropertiesMap.set(t, { ...result }))
);
excecuteTrait(t, { ...result });
});
setTraitPropertiesMap(new Map(traitPropertiesMap.set(t, { ...result })));
excecuteTrait(t, { ...result });
stops.push(stop);
}
return () => stops.forEach(s => s());
}, []);
}, [c.traits]);
const traitsProps = {};
const wrappers: React.FC[] = [];
for (const t of c.traits) {
const tImpl = registry.getTrait(
t.parsedType.version,
t.parsedType.name
).impl;
const { props: tProps, component: Wrapper } = tImpl({
...traitPropertiesMap.get(t),
mergeState,
subscribeMethods,
});
merge(traitsProps, tProps);
if (Wrapper) {
wrappers.push(Wrapper);
}
}
// reduce traitResults
const propsFromTraits: TraitResult = useMemo(() => {
return Array.from(traitResults.values()).reduce(
(prevProps, result: TraitResult) => {
return { ...prevProps, ...result.props };
},
{ props: null }
);
}, [traitResults]);
const [mergedProps, setMergedProps] = useState(
deepEval({
...c.properties,
...traitsProps,
}).result
// component properties
const [evaledComponentProperties, setEvaledComponentProperties] = useState(
merge(deepEval(c.properties).result, propsFromTraits)
);
useEffect(() => {
const rawProps: Record<string, unknown> = {
...c.properties,
...traitsProps,
};
const { stop, result } = deepEval(rawProps, ({ result }) => {
setMergedProps({ ...result });
// eval component properties
useEffect(() => {
const { stop, result } = deepEval(c.properties, ({ result }) => {
setEvaledComponentProperties({ ...result });
});
setMergedProps({ ...result });
setEvaledComponentProperties(result);
return stop;
}, []);
const mergedProps = { ...evaledComponentProperties, ...propsFromTraits };
let C = (
<Impl
key={c.id}
{...mergedProps}
{...props}
mergeState={mergeState}
subscribeMethods={subscribeMethods}
slotsMap={slotsMap}
/>
);
while (wrappers.length) {
const W = wrappers.pop()!;
C = <W>{C}</W>;
}
if (targetSlot) {
const targetC = app.spec.components.find(c => c.id === targetSlot.id);
if (targetC?.parsedType.name === 'grid_layout') {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { CSSProperties } from 'react';
import ReactMarkdown from 'react-markdown';
import { Static, Type } from '@sinclair/typebox';
@ -14,13 +14,14 @@ export const TextPropertySchema = Type.Object({
export type TextProps = {
value: Static<typeof TextPropertySchema>;
style?: CSSProperties;
};
const Text: React.FC<TextProps> = ({ value }) => {
const Text: React.FC<TextProps> = ({ value, style }) => {
if (value.format === 'md') {
return <ReactMarkdown>{value.raw}</ReactMarkdown>;
}
return <>{value.raw}</>;
return <span style={style}>{value.raw}</span>;
};
export default Text;

View File

@ -67,6 +67,7 @@ const Input: ComponentImplementation<{
left,
right,
mergeState,
data,
}) => {
const [value, setValue] = React.useState(''); // TODO: pin input
const onChange = (event: React.ChangeEvent<HTMLInputElement>) =>
@ -74,7 +75,8 @@ const Input: ComponentImplementation<{
useEffect(() => {
mergeState({ value });
}, [value]);
mergeState({ ...data });
}, [value, data]);
return (
<InputGroup size={size}>

View File

@ -4,12 +4,16 @@ import { Type } from '@sinclair/typebox';
import { ComponentImplementation } from '../../registry';
import _Text, { TextProps, TextPropertySchema } from '../_internal/Text';
const Text: ComponentImplementation<TextProps> = ({ value, mergeState }) => {
const Text: ComponentImplementation<TextProps> = ({
value,
mergeState,
style,
}) => {
useEffect(() => {
mergeState({ value: value.raw });
}, [value.raw]);
return <_Text value={value} />;
return <_Text value={value} style={style} />;
};
const StateSchema = Type.Object({

View File

@ -0,0 +1,3 @@
type HandlerMap = Record<string, (parameters?: any) => void>;
export const globalHandlerMap = new Map<string, HandlerMap>();

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { CSSProperties } from 'react';
import { RuntimeComponent, RuntimeTrait } from '@meta-ui/core';
import { SlotsMap } from './App';
// components
@ -29,6 +29,7 @@ import CoreEvent from './traits/core/event';
import CoreSlot from './traits/core/slot';
import CoreHidden from './traits/core/hidden';
import CoreFetch from './traits/core/fetch';
import CoreValidation from './traits/core/validation';
type ImplementedRuntimeComponent = RuntimeComponent & {
impl: ComponentImplementation;
@ -50,18 +51,25 @@ export type ComponentImplementation<T = any> = React.FC<
mergeState: MergeState;
subscribeMethods: SubscribeMethods;
slotsMap: SlotsMap | undefined;
style?: CSSProperties;
data?: Record<string, any>;
}
>;
export type TraitResult = {
props: {
data?: unknown;
style?: CSSProperties;
} | null;
};
export type TraitImplementation<T = any> = (
props: T & {
componentId: string;
mergeState: MergeState;
subscribeMethods: SubscribeMethods;
}
) => {
props: any;
component?: React.FC;
};
) => TraitResult;
class Registry {
components: Map<string, Map<string, ImplementedRuntimeComponent>> = new Map();
@ -134,3 +142,4 @@ registry.registerTrait(CoreEvent);
registry.registerTrait(CoreSlot);
registry.registerTrait(CoreHidden);
registry.registerTrait(CoreFetch);
registry.registerTrait(CoreValidation);

View File

@ -55,11 +55,15 @@ function maskedEval(raw: string) {
if (!dynamic) {
return raw;
}
return new Function(`with(this) { return ${expression} }`).call({
...stateStore,
...builtIn,
});
try {
const result = new Function(`with(this) { return ${expression} }`).call({
...stateStore,
...builtIn,
});
return result;
} catch {
return undefined;
}
}
const mapValuesDeep = (

View File

@ -1,4 +1,3 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createTrait } from '@meta-ui/core';
import { Static, Type } from '@sinclair/typebox';
import { debounce, throttle, delay } from 'lodash';
@ -8,64 +7,55 @@ import { apiService } from '../../api-service';
const useEventTrait: TraitImplementation<{
events: Static<typeof EventsPropertySchema>;
}> = ({ events }) => {
const handlerMap = useRef<Record<string, Array<(parameters?: any) => void>>>(
{}
);
const eventHandler = useCallback((s: { name: string; parameters?: any }) => {
if (!handlerMap.current[s.name]) {
const handlerMap: Record<string, Array<(parameters?: any) => void>> = {};
const eventHandler = (s: { name: string; parameters?: any }) => {
if (!handlerMap[s.name]) {
// maybe log?
return;
}
handlerMap.current[s.name].forEach(fn => fn(s.parameters));
}, []);
handlerMap[s.name].forEach(fn => fn(s.parameters));
};
useEffect(() => {
// tear down previous handlers
handlerMap.current = {};
// setup current handlers
for (const event of events) {
const handler = () => {
let disabled = false;
if (typeof event.disabled === 'boolean') {
disabled = event.disabled;
}
if (disabled) {
return;
}
apiService.send('uiMethod', {
componentId: event.componentId,
name: event.method.name,
parameters: event.method.parameters,
});
};
if (!handlerMap.current[event.event]) {
handlerMap.current[event.event] = [];
// setup current handlers
for (const event of events) {
const handler = () => {
let disabled = false;
if (typeof event.disabled === 'boolean') {
disabled = event.disabled;
}
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
);
if (disabled) {
return;
}
console.log(event);
apiService.send('uiMethod', {
componentId: event.componentId,
name: event.method.name,
parameters: event.method.parameters,
});
};
if (!handlerMap[event.event]) {
handlerMap[event.event] = [];
}
}, [events]);
handlerMap[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
);
}
const hub = useMemo(() => {
return {
return {
props: {
// HARDCODE
onClick() {
eventHandler({
name: 'click',
});
},
};
}, []);
return {
props: hub,
},
};
};

View File

@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo } from 'react';
import { createTrait } from '@meta-ui/core';
import { Static, Type } from '@sinclair/typebox';
import { TraitImplementation } from '../../registry';
let hasFetched = false;
const useFetchTrait: TraitImplementation<FetchPropertySchema> = ({
name,
url,
@ -13,11 +14,9 @@ const useFetchTrait: TraitImplementation<FetchPropertySchema> = ({
mergeState,
subscribeMethods,
}) => {
const lazy = useMemo(() => {
return _lazy === undefined ? method.toLowerCase() !== 'get' : _lazy;
}, [method, _lazy]);
const fetchData = useCallback(() => {
const lazy = undefined ? method.toLowerCase() !== 'get' : _lazy;
const fetchData = () => {
hasFetched = true;
// before fetching, initial data
mergeState({
[name]: {
@ -77,26 +76,12 @@ const useFetchTrait: TraitImplementation<FetchPropertySchema> = ({
});
}
);
}, [url, method, _headers, body, lazy]);
// intialize data
useEffect(() => {
mergeState({
[name]: {
loading: false,
data: undefined,
error: undefined,
},
});
}, []);
};
// non lazy query, listen to the change and query;
useEffect(() => {
if (lazy || !url) {
return;
}
if (!lazy && url && !hasFetched) {
fetchData();
}, [url, method, _headers, body, lazy]);
}
// only subscribe non lazy fetch trait
if (lazy) {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { CSSProperties } from 'react';
import { createTrait } from '@meta-ui/core';
import { Static, Type } from '@sinclair/typebox';
import { TraitImplementation } from '../../registry';
@ -7,17 +7,15 @@ type HiddenProps = {
hidden: Static<typeof HiddenPropertySchema>;
};
const Hidden: React.FC<HiddenProps> = ({ hidden, children }) => {
if (hidden) {
return null;
}
return <>{children}</>;
};
const useHiddenTrait: TraitImplementation<HiddenProps> = ({ hidden }) => {
const style: CSSProperties = {};
if (hidden) {
style.display = 'none';
}
return {
props: null,
component: props => <Hidden {...props} hidden={hidden} />,
props: {
style,
},
};
};

View File

@ -1,24 +1,32 @@
import { useEffect } from 'react';
import { createTrait } from '@meta-ui/core';
import { Static, Type } from '@sinclair/typebox';
import { TraitImplementation } from '../../registry';
const HasInitializedMap = new Map<string, boolean>();
type KeyValue = { key: string; value: unknown };
const useStateTrait: TraitImplementation<{
key: Static<typeof KeyPropertySchema>;
initialValue: Static<typeof InitialValuePropertySchema>;
}> = ({ key, initialValue, mergeState, subscribeMethods }) => {
useEffect(() => {
}> = ({ key, initialValue, componentId, mergeState, subscribeMethods }) => {
const hashId = `#${componentId}@${key}`;
let hasInitialized = HasInitializedMap.get(hashId);
if (!hasInitialized) {
mergeState({ [key]: initialValue });
subscribeMethods({
setValue(value) {
const methods = {
setValue({ key, value }: KeyValue) {
mergeState({ [key]: value });
},
reset() {
resetValue({ key }: KeyValue) {
mergeState({ [key]: initialValue });
},
});
}, []);
};
subscribeMethods(methods);
HasInitializedMap.set(hashId, true);
}
return {
props: null,
@ -50,7 +58,10 @@ export default {
methods: [
{
name: 'setValue',
parameters: Type.Any(),
parameters: Type.Object({
key: Type.String(),
value: Type.Any(),
}),
},
{
name: 'reset',

View File

@ -0,0 +1,127 @@
import React from 'react';
import { createTrait } from '@meta-ui/core';
import { Static, Type } from '@sinclair/typebox';
import { TraitImplementation } from '../../registry';
import { min } from 'lodash';
type ValidationResult = { isValid: boolean; errorMsg: string };
type ValidationRule = (text: string) => { isValid: boolean; errorMsg: string };
const rules = new Map<string, ValidationRule>();
export function addValidationRule(name: string, rule: ValidationRule) {
rules.set(name, rule);
}
(window as any).rules = rules;
addValidationRule('email', text => {
if (/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(text)) {
return {
isValid: true,
errorMsg: '',
};
} else {
return {
isValid: false,
errorMsg: '请输入正确的 email',
};
}
});
addValidationRule('phoneNumber', text => {
if (/^1[3456789]\d{9}$/.test(text)) {
return {
isValid: true,
errorMsg: '',
};
} else {
return {
isValid: false,
errorMsg: '请输入正确的手机号码',
};
}
});
type ValidationProps = {
value: string;
minLength: number;
maxLength: number;
rule: string;
};
const useValidationTrait: TraitImplementation<ValidationProps> = props => {
const { value, minLength, maxLength, rule, mergeState } = props;
let result: ValidationResult = {
isValid: true,
errorMsg: '',
};
if (value.length > maxLength) {
result = {
isValid: false,
errorMsg: `最长不能超过${maxLength}个字符`,
};
} else if (value.length < minLength) {
result = {
isValid: false,
errorMsg: `不能少于${minLength}个字符`,
};
}
const rulesArr = rule.split(',');
for (const ruleName of rulesArr) {
const validateFunc = rules.get(ruleName);
if (validateFunc) {
result = validateFunc(value);
if (!result.isValid) {
break;
}
}
}
return {
props: {
data: {
validationResult: result,
},
},
};
};
const ValidationValuePropertySchema = Type.String();
const ValidationRulePropertySchema = Type.String();
const ValidationMinLengthPropertySchema = Type.Integer();
const ValidationMaxLengthPropertySchema = Type.Integer();
export default {
...createTrait({
version: 'core/v1',
metadata: {
name: 'validation',
description: 'validation trait',
},
spec: {
properties: [
{
name: 'value',
...ValidationValuePropertySchema,
},
{
name: 'rule',
...ValidationRulePropertySchema,
},
{
name: 'minLength',
...ValidationMinLengthPropertySchema,
},
{
name: 'maxLength',
...ValidationMaxLengthPropertySchema,
},
],
state: {},
methods: [],
},
}),
impl: useValidationTrait,
};

View File

@ -13,7 +13,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react",
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["./src"]
}