committed by
拾邑
22 changed files with 686 additions and 74 deletions
@ -0,0 +1,35 @@ |
|||
import * as React from 'react'; |
|||
import { Menu, Dropdown } from 'antd'; |
|||
import { useTranslation } from 'react-i18next'; |
|||
import { PlusIcon } from '../utils/styles/common'; |
|||
|
|||
interface AddNodeProps { |
|||
addChildField: () => void; |
|||
addSiblingField: () => void; |
|||
} |
|||
|
|||
function AddNodeIcon({ addChildField, addSiblingField }: AddNodeProps): JSX.Element { |
|||
const { t } = useTranslation(); |
|||
const menu = ( |
|||
<Menu> |
|||
<Menu.Item> |
|||
<div tabIndex={0} role="button" onClick={addChildField} onKeyDown={addChildField}> |
|||
{t('child_node')} |
|||
</div> |
|||
</Menu.Item> |
|||
<Menu.Item> |
|||
<div tabIndex={0} role="button" onClick={addSiblingField} onKeyDown={addSiblingField}> |
|||
{t('sibling_node')} |
|||
</div> |
|||
</Menu.Item> |
|||
</Menu> |
|||
); |
|||
|
|||
return ( |
|||
<Dropdown overlay={menu}> |
|||
<PlusIcon /> |
|||
</Dropdown> |
|||
); |
|||
} |
|||
|
|||
export default AddNodeIcon; |
@ -0,0 +1,49 @@ |
|||
import * as React from 'react'; |
|||
import styled from '@emotion/styled'; |
|||
import { Input } from 'antd'; |
|||
|
|||
interface FieldInputProps { |
|||
value: string; |
|||
changeField: (value: string) => void; |
|||
addonAfter: JSX.Element; |
|||
} |
|||
|
|||
const CustomInput = styled(Input)` |
|||
.ant-input-group-addon { |
|||
background: transparent; |
|||
border: none; |
|||
} |
|||
`;
|
|||
|
|||
function FieldInput({ value: initValue, changeField, addonAfter }: FieldInputProps): JSX.Element { |
|||
const [value, setValue] = React.useState(initValue); |
|||
|
|||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { |
|||
setValue(e.target.value); |
|||
}; |
|||
|
|||
const handleBlur = (e: React.ChangeEvent<HTMLInputElement>): void => { |
|||
if (e.target.value !== initValue) { |
|||
changeField(e.target.value); |
|||
} |
|||
}; |
|||
|
|||
const handleKeyUp = (e: React.KeyboardEvent): void => { |
|||
const { value: newValue } = e.target as HTMLInputElement; |
|||
if (e.keyCode === 13 && newValue !== initValue) { |
|||
changeField(newValue); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<CustomInput |
|||
value={value} |
|||
onChange={handleChange} |
|||
onBlur={handleBlur} |
|||
onKeyUp={handleKeyUp} |
|||
addonAfter={addonAfter} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
export default FieldInput; |
@ -1,22 +1,40 @@ |
|||
/** @jsx jsx */ |
|||
import { useEffect, memo, useRef } from 'react'; |
|||
import { jsx } from '@emotion/core'; |
|||
import { useUIState } from '../store/ui'; |
|||
import { isUndefined } from 'lodash-es'; |
|||
import SchemaItem from './schema-item'; |
|||
import { ObjectSchemaItem, KeyRoute } from '../model'; |
|||
|
|||
interface SchemaFormProps { |
|||
data: ObjectSchemaItem; |
|||
keyRoute: KeyRoute; |
|||
} |
|||
|
|||
function SchemaForm({ data, keyRoute }: SchemaFormProps): JSX.Element { |
|||
const { required, properties } = data; |
|||
|
|||
function SchemaForm(): JSX.Element { |
|||
const uiState = useUIState(); |
|||
const { formVisible } = uiState; |
|||
const renders = useRef(1); |
|||
useEffect(() => { |
|||
console.log(11111); |
|||
}); |
|||
renders.current += 1; |
|||
return ( |
|||
<div> |
|||
{formVisible ? 3 : 0} |
|||
(renders:{renders.current}) |
|||
{Object.keys(properties).map((field) => { |
|||
const itemData = properties[field]; |
|||
const isRequired = required && required.includes(field); |
|||
const fieldKeyRoute = keyRoute.concat(field); |
|||
|
|||
return ( |
|||
<SchemaItem |
|||
key={field} |
|||
field={field} |
|||
data={itemData} |
|||
required={isRequired} |
|||
keyRoute={fieldKeyRoute} |
|||
> |
|||
{itemData.type === 'object' && !isUndefined(itemData.properties) ? ( |
|||
<SchemaForm data={itemData} keyRoute={fieldKeyRoute.concat('properties')} /> |
|||
) : null} |
|||
</SchemaItem> |
|||
); |
|||
})} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default memo(SchemaForm); |
|||
export default SchemaForm; |
|||
|
@ -0,0 +1,152 @@ |
|||
/** @jsx jsx */ |
|||
import { jsx } from '@emotion/core'; |
|||
import { Row, Col, Tooltip, Input, Checkbox, Select } from 'antd'; |
|||
import { useTranslation } from 'react-i18next'; |
|||
import { useUIState, useSetUIState } from '../store/ui'; |
|||
import { useSchemaDispatch } from '../store/schema'; |
|||
import { schemaType } from '../utils/schema'; |
|||
import { SchemaType, SchemaItem, KeyRoute } from '../model'; |
|||
import FieldInput from './field-input'; |
|||
import AddNode from './add-node'; |
|||
import { |
|||
CaretDownIcon, |
|||
CaretRightIcon, |
|||
EditIcon, |
|||
SettingIcon, |
|||
PlusIcon, |
|||
CloseIcon, |
|||
} from '../utils/styles/common'; |
|||
|
|||
const { Option } = Select; |
|||
|
|||
interface SchemaItemProps { |
|||
field: string; |
|||
data: SchemaItem; |
|||
required: boolean; |
|||
keyRoute: KeyRoute; |
|||
children: React.ReactChild | null; |
|||
} |
|||
|
|||
function SchemaFormItem({ |
|||
field, |
|||
data, |
|||
required, |
|||
keyRoute, |
|||
children, |
|||
}: SchemaItemProps): JSX.Element { |
|||
const { t } = useTranslation(); |
|||
const schemaDispatch = useSchemaDispatch(); |
|||
const setUIState = useSetUIState(); |
|||
const uiState = useUIState(); |
|||
const key = keyRoute.join('-'); |
|||
const visible = uiState[key] !== false; |
|||
|
|||
const toggleFormVisible = (): void => { |
|||
setUIState((prev) => ({ ...prev, [key]: !visible })); |
|||
}; |
|||
|
|||
const toggleRequired = (): void => { |
|||
if (required) { |
|||
schemaDispatch({ type: 'REMOVE_REQUIRED', keyRoute }); |
|||
} else { |
|||
schemaDispatch({ type: 'ADD_REQUIRED', keyRoute }); |
|||
} |
|||
}; |
|||
|
|||
const addChildField = (): void => { |
|||
schemaDispatch({ type: 'ADD_CHILD_FIELD', keyRoute: keyRoute.concat('properties') }); |
|||
}; |
|||
|
|||
const addSiblingField = (): void => { |
|||
schemaDispatch({ type: 'ADD_SIBLING_FIELD', keyRoute }); |
|||
}; |
|||
|
|||
const removeField = (): void => { |
|||
schemaDispatch({ type: 'REMOVE_FIELD', keyRoute }); |
|||
}; |
|||
|
|||
const changeField = (value: string): void => { |
|||
setTimeout(() => { |
|||
schemaDispatch({ type: 'CHANGE_FIELD', keyRoute, field: value }); |
|||
}, 500); |
|||
}; |
|||
|
|||
const changeType = (value: SchemaType): void => { |
|||
schemaDispatch({ type: 'CHANGE_TYPE', keyRoute, fieldType: value }); |
|||
}; |
|||
|
|||
const changeTitle = (e: React.ChangeEvent<HTMLInputElement>): void => { |
|||
schemaDispatch({ type: 'CHANGE_TITLE', keyRoute, title: e.target.value }); |
|||
}; |
|||
|
|||
const changeDesc = (e: React.ChangeEvent<HTMLInputElement>): void => { |
|||
schemaDispatch({ type: 'CHANGE_DESC', keyRoute, desc: e.target.value }); |
|||
}; |
|||
|
|||
return ( |
|||
<div css={{ marginTop: '10px' }}> |
|||
<Row align="middle"> |
|||
<Col span="8" css={{ paddingLeft: `${keyRoute.length * 10}px` }}> |
|||
<Row align="middle"> |
|||
<Col span="2"> |
|||
{data.type === 'object' && visible && <CaretDownIcon onClick={toggleFormVisible} />} |
|||
{data.type === 'object' && !visible && <CaretRightIcon onClick={toggleFormVisible} />} |
|||
</Col> |
|||
<Col span="22"> |
|||
<FieldInput |
|||
value={field} |
|||
changeField={changeField} |
|||
addonAfter={ |
|||
<Tooltip placement="top" title={t('required')}> |
|||
<Checkbox checked={required} onChange={toggleRequired} /> |
|||
</Tooltip> |
|||
} |
|||
/> |
|||
</Col> |
|||
</Row> |
|||
</Col> |
|||
<Col span="3"> |
|||
<Select value={data.type} onChange={changeType}> |
|||
{schemaType.map((type) => ( |
|||
<Option key={type} value={type}> |
|||
{type} |
|||
</Option> |
|||
))} |
|||
</Select> |
|||
</Col> |
|||
<Col span="5"> |
|||
<Input |
|||
value={data.title} |
|||
onChange={changeTitle} |
|||
placeholder={t('title')} |
|||
addonAfter={<EditIcon />} |
|||
/> |
|||
</Col> |
|||
<Col span="5"> |
|||
<Input |
|||
value={data.description} |
|||
onChange={changeDesc} |
|||
placeholder={t('description')} |
|||
addonAfter={<EditIcon />} |
|||
/> |
|||
</Col> |
|||
<Col span="3"> |
|||
<Tooltip placement="top" title={t('setting')}> |
|||
<SettingIcon /> |
|||
</Tooltip> |
|||
<CloseIcon onClick={removeField} /> |
|||
{data.type === 'object' ? ( |
|||
<AddNode addChildField={addChildField} addSiblingField={addSiblingField} /> |
|||
) : ( |
|||
<Tooltip placement="top" title={t('add_sibling_node')}> |
|||
<PlusIcon onClick={addSiblingField} /> |
|||
</Tooltip> |
|||
)} |
|||
</Col> |
|||
</Row> |
|||
{visible && children ? <div css={{ marginTop: '10px' }}>{children}</div> : null} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default SchemaFormItem; |
@ -1 +1,2 @@ |
|||
export * from './ui'; |
|||
export * from './schema'; |
|||
|
@ -0,0 +1,45 @@ |
|||
export type SchemaType = 'string' | 'number' | 'object' | 'array' | 'boolean' | 'integer'; |
|||
|
|||
type basicSchemaType = 'string' | 'number' | 'boolean' | 'integer'; |
|||
|
|||
interface BasicSchemaItem { |
|||
title?: string; |
|||
description?: string; |
|||
type: basicSchemaType; |
|||
} |
|||
|
|||
export interface ObjectSchemaItem { |
|||
title?: string; |
|||
description?: string; |
|||
type: 'object'; |
|||
properties: { |
|||
[field: string]: SchemaItem; |
|||
}; |
|||
required: string[]; |
|||
} |
|||
|
|||
export interface ArraySchemaItem { |
|||
title?: string; |
|||
description?: string; |
|||
type: 'array'; |
|||
items: SchemaItem; |
|||
} |
|||
|
|||
export type SchemaItem = BasicSchemaItem | ObjectSchemaItem | ArraySchemaItem; |
|||
|
|||
export type SchemaState = ObjectSchemaItem; |
|||
|
|||
export type KeyRoute = (string | number)[]; |
|||
|
|||
export type SchemaAction = |
|||
| { type: 'SET_SCHEMA'; schema: SchemaState } |
|||
| { type: 'ADD_CHILD_FIELD'; keyRoute: KeyRoute } |
|||
| { type: 'ADD_SIBLING_FIELD'; keyRoute: KeyRoute } |
|||
| { type: 'REMOVE_FIELD'; keyRoute: KeyRoute } |
|||
| { type: 'TOGGLE_ALL_CHECKED'; checked: boolean } |
|||
| { type: 'ADD_REQUIRED'; keyRoute: KeyRoute } |
|||
| { type: 'REMOVE_REQUIRED'; keyRoute: KeyRoute } |
|||
| { type: 'CHANGE_FIELD'; keyRoute: KeyRoute; field: string } |
|||
| { type: 'CHANGE_TYPE'; keyRoute: KeyRoute; fieldType: SchemaType } |
|||
| { type: 'CHANGE_TITLE'; keyRoute: KeyRoute; title: string } |
|||
| { type: 'CHANGE_DESC'; keyRoute: KeyRoute; desc: string }; |
@ -1,13 +1,3 @@ |
|||
export interface UIState { |
|||
formVisible: boolean; |
|||
aaa: boolean; |
|||
} |
|||
|
|||
export interface UIAction { |
|||
type: string; |
|||
} |
|||
|
|||
export interface UIContext { |
|||
uiState: UIState; |
|||
uiDispatch: React.Dispatch<UIAction>; |
|||
[key: string]: boolean; |
|||
} |
|||
|
@ -0,0 +1,176 @@ |
|||
import { get, set, unset, remove, cloneDeep } from 'lodash-es'; |
|||
import { SchemaType, SchemaState, SchemaAction, KeyRoute } from '../model'; |
|||
import { defaultSchema, setAllFieldRequired } from '../utils/schema'; |
|||
|
|||
let fieldNum = 0; |
|||
|
|||
const setSchema = (schema: SchemaState): SchemaState => { |
|||
return schema; |
|||
}; |
|||
|
|||
const addRequired = (state: SchemaState, keyRoute: KeyRoute): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
const field = keyRoute[keyRoute.length - 1]; |
|||
const requiredKeyRoute = keyRoute.slice(0, -2).concat('required'); |
|||
const required = get(schema, requiredKeyRoute) || []; |
|||
required.push(field); |
|||
set(schema, requiredKeyRoute, required); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const removeRequired = (state: SchemaState, keyRoute: KeyRoute): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
const field = keyRoute[keyRoute.length - 1]; |
|||
const requiredKeyRoute = keyRoute.slice(0, -2).concat('required'); |
|||
const required = get(schema, requiredKeyRoute) || []; |
|||
remove(required, (item) => item === field); |
|||
set(schema, requiredKeyRoute, required); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const toggleAllChecked = (state: SchemaState, checked: boolean): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
setAllFieldRequired(schema, checked); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const addChildField = (state: SchemaState, keyRoute: KeyRoute): SchemaState => { |
|||
fieldNum += 1; |
|||
|
|||
const newField = `field_${fieldNum}`; |
|||
let schema = cloneDeep(state); |
|||
|
|||
// add new field - string
|
|||
const newKeyRoute = keyRoute.concat(newField); |
|||
set(schema, newKeyRoute, defaultSchema.string); |
|||
|
|||
// add new filed to its parent's required
|
|||
schema = addRequired(schema, newKeyRoute); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const addSiblingField = (state: SchemaState, keyRoute: KeyRoute): SchemaState => { |
|||
fieldNum += 1; |
|||
|
|||
const newField = `field_${fieldNum}`; |
|||
let schema = cloneDeep(state); |
|||
|
|||
// add new field - string
|
|||
const newKeyRoute = keyRoute.slice(0, -1).concat(newField); |
|||
set(schema, newKeyRoute, defaultSchema.string); |
|||
|
|||
// add new filed to its parent's required
|
|||
schema = addRequired(schema, newKeyRoute); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const removeField = (state: SchemaState, keyRoute: KeyRoute): SchemaState => { |
|||
let schema = cloneDeep(state); |
|||
|
|||
// remove field
|
|||
unset(schema, keyRoute); |
|||
|
|||
// remove filed from its parent's required
|
|||
schema = removeRequired(schema, keyRoute); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const changeField = (state: SchemaState, keyRoute: KeyRoute, field: string): SchemaState => { |
|||
let schema = cloneDeep(state); |
|||
|
|||
// temp old field's value and remove old field
|
|||
const temp = get(schema, keyRoute); |
|||
unset(schema, keyRoute); |
|||
|
|||
// remove old filed from its parent's required
|
|||
schema = removeRequired(schema, keyRoute); |
|||
|
|||
// point to new field and add temp to it
|
|||
const fieldKeyRoute = keyRoute.slice(0, -1).concat(field); |
|||
set(schema, fieldKeyRoute, temp); |
|||
|
|||
// add new filed to its parent's required
|
|||
schema = addRequired(schema, fieldKeyRoute); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const changeType = (state: SchemaState, keyRoute: KeyRoute, fieldType: SchemaType): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
// set field new value
|
|||
set(schema, keyRoute, defaultSchema[fieldType]); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const changeTitle = (state: SchemaState, keyRoute: KeyRoute, title: string): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
// point to title and set new value
|
|||
const titleKeyRoute = keyRoute.concat('title'); |
|||
set(schema, titleKeyRoute, title); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const changeDesc = (state: SchemaState, keyRoute: KeyRoute, desc: string): SchemaState => { |
|||
const schema = cloneDeep(state); |
|||
|
|||
// point to desc and set new value
|
|||
const descKeyRoute = keyRoute.concat('description'); |
|||
set(schema, descKeyRoute, desc); |
|||
|
|||
return schema; |
|||
}; |
|||
|
|||
const reducer = (state: SchemaState, action: SchemaAction): SchemaState => { |
|||
switch (action.type) { |
|||
case 'SET_SCHEMA': |
|||
return setSchema(action.schema); |
|||
|
|||
case 'ADD_CHILD_FIELD': |
|||
return addChildField(state, action.keyRoute); |
|||
|
|||
case 'ADD_SIBLING_FIELD': |
|||
return addSiblingField(state, action.keyRoute); |
|||
|
|||
case 'REMOVE_FIELD': |
|||
return removeField(state, action.keyRoute); |
|||
|
|||
case 'TOGGLE_ALL_CHECKED': |
|||
return toggleAllChecked(state, action.checked); |
|||
|
|||
case 'ADD_REQUIRED': |
|||
return addRequired(state, action.keyRoute); |
|||
|
|||
case 'REMOVE_REQUIRED': |
|||
return removeRequired(state, action.keyRoute); |
|||
|
|||
case 'CHANGE_FIELD': |
|||
return changeField(state, action.keyRoute, action.field); |
|||
|
|||
case 'CHANGE_TYPE': |
|||
return changeType(state, action.keyRoute, action.fieldType); |
|||
|
|||
case 'CHANGE_TITLE': |
|||
return changeTitle(state, action.keyRoute, action.title); |
|||
|
|||
case 'CHANGE_DESC': |
|||
return changeDesc(state, action.keyRoute, action.desc); |
|||
|
|||
default: |
|||
return state; |
|||
} |
|||
}; |
|||
|
|||
export default reducer; |
@ -0,0 +1,23 @@ |
|||
import { Dispatch, useReducer } from 'react'; |
|||
import { createContainer } from 'react-tracked'; |
|||
import { SchemaState, SchemaAction } from '../model'; |
|||
import reducer from '../reducer/schema'; |
|||
|
|||
interface StoreProps { |
|||
initialData: SchemaState; |
|||
} |
|||
|
|||
const defaultData: SchemaState = { |
|||
type: 'object', |
|||
properties: {}, |
|||
required: [], |
|||
}; |
|||
|
|||
const useValue = ({ initialData }: StoreProps): [SchemaState, Dispatch<SchemaAction>] => |
|||
useReducer(reducer, initialData || defaultData); |
|||
|
|||
export const { |
|||
Provider: SchemaProvider, |
|||
useTrackedState: useSchemaState, |
|||
useUpdate: useSchemaDispatch, |
|||
} = createContainer(useValue); |
@ -0,0 +1,50 @@ |
|||
import { SchemaItem } from '../model'; |
|||
|
|||
export const defaultSchema = { |
|||
string: { |
|||
type: 'string', |
|||
}, |
|||
number: { |
|||
type: 'number', |
|||
}, |
|||
array: { |
|||
type: 'array', |
|||
items: { |
|||
type: 'string', |
|||
}, |
|||
}, |
|||
object: { |
|||
type: 'object', |
|||
properties: {}, |
|||
}, |
|||
boolean: { |
|||
type: 'boolean', |
|||
}, |
|||
integer: { |
|||
type: 'integer', |
|||
}, |
|||
}; |
|||
|
|||
export const schemaType = ['string', 'number', 'array', 'object', 'boolean', 'integer']; |
|||
|
|||
export const setAllFieldRequired = (schema: SchemaItem, checked: boolean): void => { |
|||
if (schema.type === 'object') { |
|||
const { properties } = schema; |
|||
const fields = Object.keys(properties); |
|||
|
|||
if (checked) { |
|||
schema.required = fields; |
|||
} else { |
|||
delete schema.required; |
|||
} |
|||
|
|||
fields.forEach((field) => { |
|||
const childSchema = properties[field]; |
|||
if (['object', 'array'].includes(childSchema.type)) { |
|||
setAllFieldRequired(childSchema, checked); |
|||
} |
|||
}); |
|||
} else if (schema.type === 'array') { |
|||
setAllFieldRequired(schema.items, checked); |
|||
} |
|||
}; |
@ -0,0 +1,9 @@ |
|||
import 'antd/lib/row/style'; |
|||
import 'antd/lib/col/style'; |
|||
import 'antd/lib/menu/style'; |
|||
import 'antd/lib/input/style'; |
|||
import 'antd/lib/select/style'; |
|||
import 'antd/lib/button/style'; |
|||
import 'antd/lib/tooltip/style'; |
|||
import 'antd/lib/dropdown/style'; |
|||
import 'antd/lib/checkbox/style'; |
Loading…
Reference in new issue