mirror of
https://github.com/smartxworks/sunmao-ui.git
synced 2025-03-01 17:56:06 +08:00
Merge pull request #362 from webzard-io/feat/conditional-form
feat: implement conditional rendering
This commit is contained in:
commit
7683ea8b9a
@ -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: [],
|
||||
|
@ -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'
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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);
|
@ -1,5 +1 @@
|
||||
import tableColumnWidget from './TableColumn';
|
||||
|
||||
export * from './TableColumn';
|
||||
|
||||
export const widgets = [tableColumnWidget];
|
||||
export const widgets = [];
|
||||
|
86
packages/editor-sdk/__tests__/condition.spec.ts
Normal file
86
packages/editor-sdk/__tests__/condition.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
18
packages/editor-sdk/jest.config.js
Normal file
18
packages/editor-sdk/jest.config.js
Normal 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',
|
||||
};
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
10
packages/editor-sdk/src/constants/condition.ts
Normal file
10
packages/editor-sdk/src/constants/condition.ts
Normal 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];
|
18
packages/editor-sdk/src/types/condition.ts
Normal file
18
packages/editor-sdk/src/types/condition.ts
Normal 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);
|
@ -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>> = {
|
||||
|
72
packages/editor-sdk/src/utils/condition.ts
Normal file
72
packages/editor-sdk/src/utils/condition.ts
Normal 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);
|
||||
}
|
@ -134,7 +134,8 @@ export class SchemaValidator implements ISchemaValidator {
|
||||
.addKeyword('widget')
|
||||
.addKeyword('weight')
|
||||
.addKeyword('category')
|
||||
.addKeyword('widgetOptions');
|
||||
.addKeyword('widgetOptions')
|
||||
.addKeyword('conditions');
|
||||
|
||||
this.validatorMap = {
|
||||
components: {},
|
||||
|
Loading…
Reference in New Issue
Block a user