Merge pull request #210 from webzard-io/feat/validate-exp

validate expression
This commit is contained in:
yz-yu 2022-01-18 13:48:36 +08:00 committed by GitHub
commit 77eaba8fa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 525 additions and 197 deletions

View File

@ -5,7 +5,9 @@ import {
SlotName,
TraitType,
} from '../../src/AppModel/IAppModel';
import { AppSchema } from './mock';
import { AppSchema, EventHanlderMockSchema } from './mock';
import { produce } from 'immer';
import { get } from 'lodash-es';
describe('ComponentModel test', () => {
it('compute component property', () => {
@ -21,9 +23,9 @@ describe('ComponentModel test', () => {
expect(button1.parentSlot).toEqual('content');
expect(button1.prevSilbling).toBe(appModel.getComponentById('text2' as ComponentId));
expect(button1.nextSilbing).toBe(null);
expect(button1.properties['text'].value).toEqual({ raw: 'text', format: 'plain' });
expect(button1.rawProperties.text).toEqual({ raw: 'text', format: 'plain' });
const apiFetch = appModel.getComponentById('apiFetch' as ComponentId)!;
expect(apiFetch.stateKeys).toEqual(['fetch']);
expect([...Object.keys(apiFetch.stateExample)]).toEqual(['fetch']);
expect(apiFetch.methods[0].name).toEqual('triggerFetch');
});
});
@ -40,9 +42,11 @@ describe('update component property', () => {
expect(newSchema[5].properties.value).toEqual({ raw: 'hello', format: 'md' });
});
it("update a new property that component don't have",()=>{
expect(newSchema[5].properties.newProperty).toEqual("a property that didn't exist before");
})
it("update a new property that component don't have", () => {
expect(newSchema[5].properties.newProperty).toEqual(
"a property that didn't exist before"
);
});
it('keep immutable after updating component properties', () => {
expect(origin).not.toBe(newSchema);
@ -52,6 +56,23 @@ describe('update component property', () => {
});
});
describe('update event trait handlers(array) property', () => {
const appModel = new AppModel(EventHanlderMockSchema);
const button1 = appModel.getComponentById('button1' as any)!;
const oldHandlers = button1.traits[0].rawProperties.handlers;
const newHandlers = produce(oldHandlers, (draft: any) => {
draft[1].method.parameters.value = 'hello';
});
button1.updateTraitProperties(button1.traits[0].id, { handlers: newHandlers });
const newSchema = appModel.toSchema();
it('update trait array properties', () => {
expect(
get(newSchema[0].traits[0].properties, 'handlers[1].method.parameters.value')
).toEqual('hello');
});
});
describe('append to another component', () => {
const appModel = new AppModel(AppSchema.spec.components);
const origin = appModel.toSchema();

View File

@ -0,0 +1,45 @@
import { FieldModel } from '../../src/AppModel/FieldModel';
describe('Field test', () => {
it('parse static property', () => {
const field = new FieldModel('Hello, world!');
expect(field.isDynamic).toEqual(false);
expect(field.refs).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.rawValue).toEqual('{{input.value}} + {{list[0].text}}');
});
it('parse object property', () => {
const field = new FieldModel({ raw: '{{input.value}}', format: 'md' });
expect(field.isDynamic).toEqual(false);
expect(field.refs).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('format')!.rawValue).toEqual('md');
expect(field.getProperty('format')!.isDynamic).toEqual(false);
expect(field.getProperty('format')!.refs).toEqual({});
});
it('parse array property', () => {
const field = new FieldModel({ data: [1, '{{fetch.data}}'] });
expect(field.isDynamic).toEqual(false);
expect(field.refs).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')!.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'] });
});
});

View File

@ -148,3 +148,40 @@ export const DuplicatedIdSchema: ComponentSchema[] = [
traits: [],
},
];
export const EventHanlderMockSchema: ComponentSchema[] = [
{
id: 'button1',
type: 'chakra_ui/v1/button',
properties: {},
traits: [
{
type: 'core/v1/event',
properties: {
handlers: [
{
type: 'onClick',
componentId: 'input1',
method: {
name: 'setInputValue',
parameters: {
value: '666',
},
},
},
{
type: 'onClick',
componentId: 'input2',
method: {
name: 'setInputValue',
parameters: {
value: '666',
},
},
},
],
},
},
],
},
];

View File

@ -1,5 +1,9 @@
import { registry } from '../../src/setup';
import { ComponentInvalidSchema,ComponentPropertyExpressionSchema } from './mock';
import {
ComponentInvalidSchema,
ComponentPropertyExpressionSchema,
ComponentWrongPropertyExpressionSchema,
} from './mock';
import { SchemaValidator } from '../../src/validator';
const schemaValidator = new SchemaValidator(registry);
@ -13,9 +17,21 @@ describe('Validate component', () => {
it('detect wrong type', () => {
expect(result[1].message).toBe(`must be string`);
});
it('ignore expreesion', () => {
it('ignore expression', () => {
const result = schemaValidator.validate(ComponentPropertyExpressionSchema);
expect(result.length).toBe(0);
})
});
})
describe('validate expression', () => {
const result = schemaValidator.validate(ComponentWrongPropertyExpressionSchema);
it('detect using non-exist variables in expression', () => {
expect(result[0].message).toBe(`Cannot find 'data' in store.`);
});
it('detect using non-exist variables in expression of array property', () => {
expect(result[1].message).toBe(`Cannot find 'fetch' in store.`);
});
it('detect using property which does not exist in component state spec', () => {
expect(result[2].message).toBe(`Component 'input1' does not have property 'noValue'.`);
});
});
});

View File

@ -57,16 +57,61 @@ export const ComponentInvalidSchema: ComponentSchema[] = [
export const ComponentPropertyExpressionSchema: ComponentSchema[] = [
{
id: 'text1',
id: 'list',
type: 'chakra_ui/v1/list',
properties: {
listData: '{{data}}',
template: '{{template}}',
listData: '{{ [] }}',
template: '{{ {} }}',
},
traits: [],
},
];
export const ComponentWrongPropertyExpressionSchema: ComponentSchema[] = [
{
id: 'input1',
type: 'chakra_ui/v1/input',
properties: {
variant: 'outline',
placeholder: '{{data.value}}',
size: 'md',
isDisabled: false,
isRequired: false,
defaultValue: '',
},
traits: [],
},
{
id: 'button1',
type: 'chakra_ui/v1/button',
properties: {
text: {
raw: '{{fetch.data.value}}',
format: 'md',
},
},
traits: [
{
type: 'core/v1/event',
properties: {
handlers: [
{
type: 'onClick',
componentId: 'input1',
method: {
name: 'setInputValue',
parameters: {
value: '{{input1.noValue}}',
},
},
},
],
},
},
],
},
];
export const TraitInvalidSchema: ComponentSchema[] = [
{
id: 'text1',

View File

@ -38,6 +38,9 @@
"@sunmao-ui/chakra-ui-lib": "^0.1.3",
"@sunmao-ui/core": "^0.3.5",
"@sunmao-ui/runtime": "^0.3.9",
"acorn": "^8.7.0",
"acorn-loose": "^8.3.0",
"acorn-walk": "^8.2.0",
"ajv": "^8.8.2",
"codemirror": "^5.63.3",
"formik": "^2.2.9",

View File

@ -1,8 +1,4 @@
import {
ComponentSchema,
MethodSchema,
RuntimeComponent,
} from '@sunmao-ui/core';
import { ComponentSchema, MethodSchema, RuntimeComponent } from '@sunmao-ui/core';
import { registry } from '../setup';
import { genComponent, genTrait } from './utils';
import {
@ -12,7 +8,6 @@ import {
IComponentModel,
SlotName,
StyleSlotName,
StateKey,
ITraitModel,
IFieldModel,
EventName,
@ -22,19 +17,28 @@ import {
} from './IAppModel';
import { TraitModel } from './TraitModel';
import { FieldModel } from './FieldModel';
type ComponentSpecModel = RuntimeComponent<MethodName, StyleSlotName, SlotName, EventName>
const SlotTraitType: TraitType = 'core/v1/slot' as TraitType;
export class ComponentModel implements IComponentModel {
private spec: ComponentSpecModel;
import { merge } from 'lodash-es';
import { parseTypeBox } from '@sunmao-ui/runtime';
const SlotTraitType: TraitType = 'core/v1/slot' as TraitType;
type ComponentSpecModel = RuntimeComponent<
MethodName,
StyleSlotName,
SlotName,
EventName
>;
export class ComponentModel implements IComponentModel {
spec: ComponentSpecModel;
id: ComponentId;
type: ComponentType;
properties: Record<string, IFieldModel> = {};
properties: IFieldModel;
children: Record<SlotName, IComponentModel[]> = {};
parent: IComponentModel | null = null;
parentId: ComponentId | null = null;
parentSlot: SlotName | null = null;
traits: ITraitModel[] = [];
stateExample: Record<string, any> = {};
_isDirty = false;
constructor(public appModel: IAppModel, private schema: ComponentSchema) {
@ -45,35 +49,16 @@ export class ComponentModel implements IComponentModel {
this.spec = registry.getComponentByType(this.type) as any;
this.traits = schema.traits.map(t => new TraitModel(t, this));
// find slot trait
this.traits.forEach(t => {
if (t.type === 'core/v1/slot') {
this.parentId = t.rawProperties.container.id;
this.parentSlot = t.rawProperties.container.slot;
}
});
for (const key in schema.properties) {
this.properties[key] = new FieldModel(schema.properties[key]);
}
this.genStateExample()
this.parentId = this._slotTrait?.rawProperties.container.id;
this.parentSlot = this._slotTrait?.rawProperties.container.slot;
this.properties = new FieldModel(schema.properties);
}
get slots() {
return (this.spec ? this.spec.spec.slots : []) as SlotName[];
}
get stateKeys() {
if (!this.spec) return [];
const componentStateKeys = Object.keys(
this.spec.spec.state.properties || {}
) as StateKey[];
const traitStateKeys: StateKey[] = this.traits.reduce(
(acc, t) => acc.concat(t.stateKeys),
[] as StateKey[]
);
return [...componentStateKeys, ...traitStateKeys];
}
get events() {
return (this.spec ? this.spec.spec.events : []) as EventName[];
}
@ -85,7 +70,7 @@ export class ComponentModel implements IComponentModel {
componentMethods.push({
name: methodName,
parameters: this.spec.spec.methods[methodName as MethodName]!,
})
});
}
const traitMethods: MethodSchema[] = this.traits.reduce(
(acc, t) => acc.concat(t.methods),
@ -99,11 +84,7 @@ export class ComponentModel implements IComponentModel {
}
get rawProperties() {
const obj: Record<string, any> = {};
for (const key in this.properties) {
obj[key] = this.properties[key].value;
}
return obj;
return this.properties.rawValue;
}
get prevSilbling() {
@ -146,11 +127,7 @@ export class ComponentModel implements IComponentModel {
}
updateComponentProperty(propertyName: string, value: any) {
if (!Reflect.has(this.properties, propertyName)) {
this.properties[propertyName] = new FieldModel(value)
} else {
this.properties[propertyName].update(value);
}
this.properties.update({ [propertyName]: value });
this._isDirty = true;
}
@ -159,6 +136,7 @@ export class ComponentModel implements IComponentModel {
const trait = new TraitModel(traitSchema, this);
this.traits.push(trait);
this._isDirty = true;
this.genStateExample()
return trait;
}
@ -200,6 +178,7 @@ export class ComponentModel implements IComponentModel {
if (traitIndex === -1) return;
this.traits.splice(traitIndex, 1);
this._isDirty = true;
this.genStateExample()
}
changeId(newId: ComponentId) {
@ -215,7 +194,7 @@ export class ComponentModel implements IComponentModel {
child.parentId = newId;
const slotTrait = child.traits.find(t => t.type === SlotTraitType);
if (slotTrait) {
slotTrait.properties.container.update({ id: newId, slot });
slotTrait.properties.update({ container: { id: newId, slot } });
slotTrait._isDirty = true;
}
child._isDirty = true;
@ -263,15 +242,15 @@ export class ComponentModel implements IComponentModel {
const trait = this.traits.find(t => t.id === traitId);
if (!trait) return;
for (const property in properties) {
trait.properties[property].update(properties[property]);
trait._isDirty = true;
trait.updateProperty(property, properties[property]);
}
trait._isDirty = true;
this._isDirty = true;
}
updateSlotTrait(parent: ComponentId, slot: SlotName) {
if (this._slotTrait) {
this._slotTrait.properties.container.update({ id: parent, slot });
this._slotTrait.properties.update({ container: { id: parent, slot } });
this._slotTrait._isDirty = true;
} else {
this.addTrait(SlotTraitType, { container: { id: parent, slot } });
@ -290,4 +269,15 @@ export class ComponentModel implements IComponentModel {
}
traverse(this);
}
// should be called after changing traits length
private genStateExample() {
if (!this.spec) return [];
const componentStateSpec = this.spec.spec.state;
const traitsStateSpec = this.traits.map(t => t.spec.spec.state);
const stateSpecs = [componentStateSpec, ...traitsStateSpec];
this.stateExample = stateSpecs.reduce((res, jsonSchema) => {
return merge(res, parseTypeBox(jsonSchema as any, true));
}, {});
}
}

View File

@ -1,18 +1,115 @@
import { IFieldModel } from './IAppModel';
import { parseExpression } from '@sunmao-ui/runtime';
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 { ComponentId, IFieldModel, ModuleId } from './IAppModel';
const regExp = new RegExp('.*{{.*}}.*');
export class FieldModel implements IFieldModel {
value: any;
isDynamic = false;
// refs: Array<ComponentId | ModuleId> = []
refs: Record<ComponentId | ModuleId, string[]> = {};
private value: unknown | Array<IFieldModel> | Record<string, IFieldModel>;
constructor(value: unknown) {
this.update(value);
}
get rawValue() {
if (isObject(this.value)) {
if (isArray(this.value)) {
return this.value.map(field => field.rawValue);
} else {
const _thisValue = this.value as Record<string, IFieldModel>;
const res: Record<string, any> = {};
for (const key in _thisValue) {
res[key] = _thisValue[key].rawValue;
}
return res;
}
}
return this.value;
}
update(value: unknown) {
this.value = value;
if (isObject(value)) {
if (!isObject(this.value)) {
this.value = isArray(value) ? [] : {};
}
for (const key in value) {
const val = (value as Record<string, unknown>)[key];
const _thisValue = this.value as Record<string, IFieldModel>;
if (!_thisValue[key]) {
_thisValue[key] = new FieldModel(val);
} else {
_thisValue[key].update(val);
}
}
} else {
this.value = value;
}
this.isDynamic = typeof value === 'string' && regExp.test(value);
this.parseReferences();
}
getProperty(key: string | number): FieldModel | undefined {
if (typeof this.value === 'object') {
return (this.value as any)[key];
}
return undefined;
}
getValue() {
return this.value
}
traverse(cb: (f: IFieldModel, key: string) => void) {
function _traverse(field: FieldModel, key: string) {
if (isObject(field.value)) {
for (const _key in field.value) {
const val = field.getProperty(_key);
if (val instanceof FieldModel) {
_traverse(val, `${key}.${_key}`);
}
}
} else {
cb(field, key);
}
}
_traverse(this, '');
}
private parseReferences() {
if (!this.isDynamic || typeof this.value !== 'string') return;
const exps = flattenDeep(
parseExpression(this.value as string).filter(exp => typeof exp !== 'string')
);
exps.forEach(exp => {
let lastIdentifier: ComponentId = '' as ComponentId;
simpleWalk((acornLoose as typeof acorn).parse(exp, { ecmaVersion: 2020 }), {
Expression: node => {
switch (node.type) {
case 'Identifier':
const key = exp.slice(node.start, node.end) as ComponentId;
this.refs[key] = [];
lastIdentifier = key;
break;
case 'MemberExpression':
const str = exp.slice(node.start, node.end);
let path = str.replace(lastIdentifier, '');
if (path.startsWith('.')) {
path = path.slice(1, path.length)
}
this.refs[lastIdentifier].push(path);
break;
default:
}
},
});
});
}
}

View File

@ -1,4 +1,9 @@
import { ComponentSchema, TraitSchema, MethodSchema } from '@sunmao-ui/core';
import {
ComponentSchema,
TraitSchema,
MethodSchema,
RuntimeTrait,
} from '@sunmao-ui/core';
export type ComponentId = string & {
kind: 'componentId';
@ -27,9 +32,6 @@ export type MethodName = string & {
export type StyleSlotName = string & {
kind: 'styleSlotName';
};
export type StateKey = string & {
kind: 'stateKey';
};
export type EventName = string & {
kind: 'eventName';
};
@ -59,7 +61,7 @@ export interface IComponentModel {
appModel: IAppModel;
id: ComponentId;
type: ComponentType;
properties: Record<string, IFieldModel>;
properties: IFieldModel;
// just like properties in schema
rawProperties: Record<string, any>;
children: Record<SlotName, IComponentModel[]>;
@ -69,8 +71,9 @@ export interface IComponentModel {
traits: ITraitModel[];
slots: SlotName[];
styleSlots: StyleSlotName[];
// both component's stateKeys and traits's stateKeys
stateKeys: StateKey[];
// fake data generated by the stateSpecs of component and its traits.
// for validator to validate expression
stateExample: Record<string, any>;
// both component's methods and traits's methods
methods: MethodSchema[];
events: EventName[];
@ -98,22 +101,26 @@ export interface IComponentModel {
export interface ITraitModel {
// trait id only exists in model, doesnt exist in schema
spec: RuntimeTrait;
id: TraitId;
parent: IComponentModel;
type: TraitType;
rawProperties: Record<string, any>;
properties: Record<string, IFieldModel>;
properties: IFieldModel;
methods: MethodSchema[];
stateKeys: StateKey[];
_isDirty: boolean;
toSchema(): TraitSchema;
updateProperty: (key: string, value: any) => void;
}
export interface IFieldModel {
value: any;
// value: any;
isDynamic: boolean;
update: (value: any) => void;
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
// dependencies: Array<ComponentId | ModuleId>;
refs: Record<ComponentId | ModuleId, string[]>;
}

View File

@ -5,7 +5,6 @@ import {
TraitType,
ITraitModel,
IFieldModel,
StateKey,
TraitId,
} from './IAppModel';
import { FieldModel } from './FieldModel';
@ -15,10 +14,10 @@ let traitIdCount = 0;
export class TraitModel implements ITraitModel {
private schema: TraitSchema;
private spec: RuntimeTrait;
spec: RuntimeTrait;
id: TraitId;
type: TraitType;
properties: Record<string, IFieldModel> = {};
properties: IFieldModel;
_isDirty = false;
constructor(trait: TraitSchema, public parent: IComponentModel) {
@ -28,28 +27,17 @@ export class TraitModel implements ITraitModel {
this.id = `${this.parent.id}_trait${traitIdCount++}` as TraitId;
this.spec = registry.getTraitByType(this.type);
for (const key in trait.properties) {
this.properties[key] = new FieldModel(trait.properties[key]);
}
this.properties;
this.properties = new FieldModel(trait.properties);
}
get rawProperties() {
const obj: Record<string, any> = {};
for (const key in this.properties) {
obj[key] = this.properties[key].value;
}
return obj;
return this.properties.rawValue
}
get methods() {
return this.spec ? this.spec.spec.methods : []
}
get stateKeys() {
return (this.spec ? Object.keys(this.spec.spec.state.properties || {}) : []) as StateKey[];
}
toSchema(): TraitSchema {
if (this._isDirty) {
this.schema = genTrait(this.type, this.rawProperties);
@ -58,11 +46,7 @@ export class TraitModel implements ITraitModel {
}
updateProperty(key: string, value: any) {
if (this.properties[key]) {
this.properties[key].update(value);
} else {
this.properties[key] = new FieldModel(value);
}
this.properties.update({[key]: value})
this._isDirty = true;
this.parent._isDirty = true;
}

View File

@ -16,7 +16,7 @@ class EditorStore {
// currentEditingComponents, it could be app's or module's components
_selectedComponentId = '';
_hoverComponentId = '';
_dragOverComponentId: string = '';
_dragOverComponentId = '';
// current editor editing target(app or module)
currentEditingTarget: EditingTarget = {
kind: 'app',

1
packages/editor/src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'acorn-loose' {}

View File

@ -15,7 +15,7 @@ export class ModifyComponentPropertiesLeafOperation extends BaseLeafOperation<Mo
const component = appModel.getComponentById(this.context.componentId as ComponentId);
if (component) {
for (const property in this.context.properties) {
const oldValue = component.properties[property]?.value;
const oldValue = component.rawProperties[property];
// assign previous data
this.previousState[property] = oldValue;
let newValue = this.context.properties[property];

View File

@ -21,7 +21,7 @@ export class ModifyTraitPropertiesLeafOperation extends BaseLeafOperation<Modify
}
const trait = component.traits[this.context.traitIndex];
for (const property in this.context.properties) {
const oldValue = trait.properties[property]?.value;
const oldValue = trait.rawProperties[property];
this.previousState[property] = oldValue;
let newValue = this.context.properties[property];
if (_.isFunction(newValue)) {

View File

@ -1,6 +1,7 @@
import { ComponentSchema, RuntimeComponent } from '@sunmao-ui/core';
import { Registry } from '@sunmao-ui/runtime';
import Ajv from 'ajv';
import { PropertiesValidatorRule } from '.';
import { AppModel } from '../AppModel/AppModel';
import {
ISchemaValidator,
@ -18,6 +19,7 @@ export class SchemaValidator implements ISchemaValidator {
private traitRules: TraitValidatorRule[] = [];
private componentRules: ComponentValidatorRule[] = [];
private allComponentsRules: AllComponentsValidatorRule[] = [];
private propertiesRules: PropertiesValidatorRule[] = [];
private componentIdSpecMap: Record<
string,
RuntimeComponent<string, string, string, string>
@ -42,6 +44,9 @@ export class SchemaValidator implements ISchemaValidator {
case 'trait':
this.traitRules.push(rule);
break;
case 'properties':
this.propertiesRules.push(rule);
break;
}
});
}
@ -74,6 +79,19 @@ export class SchemaValidator implements ISchemaValidator {
this.result = this.result.concat(r);
}
});
// validate component properties
this.propertiesRules.forEach(rule => {
const r = rule.validate({
properties: component.properties,
component,
...baseContext,
});
if (r.length > 0) {
this.result = this.result.concat(r);
}
});
this.traitRules.forEach(rule => {
component.traits.forEach(trait => {
const r = rule.validate({
@ -84,6 +102,18 @@ export class SchemaValidator implements ISchemaValidator {
if (r.length > 0) {
this.result = this.result.concat(r);
}
// validate trait properties
this.propertiesRules.forEach(rule => {
const r = rule.validate({
properties: trait.properties,
trait,
component,
...baseContext,
});
if (r.length > 0) {
this.result = this.result.concat(r);
}
});
});
});
});

View File

@ -1,7 +1,7 @@
import { ComponentSchema } from '@sunmao-ui/core';
import { ComponentSchema, RuntimeComponent } from '@sunmao-ui/core';
import { Registry } from '@sunmao-ui/runtime';
import Ajv, { ValidateFunction } from 'ajv';
import { IAppModel, IComponentModel, ITraitModel } from '../AppModel/IAppModel';
import { IAppModel, IComponentModel, IFieldModel, ITraitModel } from '../AppModel/IAppModel';
export interface ValidatorMap {
components: Record<string, ValidateFunction>;
@ -9,10 +9,15 @@ export interface ValidatorMap {
}
interface BaseValidateContext {
components: ComponentSchema[];
validators: ValidatorMap;
registry: Registry;
appModel: IAppModel;
ajv: Ajv;
componentIdSpecMap: Record<
string,
RuntimeComponent<string, string, string, string>
>;
}
export interface ComponentValidateContext extends BaseValidateContext {
@ -23,11 +28,19 @@ export interface TraitValidateContext extends BaseValidateContext {
trait: ITraitModel;
component: IComponentModel;
}
export interface PropertiesValidateContext extends BaseValidateContext {
properties: IFieldModel;
trait?: ITraitModel;
component: IComponentModel;
}
export type AllComponentsValidateContext = BaseValidateContext;
export type ValidateContext =
| ComponentValidateContext
| TraitValidateContext
| PropertiesValidateContext
| AllComponentsValidateContext;
export interface ComponentValidatorRule {
@ -36,6 +49,11 @@ export interface ComponentValidatorRule {
fix?: (validateContext: ComponentValidateContext) => void;
}
export interface PropertiesValidatorRule {
kind: 'properties';
validate: (validateContext: PropertiesValidateContext) => ValidateErrorResult[];
fix?: (validateContext: PropertiesValidateContext) => void;
}
export interface AllComponentsValidatorRule {
kind: 'allComponents';
validate: (validateContext: AllComponentsValidateContext) => ValidateErrorResult[];
@ -51,6 +69,7 @@ export interface TraitValidatorRule {
export type ValidatorRule =
| ComponentValidatorRule
| AllComponentsValidatorRule
| PropertiesValidatorRule
| TraitValidatorRule;
export interface ISchemaValidator {

View File

@ -3,45 +3,6 @@ import {
ComponentValidateContext,
ValidateErrorResult,
} from '../interfaces';
import { isExpression } from '../utils';
class ComponentPropertyValidatorRule implements ComponentValidatorRule {
kind: 'component' = 'component';
validate({ component, validators }: ComponentValidateContext): ValidateErrorResult[] {
const results: ValidateErrorResult[] = [];
const validate = validators.components[component.type];
if (!validate) {
results.push({
message: `Component is not registered: ${component.type}.`,
componentId: component.id,
});
return results;
}
const properties = component.rawProperties
const valid = validate(properties);
if (!valid) {
validate.errors!.forEach(error => {
if (error.keyword === 'type') {
const { instancePath } = error;
const path = instancePath.split('/')[1];
const value = properties[path];
// if value is an expression, skip it
if (isExpression(value)) {
return;
}
}
results.push({
message: error.message || '',
componentId: component.id,
property: error.instancePath,
});
});
}
return results;
}
}
class ModuleValidatorRule implements ComponentValidatorRule {
kind: 'component' = 'component';
@ -52,11 +13,11 @@ class ModuleValidatorRule implements ComponentValidatorRule {
}
const results: ValidateErrorResult[] = [];
let moduleSpec
let moduleSpec;
try {
moduleSpec = registry.getModuleByType(component.rawProperties.type.value as string);
} catch (err) {
moduleSpec = undefined
moduleSpec = undefined;
}
if (!moduleSpec) {
results.push({
@ -68,7 +29,4 @@ class ModuleValidatorRule implements ComponentValidatorRule {
return results;
}
}
export const ComponentRules = [
new ComponentPropertyValidatorRule(),
new ModuleValidatorRule(),
];
export const ComponentRules = [new ModuleValidatorRule()];

View File

@ -0,0 +1,98 @@
import { get, has } from 'lodash-es';
import { ComponentId } from '../../AppModel/IAppModel';
import {
PropertiesValidatorRule,
PropertiesValidateContext,
ValidateErrorResult,
} from '../interfaces';
class PropertySchemaValidatorRule implements PropertiesValidatorRule {
kind: 'properties' = 'properties';
validate({
properties,
component,
trait,
validators,
}: PropertiesValidateContext): ValidateErrorResult[] {
const results: ValidateErrorResult[] = [];
let validate
if (trait) {
validate = validators.traits[trait.type]
} else {
validate = validators.components[component.type];
}
if (!validate) return results;
const valid = validate(properties.rawValue);
if (valid) return results;
validate.errors!.forEach(error => {
// todo: detect deep error
const { instancePath, params } = error;
let key = '';
if (instancePath) {
key = instancePath.split('/')[1];
} else {
key = params.missingProperty;
}
const fieldModel = properties.getProperty(key);
// if field is expression, ignore type error
// fieldModel could be undefiend. if is undefined, still throw error.
if (get(fieldModel, 'isDynamic') !== true) {
results.push({
message: error.message || '',
componentId: component.id,
property: error.instancePath,
traitType: trait?.type,
});
}
});
return results;
}
}
class ExpressionValidatorRule implements PropertiesValidatorRule {
kind: 'properties' = 'properties';
validate({
properties,
component,
trait,
appModel,
}: PropertiesValidateContext): ValidateErrorResult[] {
const results: ValidateErrorResult[] = [];
// validate expression
properties.traverse((fieldModel, key) => {
Object.keys(fieldModel.refs).forEach((id: string) => {
const targetComponent = appModel.getComponentById(id as ComponentId);
if (!targetComponent) {
results.push({
message: `Cannot find '${id}' in store.`,
componentId: component.id,
property: key,
traitType: trait?.type,
});
} else {
const paths = fieldModel.refs[id as ComponentId];
paths.forEach(path => {
if (!has(targetComponent.stateExample, path)) {
results.push({
message: `Component '${id}' does not have property '${path}'.`,
componentId: component.id,
property: key,
traitType: trait?.type,
});
}
});
}
});
});
return results;
}
}
export const PropertiesRules = [new PropertySchemaValidatorRule(), new ExpressionValidatorRule()];

View File

@ -8,48 +8,6 @@ import { EventHandlerSchema } from '@sunmao-ui/runtime';
import { isExpression } from '../utils';
import { ComponentId, EventName } from '../../AppModel/IAppModel';
class TraitPropertyValidatorRule implements TraitValidatorRule {
kind: 'trait' = 'trait';
validate({
trait,
component,
validators,
}: TraitValidateContext): ValidateErrorResult[] {
const results: ValidateErrorResult[] = [];
const validate = validators.traits[trait.type];
if (!validate) {
results.push({
message: `Trait is not registered: ${trait.type}.`,
componentId: component.id,
});
return results;
}
const valid = validate(trait.rawProperties);
if (!valid) {
validate.errors!.forEach(error => {
if (error.keyword === 'type') {
const { instancePath } = error;
const path = instancePath.split('/')[1];
const value = trait.rawProperties[path];
// if value is an expression, skip it
if (isExpression(value)) {
return;
}
}
results.push({
message: error.message || '',
componentId: component.id,
traitType: trait.type,
property: error.instancePath,
});
});
}
return results;
}
}
class EventHandlerValidatorRule implements TraitValidatorRule {
kind: 'trait' = 'trait';
traitMethods = ['setValue', 'resetValue', 'triggerFetch'];
@ -112,7 +70,7 @@ class EventHandlerValidatorRule implements TraitValidatorRule {
if (error.keyword === 'type') {
const { instancePath } = error;
const path = instancePath.split('/')[1];
const value = trait.properties[path];
const value = trait.rawProperties[path];
// if value is an expression, skip it
if (isExpression(value)) {
@ -132,7 +90,7 @@ class EventHandlerValidatorRule implements TraitValidatorRule {
return results;
}
}
export const TraitRules = [
new TraitPropertyValidatorRule(),
new EventHandlerValidatorRule(),
];

View File

@ -2,5 +2,6 @@ import { ValidatorRule } from '..';
import { AllComponentsRules } from './AllComponentsRules';
import { ComponentRules } from './ComponentRules';
import { TraitRules } from './TraitRules';
import { PropertiesRules } from './PropertiesRules';
export const rules: ValidatorRule[] = [...AllComponentsRules, ...ComponentRules, ...TraitRules];
export const rules: ValidatorRule[] = [...AllComponentsRules, ...ComponentRules, ...TraitRules, ...PropertiesRules];

View File

@ -29,6 +29,7 @@ export * from './types';
export * from './types/TraitPropertiesSchema';
export * from './constants';
export * from './services/registry';
export * from './services/stateStore';
export { ModuleRenderer } from './components/_internal/ModuleRenderer';
export { default as Text, TextPropertySchema } from './components/_internal/Text';

View File

@ -11,8 +11,8 @@ import {
UnionKind,
} from '@sinclair/typebox';
export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
if (tSchema.modifier === OptionalModifier) {
export function parseTypeBox(tSchema: TSchema, noOptional = false): Static<typeof tSchema> {
if (tSchema.modifier === OptionalModifier && !noOptional) {
return undefined;
}
@ -31,14 +31,14 @@ export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
case tSchema.kind === ObjectKind: {
const obj: Static<typeof tSchema> = {};
for (const key in tSchema.properties) {
obj[key] = parseTypeBox(tSchema.properties[key]);
obj[key] = parseTypeBox(tSchema.properties[key], noOptional);
}
return obj;
}
case tSchema.kind === UnionKind && 'anyOf' in tSchema && tSchema.anyOf.length > 0:
case tSchema.kind === UnionKind && 'oneOf' in tSchema && tSchema.oneOf.length > 0: {
const subSchema = (tSchema.anyOf || tSchema.oneOf)[0];
return parseTypeBox(subSchema);
return parseTypeBox(subSchema, noOptional);
}
default:
return {};

View File

@ -3416,11 +3416,23 @@ acorn-jsx@^5.3.1:
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-loose@^8.3.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/acorn-loose/-/acorn-loose-8.3.0.tgz#0cd62461d21dce4f069785f8d3de136d5525029a"
integrity sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==
dependencies:
acorn "^8.5.0"
acorn-walk@^7.1.1:
version "7.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn-walk@^8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
acorn@^7.1.1, acorn@^7.4.0:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
@ -3431,6 +3443,11 @@ acorn@^8.2.4:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2"
integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==
acorn@^8.5.0, acorn@^8.7.0:
version "8.7.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
add-stream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa"