Merge pull request #362 from webzard-io/feat/conditional-form

feat: implement conditional rendering
This commit is contained in:
yz-yu 2022-04-06 14:37:24 +08:00 committed by GitHub
commit 7683ea8b9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 232 additions and 65 deletions

View File

@ -1,6 +1,11 @@
module.exports = {
rootDir: process.cwd(),
projects: ['<rootDir>/packages/core', '<rootDir>/packages/runtime', '<rootDir>/packages/editor'],
projects: [
'<rootDir>/packages/core',
'<rootDir>/packages/runtime',
'<rootDir>/packages/editor',
'<rootDir>/packages/editor-sdk',
],
collectCoverage: true,
collectCoverageFrom: ['**/src/**/*.[jt]s?(x)'],
coveragePathIgnorePatterns: [],

View File

@ -63,13 +63,18 @@ export const ColumnSchema = Type.Object(
},
{
title: 'Button Config',
conditions: [
{
key: 'type',
value: 'button',
},
],
}
),
module: ModuleSchema,
module: { ...ModuleSchema, conditions: [{ key: 'type', value: 'module' }] },
},
{
title: 'Column',
widget: 'chakra_ui/v1/TableColumn'
}
);

View File

@ -1,50 +0,0 @@
import React from 'react';
import { JSONSchema7 } from 'json-schema';
import { implementWidget, WidgetProps, SchemaField } from '@sunmao-ui/editor-sdk';
export const TableColumnWidget: React.FC<WidgetProps> = props => {
const { value, level, path, schema, component, services, onChange } = props;
const { type } = value;
const properties = schema.properties || {};
const TYPE_MAP: Record<string, boolean> = {
buttonConfig: type === 'button',
module: type === 'module',
};
const propertyKeys = Object.keys(properties).filter(key => TYPE_MAP[key] !== false);
const schemas: JSONSchema7[] = propertyKeys.map(key => properties[key] as JSONSchema7);
return (
<>
{schemas.map((propertySchema, index) => {
const key = propertyKeys[index];
return (
<SchemaField
key={key}
component={component}
services={services}
schema={propertySchema}
value={value[key]}
level={level + 1}
path={path.concat(key)}
onChange={propertyValue => {
const result = {
...value,
[key]: propertyValue,
};
onChange(result);
}}
/>
);
})}
</>
);
};
export default implementWidget({
version: 'chakra_ui/v1',
metadata: {
name: 'TableColumn',
},
})(TableColumnWidget);

View File

@ -1,5 +1 @@
import tableColumnWidget from './TableColumn';
export * from './TableColumn';
export const widgets = [tableColumnWidget];
export const widgets = [];

View File

@ -0,0 +1,86 @@
import { EQ, NOT, GT, LT, GTE, LTE, AND, OR } from '../src/constants/condition';
import { shouldRender } from '../src/utils/condition';
import type { Condition } from '../src/types/condition';
describe('conditional render', () => {
it('equal condition', () => {
// type === 'button'
const conditions: Condition[] = [
{
key: 'type',
[EQ]: 'button',
},
];
expect(shouldRender(conditions, { type: 'button' })).toBe(true);
expect(shouldRender(conditions, { type: 'text' })).toBe(false);
});
it('not equal condition', () => {
// type !== 'text'
const conditions: Condition[] = [
{
key: 'type',
[NOT]: 'text',
},
];
expect(shouldRender(conditions, { type: 'button' })).toBe(true);
expect(shouldRender(conditions, { type: 'text' })).toBe(false);
});
it('range condition', () => {
// number >= 0 && number <= 10
const conditions: Condition[] = [
{
key: 'number',
[GTE]: 0,
[LTE]: 10,
},
];
expect(shouldRender(conditions, { number: -1 })).toBe(false);
expect(shouldRender(conditions, { number: 0 })).toBe(true);
expect(shouldRender(conditions, { number: 1 })).toBe(true);
expect(shouldRender(conditions, { number: 10 })).toBe(true);
expect(shouldRender(conditions, { number: 11 })).toBe(false);
});
it('not in range condition', () => {
// number < 0 || number > 10
const conditions: Condition[] = [
{
or: [
{
key: 'number',
[LT]: 0,
},
{
key: 'number',
[GT]: 10,
},
],
},
];
expect(shouldRender(conditions, { number: -1 })).toBe(true);
expect(shouldRender(conditions, { number: 0 })).toBe(false);
expect(shouldRender(conditions, { number: 1 })).toBe(false);
expect(shouldRender(conditions, { number: 10 })).toBe(false);
expect(shouldRender(conditions, { number: 11 })).toBe(true);
});
it('unknown condition', () => {
// type !== 'text'
const conditions: any[] = [
{
key: 'type',
[NOT]: 'text',
unknown: 'value',
},
];
expect(shouldRender(conditions, { type: 'button' })).toBe(true);
expect(shouldRender(conditions, { type: 'text' })).toBe(false);
});
});

View File

@ -0,0 +1,18 @@
const path = require('path');
module.exports = {
...require('../../config/jest.config'),
moduleFileExtensions: ['ts', 'js', 'tsx'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.[jt]sx?$': [
'babel-jest',
{
configFile: path.resolve(
__dirname,
'../../config/babel.react.config.js'
),
},
],
},
testEnvironment: 'jsdom',
};

View File

@ -20,7 +20,8 @@
"build": "tsup src/index.ts --format cjs,esm,iife --legacy-output --no-splitting --clean --sourcemap --platform browser",
"dev": "tsup src/index.ts --watch --format cjs,esm,iife --legacy-output --no-splitting --clean --sourcemap --platform browser",
"typings": "tsc --emitDeclarationOnly",
"prepublish": "npm run build && npm run typings"
"prepublish": "npm run build && npm run typings",
"test": "jest"
},
"dependencies": {
"@chakra-ui/icons": "^1.0.15",

View File

@ -12,6 +12,7 @@ import {
Accordion,
} from '@chakra-ui/react';
import { PRESET_PROPERTY_CATEGORY } from '../../constants/category';
import { shouldRender } from '../../utils/condition';
const PRESET_PROPERTY_CATEGORY_WEIGHT: Record<
keyof typeof PRESET_PROPERTY_CATEGORY | 'Advance',
@ -81,7 +82,7 @@ export const CategoryWidget: React.FC<WidgetProps> = props => {
if (typeof schema === 'boolean') {
return null;
}
return (
return shouldRender(schema.conditions || [], value) ? (
<SchemaField
key={name}
component={component}
@ -100,7 +101,7 @@ export const CategoryWidget: React.FC<WidgetProps> = props => {
});
}}
/>
);
) : null;
})}
</AccordionPanel>
</AccordionItem>

View File

@ -4,6 +4,7 @@ import { SchemaField } from './SchemaField';
import { WidgetProps } from '../../types/widget';
import { ExpressionWidgetOptionsSchema } from './ExpressionWidget';
import { implementWidget, mergeWidgetOptionsIntoSchema } from '../../utils/widget';
import { shouldRender } from '../../utils/condition';
const ObjectFieldWidgetOptions = Type.Object({
expressionOptions: Type.Optional(ExpressionWidgetOptionsSchema),
@ -19,11 +20,11 @@ export const ObjectField: React.FC<WidgetProps<ObjectFieldWidgetOptionsType>> =
return (
<>
{properties.map(name => {
const subSchema = (schema.properties || {})[name];
const subSchema = (schema.properties || {})[name] as WidgetProps['schema'];
if (typeof subSchema === 'boolean') {
return null;
}
return (
return shouldRender(subSchema.conditions || [], value) ? (
<SchemaField
component={component}
key={name}
@ -47,7 +48,7 @@ export const ObjectField: React.FC<WidgetProps<ObjectFieldWidgetOptionsType>> =
})
}
/>
);
) : null;
})}
</>
);

View File

@ -0,0 +1,10 @@
export const EQ = 'value';
export const NOT = 'not';
export const GT = 'gt';
export const LT = 'lt';
export const GTE = 'gte';
export const LTE = 'lte';
export const COMPARISON_OPERATORS = [EQ, NOT, GT, LT, GTE, LTE];
export const AND = 'and';
export const OR = 'or';
export const LOGIC = [AND, OR];

View File

@ -0,0 +1,18 @@
type BaseType = string | number | boolean;
type Field = { key: string };
// compare
type EQ = { value: BaseType };
type Not = { not: BaseType };
type GT = { gt: number };
type LT = { lt: number };
type GTE = { gte: number };
type LTE = { lte: number };
export type ComparisonOperator = Field & (EQ | Not | GT | LT | GTE | LTE);
// logic
type And = { and: (Logic | ComparisonOperator)[] };
type Or = { or: (Logic | ComparisonOperator)[] };
export type Logic = And | Or;
export type Condition = (ComparisonOperator | Logic);

View File

@ -2,6 +2,7 @@ import React from 'react';
import { JSONSchema7 } from 'json-schema';
import { ComponentSchema } from '@sunmao-ui/core';
import { EditorServices } from './editor';
import { Condition } from './condition';
export type EditorSchema<WidgetOptions = Record<string, any>> = {
defaultValue?: any;
@ -12,6 +13,8 @@ export type EditorSchema<WidgetOptions = Record<string, any>> = {
category?: string;
weight?: number;
name?: string;
// conditional render
conditions?: Condition[];
};
export type WidgetProps<WidgetOptions = Record<string, any>> = {

View File

@ -0,0 +1,72 @@
import { EQ, NOT, GT, LT, GTE, LTE, COMPARISON_OPERATORS } from '../constants/condition';
import type { Condition, ComparisonOperator } from '../types/condition';
export function isComparisonOperator(
condition: Condition
): condition is ComparisonOperator {
return 'key' in condition;
}
export function checkComparisonCondition(
condition: ComparisonOperator,
data: Record<string, any>
): boolean {
const { key, ...operators } = condition;
const value = data[key];
return (Object.keys(operators) as (keyof typeof COMPARISON_OPERATORS)[]).every(
operator => {
switch (operator) {
case EQ:
return operators[operator] === value;
case NOT:
return operators[operator] !== value;
case GT:
return operators[operator] < value;
case LT:
return operators[operator] > value;
case GTE:
return operators[operator] <= value;
case LTE:
return operators[operator] >= value;
}
return true;
}
);
}
export function checkUnionConditions(
conditions: Condition[],
data: Record<string, any>
): boolean {
return conditions.some(condition => checkCondition(condition, data));
}
export function checkIntersectionConditions(
conditions: Condition[],
data: Record<string, any>
): boolean {
return conditions.every(condition => checkCondition(condition, data));
}
export function checkCondition(condition: Condition, data: Record<string, any>): boolean {
if (isComparisonOperator(condition)) {
return checkComparisonCondition(condition, data);
} else if ('or' in condition) {
return checkUnionConditions(condition.or, data);
} else if ('and' in condition) {
return checkIntersectionConditions(condition.and, data);
}
return true;
}
export function shouldRender(
conditions: Condition[],
data: Record<string, any>
): boolean {
if (!conditions) return true;
return checkIntersectionConditions(conditions, data);
}

View File

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