Merge pull request #448 from webzard-io/feat/windlike-patch

feat: change the reference properties when changing the component id
This commit is contained in:
tanbowensg 2022-06-22 17:19:58 +08:00 committed by GitHub
commit db7a6936f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 382 additions and 44 deletions

View File

@ -1,52 +1,68 @@
import { AppModel } from '../../src/AppModel/AppModel';
import { FieldModel } from '../../src/AppModel/FieldModel';
import { ComponentId } from '../../src/AppModel/IAppModel';
import { registry } from '../services';
import { ChangeIdMockSchema } from './mock';
import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared';
describe('Field test', () => {
it('parse static property', () => {
const field = new FieldModel('Hello, world!');
expect(field.isDynamic).toEqual(false);
expect(field.refs).toEqual({});
expect(field.refComponentInfos).toEqual({});
expect(field.rawValue).toEqual('Hello, world!');
});
it('parse expression', () => {
const field = new FieldModel('{{input.value}} + {{list[0].text}}');
expect(field.isDynamic).toEqual(true);
expect(field.refs).toEqual({ input: ['value'], list: ['[0]', '[0].text'] });
expect(field.refComponentInfos['input' as ComponentId].refProperties).toEqual([
'value',
]);
expect(field.refComponentInfos['list' as ComponentId].refProperties).toEqual([
'[0]',
'[0].text',
]);
expect(field.rawValue).toEqual('{{input.value}} + {{list[0].text}}');
});
it('parse inline variable in expression', () => {
const field = new FieldModel('{{ [].length }}');
expect(field.isDynamic).toEqual(true);
expect(field.refs).toEqual({});
expect(field.refComponentInfos).toEqual({});
});
it('parse object property', () => {
const field = new FieldModel({ raw: '{{input.value}}', format: 'md' });
expect(field.isDynamic).toEqual(false);
expect(field.refs).toEqual({});
expect(field.refComponentInfos).toEqual({});
expect(field.rawValue).toEqual({ raw: '{{input.value}}', format: 'md' });
expect(field.getProperty('raw')!.rawValue).toEqual('{{input.value}}');
expect(field.getProperty('raw')!.isDynamic).toEqual(true);
expect(field.getProperty('raw')!.refs).toEqual({ input: ['value'] });
expect(
field.getProperty('raw')!.refComponentInfos['input' as ComponentId].refProperties
).toEqual(['value']);
expect(field.getProperty('format')!.rawValue).toEqual('md');
expect(field.getProperty('format')!.isDynamic).toEqual(false);
expect(field.getProperty('format')!.refs).toEqual({});
expect(field.getProperty('format')!.refComponentInfos).toEqual({});
});
it('parse array property', () => {
const field = new FieldModel({ data: [1, '{{fetch.data}}'] });
expect(field.isDynamic).toEqual(false);
expect(field.refs).toEqual({});
expect(field.refComponentInfos).toEqual({});
expect(field.rawValue).toEqual({ data: [1, '{{fetch.data}}'] });
expect(field.getProperty('data')!.rawValue).toEqual([1, '{{fetch.data}}']);
expect(field.getProperty('data')!.isDynamic).toEqual(false);
expect(field.getProperty('data')!.refs).toEqual({});
expect(field.getProperty('data')!.refComponentInfos).toEqual({});
expect(field.getProperty('data')!.getProperty(0)!.rawValue).toEqual(1);
expect(field.getProperty('data')!.getProperty(0)!.isDynamic).toEqual(false);
expect(field.getProperty('data')!.getProperty(1)!.rawValue).toEqual('{{fetch.data}}');
expect(field.getProperty('data')!.getProperty(1)!.isDynamic).toEqual(true);
expect(field.getProperty('data')!.getProperty(1)!.refs).toEqual({ fetch: ['data'] });
expect(
field.getProperty('data')!.getProperty(1)!.refComponentInfos['fetch' as ComponentId]
.refProperties
).toEqual(['data']);
});
it('update array property', () => {
@ -70,4 +86,50 @@ describe('Field test', () => {
field.update({ value: 'text' });
expect(field.rawValue).toEqual({ data: { a: 2 }, value: 'text' });
});
it('change component id', () => {
const appModel = new AppModel(ChangeIdMockSchema, registry);
const input = appModel.getComponentById('input' as ComponentId);
const text = appModel.getComponentById('text' as ComponentId);
const button = appModel.getComponentById('button' as ComponentId);
input!.changeId('input1' as ComponentId);
expect(input!.id).toEqual('input1' as ComponentId);
expect(text!.properties.rawValue).toEqual({
value: {
raw: "pre {{(function () {\n const object = { value: input1.value + input1.notExistKey };\n return '-' + object.value + '-';\n}());}} end",
format: 'plain',
},
string: 'Please input here',
expressionString: "{{ 'input' }}",
array: ['input'],
expressionArray: "{{['input']}}",
object: { input: 'input' },
expressionObject: "{{{'input': 'input'}}}",
});
expect(
button!.traits.find(
trait => trait.type === `${CORE_VERSION}/${CoreTraitName.Event}`
)!.properties.rawValue
).toEqual({
handlers: [
{
type: 'onClick',
componentId: 'input1',
method: {
name: 'setInputValue',
parameters: {
value: 'Hello',
},
},
disabled: false,
wait: {
type: 'delay',
time: 0,
},
},
],
});
});
});

View File

@ -185,3 +185,128 @@ export const EventHandlerMockSchema: ComponentSchema[] = [
],
},
];
export const ChangeIdMockSchema: ComponentSchema[] = [
{
id: 'stack',
type: 'core/v1/stack',
properties: {
spacing: 12,
direction: 'horizontal',
align: 'auto',
wrap: '',
justify: '',
},
traits: [],
},
{
id: 'button',
type: 'chakra_ui/v1/button',
properties: {
text: {
raw: 'text',
format: 'plain',
},
isLoading: false,
colorScheme: 'blue',
},
traits: [
{
type: 'core/v1/event',
properties: {
handlers: [
{
type: 'onClick',
componentId: 'input',
method: {
name: 'setInputValue',
parameters: {
value: 'Hello',
},
},
disabled: false,
wait: {
type: 'delay',
time: 0,
},
},
],
},
},
{
type: 'core/v1/slot',
properties: {
container: {
id: 'stack',
slot: 'content',
},
},
},
],
},
{
id: 'text',
type: 'core/v1/text',
properties: {
value: {
raw: "pre {{(function () {\n const object = { value: input.value + input.notExistKey };\n return '-' + object.value + '-';\n}());}} end",
format: 'plain',
},
string: 'Please input here',
expressionString: "{{ 'input' }}",
array: ['input'],
expressionArray: "{{['input']}}",
object: { input: 'input' },
expressionObject: "{{{'input': 'input'}}}",
},
traits: [
{
type: 'core/v1/style',
properties: {
styles: [
{
styleSlot: 'content',
style: '',
cssProperties: {
padding: '8px 0px 9px 0px ',
},
},
],
},
},
{
type: 'core/v1/slot',
properties: {
container: {
id: 'stack',
slot: 'content',
},
},
},
],
},
{
id: 'input',
type: 'chakra_ui/v1/input',
properties: {
variant: 'outline',
placeholder: 'Please input value',
size: 'md',
focusBorderColor: '',
isDisabled: false,
isRequired: false,
defaultValue: '',
},
traits: [
{
type: 'core/v1/slot',
properties: {
container: {
id: 'stack',
slot: 'content',
},
},
},
],
},
];

View File

@ -48,6 +48,7 @@
"acorn-walk": "^8.2.0",
"ajv": "^8.8.2",
"codemirror": "^5.63.3",
"escodegen": "^2.0.0",
"formik": "^2.2.9",
"framer-motion": "^4",
"immer": "^9.0.6",
@ -65,6 +66,7 @@
"@sunmao-ui/vite-plugins": "^1.0.2",
"@swc/core": "^1.2.121",
"@types/codemirror": "^5.60.5",
"@types/escodegen": "^0.0.7",
"@types/json-schema": "^7.0.7",
"@types/lodash-es": "^4.17.5",
"@types/tern": "^0.23.4",

View File

@ -11,9 +11,11 @@ import {
SlotName,
} from './IAppModel';
import { genComponent } from './utils';
import mitt from 'mitt';
export class AppModel implements IAppModel {
topComponents: IComponentModel[] = [];
emitter: IAppModel['emitter'] = mitt();
// modules: IModuleModel[] = [];
private schema: ComponentSchema[] = [];
private componentMap: Record<ComponentId, IComponentModel> = {};
@ -66,7 +68,7 @@ export class AppModel implements IAppModel {
createComponent(type: ComponentType, id?: ComponentId): IComponentModel {
const component = genComponent(this.registry, type, id || this.genId(type));
return new ComponentModel(this, component, this.registry);
return new ComponentModel(component, this.registry, this);
}
getComponentById(componentId: ComponentId): IComponentModel | undefined {
@ -109,7 +111,7 @@ export class AppModel implements IAppModel {
if (this.componentMap[c.id as ComponentId]) {
throw new Error(`Duplicate component id: ${c.id}`);
} else {
const comp = new ComponentModel(this, c, this.registry);
const comp = new ComponentModel(c, this.registry, this);
this.componentMap[c.id as ComponentId] = comp;
return comp;
}

View File

@ -42,9 +42,9 @@ export class ComponentModel implements IComponentModel {
_isDirty = false;
constructor(
public appModel: IAppModel,
private schema: ComponentSchema,
private registry: RegistryInterface
private registry: RegistryInterface,
public appModel: IAppModel
) {
this.schema = schema;
@ -52,11 +52,18 @@ export class ComponentModel implements IComponentModel {
this.type = schema.type as ComponentType;
this.spec = this.registry.getComponentByType(this.type) as any;
this.traits = schema.traits.map(t => new TraitModel(t, this, this.registry));
this.traits = schema.traits.map(
t => new TraitModel(t, this.registry, this.appModel, this)
);
this.genStateExample();
this.parentId = this._slotTrait?.rawProperties.container.id;
this.parentSlot = this._slotTrait?.rawProperties.container.slot;
this.properties = new FieldModel(schema.properties);
this.properties = new FieldModel(
schema.properties,
this.spec.spec.properties,
this.appModel,
this
);
}
get slots() {
@ -151,7 +158,7 @@ export class ComponentModel implements IComponentModel {
addTrait(traitType: TraitType, properties: Record<string, unknown>): ITraitModel {
const traitSchema = genTrait(traitType, properties);
const trait = new TraitModel(traitSchema, this, this.registry);
const trait = new TraitModel(traitSchema, this.registry, this.appModel, this);
this.traits.push(trait);
this._isDirty = true;
this.genStateExample();
@ -205,24 +212,23 @@ export class ComponentModel implements IComponentModel {
changeId(newId: ComponentId) {
const oldId = this.id;
const isIdExist = !!this.appModel.getComponentById(newId);
if (isIdExist) {
throw Error(`Id ${newId} already exist`);
}
this.id = newId;
for (const slot in this.children) {
const slotChildren = this.children[slot as SlotName];
slotChildren.forEach(child => {
child.parentId = newId;
const slotTrait = child.traits.find(t => t.type === SlotTraitType);
if (slotTrait) {
slotTrait.properties.update({ container: { id: newId, slot } });
slotTrait._isDirty = true;
}
child._isDirty = true;
});
}
this._isDirty = true;
this.appModel.changeComponentMapId(oldId, newId);
this.appModel.emitter.emit('idChange', { oldId, newId });
return this;
}

View File

@ -1,18 +1,38 @@
import { parseExpression } from '@sunmao-ui/shared';
import { parseExpression, expChunkToString, SpecOptions } from '@sunmao-ui/shared';
import * as acorn from 'acorn';
import * as acornLoose from 'acorn-loose';
import { simple as simpleWalk } from 'acorn-walk';
import { flattenDeep, isArray, isObject } from 'lodash-es';
import { isExpression } from '../validator/utils';
import { ComponentId, IFieldModel, ModuleId } from './IAppModel';
import {
ComponentId,
IAppModel,
IComponentModel,
ITraitModel,
IFieldModel,
ModuleId,
RefInfo,
ASTNode,
AppModelEventType,
} from './IAppModel';
import escodegen from 'escodegen';
import { JSONSchema7 } from 'json-schema';
export class FieldModel implements IFieldModel {
isDynamic = false;
refs: Record<ComponentId | ModuleId, string[]> = {};
refComponentInfos: Record<ComponentId | ModuleId, RefInfo> = {};
private astNodes: Record<string, ASTNode> = {};
private value: unknown | Array<IFieldModel> | Record<string, IFieldModel>;
constructor(value: unknown) {
constructor(
value: unknown,
public spec?: JSONSchema7 & SpecOptions,
private appModel?: IAppModel,
private componentModel?: IComponentModel,
private traitModel?: ITraitModel
) {
this.update(value);
this.appModel?.emitter.on('idChange', this.onReferenceIdChange.bind(this));
}
get rawValue() {
@ -47,7 +67,15 @@ export class FieldModel implements IFieldModel {
(oldValue as FieldModel).updateValue(value[key], false);
newValue = oldValue;
} else {
newValue = new FieldModel(value[key]);
newValue = new FieldModel(
value[key],
(this.spec?.properties?.[key] || this.spec?.items) as
| (JSONSchema7 & SpecOptions)
| undefined,
this.appModel,
this.componentModel,
this.traitModel
);
}
if (isArray(result)) {
@ -109,23 +137,44 @@ export class FieldModel implements IFieldModel {
parseExpression(this.value as string).filter(exp => typeof exp !== 'string')
);
this.refComponentInfos = {};
this.astNodes = {};
exps.forEach(exp => {
let lastIdentifier: ComponentId = '' as ComponentId;
simpleWalk((acornLoose as typeof acorn).parse(exp, { ecmaVersion: 2020 }), {
Expression: node => {
switch (node.type) {
const node = (acornLoose as typeof acorn).parse(exp, { ecmaVersion: 2020 });
this.astNodes[exp] = node as ASTNode;
simpleWalk(node, {
Expression: expressionNode => {
switch (expressionNode.type) {
case 'Identifier':
const key = exp.slice(node.start, node.end) as ComponentId;
this.refs[key] = [];
const key = exp.slice(
expressionNode.start,
expressionNode.end
) as ComponentId;
if (this.refComponentInfos[key]) {
this.refComponentInfos[key].componentIdASTNodes.push(
expressionNode as ASTNode
);
} else {
this.refComponentInfos[key] = {
componentIdASTNodes: [expressionNode as ASTNode],
refProperties: [],
};
}
lastIdentifier = key;
break;
case 'MemberExpression':
const str = exp.slice(node.start, node.end);
const str = exp.slice(expressionNode.start, expressionNode.end);
let path = str.replace(lastIdentifier, '');
if (path.startsWith('.')) {
path = path.slice(1, path.length);
}
this.refs[lastIdentifier]?.push(path);
this.refComponentInfos[lastIdentifier]?.refProperties.push(path);
break;
default:
}
@ -133,4 +182,50 @@ export class FieldModel implements IFieldModel {
});
});
}
private onReferenceIdChange({ oldId, newId }: AppModelEventType['idChange']) {
if (!this.componentModel) {
return;
}
if (this.spec?.isComponentId && this.value === oldId) {
// the normal string property but the `isComponentId` which like the event trait's `componentId` property
// just simply change its value
if (this.traitModel) {
this.traitModel._isDirty = true;
}
this.componentModel._isDirty = true;
this.update(newId);
} else if (this.refComponentInfos[oldId]) {
// the component vars in the expressions
// change the AST nodes values of the related component vars
// then generate the new expression
const exps = parseExpression(this.value as string);
const newExps = exps.map(exp => {
const node = this.astNodes[exp.toString()];
if (node) {
const ref = this.refComponentInfos[oldId];
ref.componentIdASTNodes.forEach(refNode => {
refNode.name = newId;
});
this.refComponentInfos[newId] = ref;
delete this.refComponentInfos[oldId];
return [escodegen.generate(node)];
}
return exp;
});
const value = expChunkToString(newExps);
if (this.traitModel) {
this.traitModel._isDirty = true;
}
this.componentModel._isDirty = true;
this.update(value);
}
}
}

View File

@ -4,6 +4,10 @@ import {
MethodSchema,
RuntimeTrait,
} from '@sunmao-ui/core';
import { SpecOptions } from '@sunmao-ui/shared';
import { Emitter } from 'mitt';
import { Node } from 'acorn';
import { JSONSchema7 } from 'json-schema';
export type ComponentId = string & {
kind: 'componentId';
@ -36,7 +40,12 @@ export type EventName = string & {
kind: 'eventName';
};
export type AppModelEventType = {
idChange: { oldId: ComponentId; newId: ComponentId };
};
export interface IAppModel {
emitter: Emitter<AppModelEventType>;
topComponents: IComponentModel[];
// modules: IModuleModel[];
moduleIds: ModuleId[];
@ -116,14 +125,22 @@ export interface ITraitModel {
updateProperty: (key: string, value: any) => void;
}
export type ASTNode = Node & { name: string };
export type RefInfo = {
componentIdASTNodes: ASTNode[];
refProperties: string[];
};
export interface IFieldModel {
// value: any;
spec?: JSONSchema7 & SpecOptions;
isDynamic: boolean;
rawValue: any;
update: (value: unknown) => void;
getProperty: (key: string) => IFieldModel | void;
getValue: () => unknown | void | IFieldModel;
traverse: (cb: (f: IFieldModel, key: string) => void) => void;
rawValue: any;
// ids of used components in the expression
refs: Record<ComponentId | ModuleId, string[]>;
refComponentInfos: Record<ComponentId | ModuleId, RefInfo>;
}

View File

@ -6,6 +6,7 @@ import {
ITraitModel,
IFieldModel,
TraitId,
IAppModel,
} from './IAppModel';
import { FieldModel } from './FieldModel';
import { genTrait } from './utils';
@ -22,8 +23,9 @@ export class TraitModel implements ITraitModel {
constructor(
trait: TraitSchema,
public parent: IComponentModel,
private registry: RegistryInterface
private registry: RegistryInterface,
private appModel: IAppModel,
public parent: IComponentModel
) {
this.schema = trait;
this.parent = parent;
@ -31,7 +33,13 @@ export class TraitModel implements ITraitModel {
this.id = `${this.parent.id}_trait${traitIdCount++}` as TraitId;
this.spec = this.registry.getTraitByType(this.type);
this.properties = new FieldModel(trait.properties);
this.properties = new FieldModel(
trait.properties,
this.spec.spec.properties,
this.appModel,
this.parent,
this
);
}
get rawProperties() {

View File

@ -136,7 +136,8 @@ export class SchemaValidator implements ISchemaValidator {
.addKeyword('category')
.addKeyword('widgetOptions')
.addKeyword('conditions')
.addKeyword('name');
.addKeyword('name')
.addKeyword('isComponentId');
this.validatorMap = {
components: {},

View File

@ -67,9 +67,9 @@ class ExpressionValidatorRule implements PropertiesValidatorRule {
// validate expression
properties.traverse((fieldModel, key) => {
Object.keys(fieldModel.refs).forEach((id: string) => {
Object.keys(fieldModel.refComponentInfos).forEach((id: string) => {
const targetComponent = appModel.getComponentById(id as ComponentId);
const paths = fieldModel.refs[id as ComponentId];
const paths = fieldModel.refComponentInfos[id as ComponentId].refProperties;
if (targetComponent) {
// case 1: id is a component

View File

@ -4,7 +4,7 @@ import { implementRuntimeTrait } from '../../utils/buildKit';
const ContainerPropertySpec = Type.Object(
{
id: Type.String(),
id: Type.String({ isComponentId: true }),
slot: Type.String(),
},
// don't show this property in the editor

View File

@ -4,6 +4,7 @@ import { CORE_VERSION, CoreWidgetName } from '../constants/core';
const BaseEventSpecObject = {
componentId: Type.String({
title: 'Component ID',
isComponentId: true,
}),
method: Type.Object(
{

View File

@ -11,4 +11,6 @@ export type SpecOptions<WidgetOptions = Record<string, any>> = {
name?: string;
// conditional render
conditions?: Condition[];
// is a reference of component id
isComponentId?: boolean;
};

View File

@ -82,3 +82,15 @@ export const parseExpression = (rawExp: string, parseListItem = false): ExpChunk
return result;
};
export const expChunkToString = (exps: ExpChunk[]): string => {
return exps
.map(expOrExpChunk => {
if (expOrExpChunk instanceof Array) {
return `{{${expChunkToString(expOrExpChunk)}}}`;
}
return expOrExpChunk;
})
.join('');
};

View File

@ -3594,6 +3594,11 @@
dependencies:
"@types/ms" "*"
"@types/escodegen@^0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.7.tgz#a1c3e3dfd76da89f01d7d196eebe227ebe4b6eec"
integrity sha512-46oENdSRNEJXCNrPJoC3vRolZJpfeEm7yvATkd2bCncKFG0PUEyfBCaoacfpcXH4Y5RRuqdVj3J7TI+wwn2SbQ==
"@types/estree@*":
version "0.0.50"
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz"
@ -5965,7 +5970,7 @@ escape-string-regexp@^4.0.0:
escodegen@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd"
integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==
dependencies:
esprima "^4.0.1"