Merge pull request #118 from webzard-io/runtime

Introduce JSON schema form as the default component properties form
This commit is contained in:
tanbowensg 2021-11-15 17:28:35 +08:00 committed by GitHub
commit 64271dc59d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 522 additions and 16 deletions

View File

@ -1,4 +1,4 @@
import { JSONSchema7, JSONSchema7Object } from 'json-schema'; import { JSONSchema7 } from 'json-schema';
import { parseVersion } from './version'; import { parseVersion } from './version';
import { ComponentMetadata } from './metadata'; import { ComponentMetadata } from './metadata';
import { MethodSchema } from './method'; import { MethodSchema } from './method';
@ -14,7 +14,7 @@ export type Component = {
}; };
type ComponentSpec = { type ComponentSpec = {
properties: JSONSchema7Object; properties: JSONSchema7;
state: JSONSchema7; state: JSONSchema7;
methods: MethodSchema[]; methods: MethodSchema[];
styleSlots: string[]; styleSlots: string[];

View File

@ -10,11 +10,13 @@ import {
ModifyComponentIdOperation, ModifyComponentIdOperation,
ModifyComponentPropertyOperation, ModifyComponentPropertyOperation,
ModifyTraitPropertyOperation, ModifyTraitPropertyOperation,
ReplaceComponentPropertyOperation,
} from '../../operations/Operations'; } from '../../operations/Operations';
import { EventTraitForm } from './EventTraitForm'; import { EventTraitForm } from './EventTraitForm';
import { GeneralTraitFormList } from './GeneralTraitFormList'; import { GeneralTraitFormList } from './GeneralTraitFormList';
import { FetchTraitForm } from './FetchTraitForm'; import { FetchTraitForm } from './FetchTraitForm';
import { Registry } from '@meta-ui/runtime/lib/services/registry'; import { Registry } from '@meta-ui/runtime/lib/services/registry';
import SchemaField from './JsonSchemaForm/SchemaField';
type Props = { type Props = {
registry: Registry; registry: Registry;
@ -113,6 +115,29 @@ export const ComponentForm: React.FC<Props> = props => {
onBlur={e => changeComponentId(selectedComponent?.id, e.target.value)} onBlur={e => changeComponentId(selectedComponent?.id, e.target.value)}
/> />
</FormControl> </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} {propertyFields.length > 0 ? propertyForm : null}
<EventTraitForm component={selectedComponent} registry={registry} /> <EventTraitForm component={selectedComponent} registry={registry} />
<FetchTraitForm component={selectedComponent} registry={registry} /> <FetchTraitForm component={selectedComponent} registry={registry} />

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -11,6 +11,7 @@ import {
AddTraitOperation, AddTraitOperation,
RemoveTraitOperation, RemoveTraitOperation,
ModifyTraitPropertiesOperation, ModifyTraitPropertiesOperation,
ReplaceComponentPropertyOperation,
} from './Operations'; } from './Operations';
import { produce } from 'immer'; import { produce } from 'immer';
import { eventBus } from '../eventBus'; import { eventBus } from '../eventBus';
@ -141,6 +142,26 @@ export class AppModelManager {
this.undoStack.push(undoOperation); this.undoStack.push(undoOperation);
} }
break; 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': case 'modifyComponentId':
const mIdo = o as ModifyComponentIdOperation; const mIdo = o as ModifyComponentIdOperation;
newApp = produce(this.app, draft => { newApp = produce(this.app, draft => {

View File

@ -27,6 +27,11 @@ export class ModifyComponentPropertyOperation {
) {} ) {}
} }
export class ReplaceComponentPropertyOperation {
kind = 'replaceComponentProperty';
constructor(public componentId: string, public properties: any) {}
}
export class AddTraitOperation { export class AddTraitOperation {
kind = 'addTraitOperation'; kind = 'addTraitOperation';
constructor( constructor(
@ -66,8 +71,5 @@ export class ModifyTraitPropertiesOperation {
} }
export class SortComponentOperation { export class SortComponentOperation {
kind = 'sortComponent'; kind = 'sortComponent';
constructor( constructor(public componentId: string, public direction: 'up' | 'down') {}
public componentId: string,
public direction: 'up' | 'down'
) {}
} }

View File

@ -21,4 +21,44 @@ describe('parseTypeBox function', () => {
}); });
expect(parseTypeBox(type)).toMatchObject({ key: '', value: [] }); 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');
});
}); });

View File

@ -8,6 +8,7 @@ import {
StringKind, StringKind,
TSchema, TSchema,
OptionalModifier, OptionalModifier,
UnionKind,
} from '@sinclair/typebox'; } from '@sinclair/typebox';
export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> { export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
@ -15,25 +16,30 @@ export function parseTypeBox(tSchema: TSchema): Static<typeof tSchema> {
return undefined; return undefined;
} }
switch (tSchema.kind) { switch (true) {
case StringKind: case tSchema.type === 'string' && 'enum' in tSchema && tSchema.enum.length > 0:
return tSchema.enum[0];
case tSchema.kind === StringKind:
return ''; return '';
case BooleanKind: case tSchema.kind === BooleanKind:
return false; return false;
case ArrayKind: case tSchema.kind === ArrayKind:
return []; return [];
case NumberKind: case tSchema.kind === NumberKind:
case tSchema.kind === IntegerKind:
return 0; return 0;
case IntegerKind: case tSchema.kind === ObjectKind: {
return 0;
case ObjectKind:
const obj: Static<typeof tSchema> = {}; const obj: Static<typeof tSchema> = {};
for (const key in tSchema.properties) { for (const key in tSchema.properties) {
obj[key] = parseTypeBox(tSchema.properties[key]); obj[key] = parseTypeBox(tSchema.properties[key]);
} }
return obj; 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: default:
return {}; return {};
} }