mirror of
https://github.com/smartxworks/sunmao-ui.git
synced 2024-11-27 08:39:59 +08:00
Merge pull request #118 from webzard-io/runtime
Introduce JSON schema form as the default component properties form
This commit is contained in:
commit
64271dc59d
@ -1,4 +1,4 @@
|
||||
import { JSONSchema7, JSONSchema7Object } from 'json-schema';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { parseVersion } from './version';
|
||||
import { ComponentMetadata } from './metadata';
|
||||
import { MethodSchema } from './method';
|
||||
@ -14,7 +14,7 @@ export type Component = {
|
||||
};
|
||||
|
||||
type ComponentSpec = {
|
||||
properties: JSONSchema7Object;
|
||||
properties: JSONSchema7;
|
||||
state: JSONSchema7;
|
||||
methods: MethodSchema[];
|
||||
styleSlots: string[];
|
||||
|
@ -10,11 +10,13 @@ import {
|
||||
ModifyComponentIdOperation,
|
||||
ModifyComponentPropertyOperation,
|
||||
ModifyTraitPropertyOperation,
|
||||
ReplaceComponentPropertyOperation,
|
||||
} from '../../operations/Operations';
|
||||
import { EventTraitForm } from './EventTraitForm';
|
||||
import { GeneralTraitFormList } from './GeneralTraitFormList';
|
||||
import { FetchTraitForm } from './FetchTraitForm';
|
||||
import { Registry } from '@meta-ui/runtime/lib/services/registry';
|
||||
import SchemaField from './JsonSchemaForm/SchemaField';
|
||||
|
||||
type Props = {
|
||||
registry: Registry;
|
||||
@ -113,6 +115,29 @@ export const ComponentForm: React.FC<Props> = props => {
|
||||
onBlur={e => changeComponentId(selectedComponent?.id, e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<VStack width="full" alignItems="start">
|
||||
<strong>Properties</strong>
|
||||
<VStack
|
||||
width="full"
|
||||
padding="4"
|
||||
background="white"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="4"
|
||||
>
|
||||
<SchemaField
|
||||
schema={cImpl.spec.properties}
|
||||
label=""
|
||||
formData={properties}
|
||||
onChange={newFormData => {
|
||||
eventBus.send(
|
||||
'operation',
|
||||
new ReplaceComponentPropertyOperation(selectedId, newFormData)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
{propertyFields.length > 0 ? propertyForm : null}
|
||||
<EventTraitForm component={selectedComponent} registry={registry} />
|
||||
<FetchTraitForm component={selectedComponent} registry={registry} />
|
||||
|
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import SchemaField from './SchemaField';
|
||||
import { FieldProps } from './fields';
|
||||
import { Box, ButtonGroup, IconButton, Flex } from '@chakra-ui/react';
|
||||
import { ArrowDownIcon, ArrowUpIcon, DeleteIcon, AddIcon } from '@chakra-ui/icons';
|
||||
import { parseTypeBox } from '@meta-ui/runtime';
|
||||
import { TSchema } from '@sinclair/typebox';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
function swap<T>(arr: Array<T>, i1: number, i2: number): Array<T> {
|
||||
const tmp = arr[i1];
|
||||
arr[i1] = arr[i2];
|
||||
arr[i2] = tmp;
|
||||
return arr;
|
||||
}
|
||||
|
||||
const ArrayField: React.FC<Props> = props => {
|
||||
const { schema, formData, onChange } = props;
|
||||
const subSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
||||
if (typeof subSchema === 'boolean' || !subSchema) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(formData)) {
|
||||
return (
|
||||
<div>
|
||||
Expected array but got
|
||||
<pre>{JSON.stringify(formData, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{formData.map((v, idx) => {
|
||||
return (
|
||||
<Box key={idx} mb={2}>
|
||||
<ButtonGroup
|
||||
spacing={0}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
display="flex"
|
||||
justifyContent="end"
|
||||
>
|
||||
<IconButton
|
||||
aria-label={`up-${idx}`}
|
||||
icon={<ArrowUpIcon />}
|
||||
disabled={idx === 0}
|
||||
onClick={() => {
|
||||
const newFormData = [...formData];
|
||||
swap(newFormData, idx, idx - 1);
|
||||
onChange(newFormData);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={`down-${idx}`}
|
||||
icon={<ArrowDownIcon />}
|
||||
disabled={idx === formData.length - 1}
|
||||
onClick={() => {
|
||||
const newFormData = [...formData];
|
||||
swap(newFormData, idx, idx + 1);
|
||||
onChange(newFormData);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={`delete-${idx}`}
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
onClick={() => {
|
||||
const newFormData = [...formData];
|
||||
newFormData.splice(idx, 1);
|
||||
onChange(newFormData);
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<SchemaField
|
||||
schema={subSchema}
|
||||
label={subSchema.title || ''}
|
||||
formData={v}
|
||||
onChange={value => {
|
||||
const newFormData = [...formData];
|
||||
newFormData[idx] = value;
|
||||
onChange(newFormData);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Flex justify="end">
|
||||
<IconButton
|
||||
aria-label="add"
|
||||
icon={<AddIcon />}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange(formData.concat(parseTypeBox(subSchema as TSchema)));
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArrayField;
|
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { FieldProps } from './fields';
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const BooleanField: React.FC<Props> = props => {
|
||||
const { formData, onChange } = props;
|
||||
|
||||
return (
|
||||
<Switch isChecked={formData} onChange={evt => onChange(evt.currentTarget.checked)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default BooleanField;
|
@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Select, Box } from '@chakra-ui/react';
|
||||
import SchemaField from './SchemaField';
|
||||
import { FieldProps } from './fields';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const _Field: React.FC<
|
||||
Omit<Props, 'schema'> & { schemas: NonNullable<FieldProps['schema']['anyOf']> }
|
||||
> = props => {
|
||||
const { schemas, formData, onChange } = props;
|
||||
const [schemaIdx, setSchemaIdx] = useState(0);
|
||||
|
||||
const subSchema = schemas[schemaIdx];
|
||||
if (typeof subSchema === 'boolean') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Select
|
||||
mb={1}
|
||||
value={schemaIdx}
|
||||
onChange={evt => setSchemaIdx(parseInt(evt.currentTarget.value))}
|
||||
>
|
||||
{schemas.map((s, idx) => {
|
||||
if (typeof s === 'boolean') {
|
||||
return null;
|
||||
}
|
||||
const text = s.title ? s.title : `schema${idx + 1}(${s.type})`;
|
||||
return (
|
||||
<option key={idx} value={idx}>
|
||||
{text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<SchemaField
|
||||
schema={subSchema}
|
||||
label={subSchema.title || ''}
|
||||
formData={formData}
|
||||
onChange={value => onChange(value)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiSchemaField: React.FC<Props> = props => {
|
||||
const { schema, formData, onChange } = props;
|
||||
|
||||
if (schema.anyOf) {
|
||||
return <_Field formData={formData} onChange={onChange} schemas={schema.anyOf} />;
|
||||
}
|
||||
|
||||
if (schema.oneOf) {
|
||||
return <_Field formData={formData} onChange={onChange} schemas={schema.oneOf} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MultiSchemaField;
|
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { FieldProps } from './fields';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const NullField: React.FC<Props> = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default NullField;
|
@ -0,0 +1,34 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FieldProps } from './fields';
|
||||
import {
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const NumberField: React.FC<Props> = props => {
|
||||
const { formData, onChange } = props;
|
||||
const [value, setValue] = useState(String(formData));
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={(vas, van) => {
|
||||
setValue(vas);
|
||||
onChange(van);
|
||||
}}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberField;
|
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import SchemaField from './SchemaField';
|
||||
import { FieldProps } from './fields';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const ObjectField: React.FC<Props> = props => {
|
||||
const { schema, formData, onChange } = props;
|
||||
|
||||
const properties = Object.keys(schema.properties || {});
|
||||
return (
|
||||
<>
|
||||
{properties.map(name => {
|
||||
const subSchema = (schema.properties || {})[name];
|
||||
if (typeof subSchema === 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SchemaField
|
||||
key={name}
|
||||
schema={subSchema}
|
||||
label={subSchema.title || name}
|
||||
formData={formData[name]}
|
||||
onChange={value =>
|
||||
onChange({
|
||||
...formData,
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectField;
|
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
FormErrorMessage,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { FieldProps, getDisplayLabel } from './fields';
|
||||
import StringField from './StringField';
|
||||
import ObjectField from './ObjectField';
|
||||
import ArrayField from './ArrayField';
|
||||
import BooleanField from './BooleanField';
|
||||
import NumberField from './NumberField';
|
||||
import NullField from './NullField';
|
||||
import MultiSchemaField from './MultiSchemaField';
|
||||
import UnsupportedField from './UnsupportedField';
|
||||
|
||||
type TemplateProps = {
|
||||
id?: string;
|
||||
label?: string;
|
||||
errors?: React.ReactElement;
|
||||
help?: string;
|
||||
description?: string;
|
||||
hidden?: boolean;
|
||||
required?: boolean;
|
||||
displayLabel?: boolean;
|
||||
};
|
||||
|
||||
const DefaultTemplate: React.FC<TemplateProps> = props => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
} = props;
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl isRequired={required} id={id}>
|
||||
{displayLabel && (
|
||||
<>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
{description && <Text fontSize="sm">{description}</Text>}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
{errors && <FormErrorMessage>{errors}</FormErrorMessage>}
|
||||
{help && <FormHelperText>{help}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = FieldProps & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
const SchemaField: React.FC<Props> = props => {
|
||||
const { schema, label, formData, onChange } = props;
|
||||
|
||||
if (isEmpty(schema)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Component = UnsupportedField;
|
||||
|
||||
if (schema.type === 'object') {
|
||||
Component = ObjectField;
|
||||
} else if (schema.type === 'string') {
|
||||
Component = StringField;
|
||||
} else if (schema.type === 'array') {
|
||||
Component = ArrayField;
|
||||
} else if (schema.type === 'boolean') {
|
||||
Component = BooleanField;
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
Component = NumberField;
|
||||
} else if (schema.type === 'null') {
|
||||
Component = NullField;
|
||||
} else if ('anyOf' in schema || 'oneOf' in schema) {
|
||||
Component = MultiSchemaField;
|
||||
} else {
|
||||
console.info('Found unsupported schema', schema);
|
||||
}
|
||||
|
||||
const displayLabel = getDisplayLabel(schema, label);
|
||||
|
||||
return (
|
||||
<DefaultTemplate label={label} displayLabel={displayLabel}>
|
||||
<Component schema={schema} formData={formData} onChange={onChange} />
|
||||
</DefaultTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchemaField;
|
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { FieldProps } from './fields';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const StringField: React.FC<Props> = props => {
|
||||
const { formData, onChange } = props;
|
||||
|
||||
return <Input value={formData} onChange={evt => onChange(evt.currentTarget.value)} />;
|
||||
};
|
||||
|
||||
export default StringField;
|
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { FieldProps } from './fields';
|
||||
|
||||
type Props = FieldProps;
|
||||
|
||||
const UnsupportedField: React.FC<Props> = props => {
|
||||
const { schema, formData } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
Unsupported field schema
|
||||
<pre>{JSON.stringify(schema, null, 2)}</pre>
|
||||
<pre>{JSON.stringify(formData, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsupportedField;
|
@ -0,0 +1,19 @@
|
||||
import { Component } from '@meta-ui/core';
|
||||
|
||||
type Schema = Component['spec']['properties'];
|
||||
|
||||
export type FieldProps = {
|
||||
schema: Schema;
|
||||
formData: any;
|
||||
onChange: (v: any) => void;
|
||||
};
|
||||
|
||||
export function getDisplayLabel(schema: Schema, label: string): boolean {
|
||||
if (!label) {
|
||||
return false;
|
||||
}
|
||||
if (schema.type === 'object') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
@ -11,6 +11,7 @@ import {
|
||||
AddTraitOperation,
|
||||
RemoveTraitOperation,
|
||||
ModifyTraitPropertiesOperation,
|
||||
ReplaceComponentPropertyOperation,
|
||||
} from './Operations';
|
||||
import { produce } from 'immer';
|
||||
import { eventBus } from '../eventBus';
|
||||
@ -141,6 +142,26 @@ export class AppModelManager {
|
||||
this.undoStack.push(undoOperation);
|
||||
}
|
||||
break;
|
||||
case 'replaceComponentProperty':
|
||||
const ro = o as ReplaceComponentPropertyOperation;
|
||||
newApp = produce(this.app, draft => {
|
||||
return draft.spec.components.forEach(c => {
|
||||
if (c.id === ro.componentId) {
|
||||
c.properties = ro.properties;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!noEffect) {
|
||||
const oldValue = this.app.spec.components.find(
|
||||
c => c.id === ro.componentId
|
||||
)?.properties;
|
||||
const undoOperation = new ReplaceComponentPropertyOperation(
|
||||
ro.componentId,
|
||||
oldValue
|
||||
);
|
||||
this.undoStack.push(undoOperation);
|
||||
}
|
||||
break;
|
||||
case 'modifyComponentId':
|
||||
const mIdo = o as ModifyComponentIdOperation;
|
||||
newApp = produce(this.app, draft => {
|
||||
|
@ -27,6 +27,11 @@ export class ModifyComponentPropertyOperation {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ReplaceComponentPropertyOperation {
|
||||
kind = 'replaceComponentProperty';
|
||||
constructor(public componentId: string, public properties: any) {}
|
||||
}
|
||||
|
||||
export class AddTraitOperation {
|
||||
kind = 'addTraitOperation';
|
||||
constructor(
|
||||
@ -66,8 +71,5 @@ export class ModifyTraitPropertiesOperation {
|
||||
}
|
||||
export class SortComponentOperation {
|
||||
kind = 'sortComponent';
|
||||
constructor(
|
||||
public componentId: string,
|
||||
public direction: 'up' | 'down'
|
||||
) {}
|
||||
constructor(public componentId: string, public direction: 'up' | 'down') {}
|
||||
}
|
||||
|
@ -21,4 +21,44 @@ describe('parseTypeBox function', () => {
|
||||
});
|
||||
expect(parseTypeBox(type)).toMatchObject({ key: '', value: [] });
|
||||
});
|
||||
|
||||
it('can parse enum', () => {
|
||||
expect(
|
||||
parseTypeBox(
|
||||
Type.KeyOf(
|
||||
Type.Object({
|
||||
foo: Type.String(),
|
||||
bar: Type.String(),
|
||||
})
|
||||
)
|
||||
)
|
||||
).toEqual('foo');
|
||||
});
|
||||
|
||||
it('can parse anyOf', () => {
|
||||
expect(
|
||||
parseTypeBox(
|
||||
Type.Union([
|
||||
Type.KeyOf(
|
||||
Type.Object({
|
||||
column: Type.String(),
|
||||
'column-reverse': Type.String(),
|
||||
row: Type.String(),
|
||||
'row-reverse': Type.String(),
|
||||
})
|
||||
),
|
||||
Type.Array(
|
||||
Type.KeyOf(
|
||||
Type.Object({
|
||||
column: Type.String(),
|
||||
'column-reverse': Type.String(),
|
||||
row: Type.String(),
|
||||
'row-reverse': Type.String(),
|
||||
})
|
||||
)
|
||||
),
|
||||
])
|
||||
)
|
||||
).toEqual('column');
|
||||
});
|
||||
});
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
StringKind,
|
||||
TSchema,
|
||||
OptionalModifier,
|
||||
UnionKind,
|
||||
} from '@sinclair/typebox';
|
||||
|
||||
export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
|
||||
@ -15,25 +16,30 @@ export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (tSchema.kind) {
|
||||
case StringKind:
|
||||
switch (true) {
|
||||
case tSchema.type === 'string' && 'enum' in tSchema && tSchema.enum.length > 0:
|
||||
return tSchema.enum[0];
|
||||
case tSchema.kind === StringKind:
|
||||
return '';
|
||||
case BooleanKind:
|
||||
case tSchema.kind === BooleanKind:
|
||||
return false;
|
||||
case ArrayKind:
|
||||
case tSchema.kind === ArrayKind:
|
||||
return [];
|
||||
case NumberKind:
|
||||
case tSchema.kind === NumberKind:
|
||||
case tSchema.kind === IntegerKind:
|
||||
return 0;
|
||||
case IntegerKind:
|
||||
return 0;
|
||||
|
||||
case ObjectKind:
|
||||
case tSchema.kind === ObjectKind: {
|
||||
const obj: Static<typeof tSchema> = {};
|
||||
for (const key in tSchema.properties) {
|
||||
obj[key] = parseTypeBox(tSchema.properties[key]);
|
||||
}
|
||||
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);
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user