Background
The designable platform developed by Alibaba is a form designer built upon the Formily form engine and schema-based JSON structures. For this project, we aim to create a simplified version of such a platform, including drag-and-drop functionality for components, configuration of form fields via schema settings on the right panel, and support for reaction-based field interactivity.
Features Implemented:
- Drag Zone: Displays a list of components with their names and icons, allowing them to be dragged.
- Drop Zone: Allows dropped components to render correctly using Formily's schema JSON while preserving original components.
- Field Configuration: Supports standard attributes like key, title, default, required, and multi-language support for certain fields.
- Reaction Logic: Includes code editing capabilities with auto-completion and field composition.
Technology Stack
- Ant Design: React UI component library.
- Formily: Form engine for rendering forms directly from schema JSON.
- React Beautiful DnD: Enables list sorting through drag-and-drop.
- React DnD: Handles drag and drop operations for component placement.
Drag and Drop Implementation
Type Definitions
Define the structure of components used in the design area:
export interface ComponentConfig {
id?: string;
key: string;
title: string;
component_type: ComponentType;
schema: ISchema;
}
export enum ComponentType {
TextInputRow = 'TextInputRow',
DateInputRow = 'DateInputRow',
CheckBox = 'CheckBox',
}
export const ComponentConfigs: ComponentConfig[] = [
{
key: ComponentType.TextInputRow,
schema: {
type: 'string',
'x-component': 'Input',
'x-decorator': 'FormItem',
},
},
{
key: ComponentType.DateInputRow,
schema: {
type: 'string',
'x-component': 'DatePicker',
'x-decorator': 'FormItem',
},
},
{
key: ComponentType.CheckBox,
schema: {
type: 'string',
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
},
},
].map((i) => ({ ...i, title: i.key, component_type: i.key }));
Dragging Components
Use useDrag to make DOM elemants draggable:
export const WrapComponent = (props: DndComponentDndItem) => {
const [, drag] = useDrag(() => ({
type: ItemTypes.CARD,
item: props.config,
collect: (monitor) => ({ isDragging: !!monitor.isDragging() }),
}));
return (
<div
style={{
width: 100,
cursor: 'move',
height: 50,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
borderRadius: 4,
}}
ref={drag}
>
{props.children}
</div>
);
};
Dropping Components
Use useDrop to enable dropping components into the canvas:
export const DropContainer = observer((props: DndComponentDndItem) => {
const [droppedItems, setDroppedItems] = useState<FieldConfigItem[]>([]);
const field = useField() as ObjectField;
const form = useForm();
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: ItemTypes.CARD,
drop: (item: ComponentConfig) => {
const newKey = `${item.key}-${Math.random()}`;
const newRnSchema = {
title: {},
component: item.component_type,
index: Object.keys(formFieldsSchemaRef.current)?.length || 0,
} as unknown as RNSchemaItem;
const newItem = {
...item,
key: '',
id: newKey,
rn_schema: newRnSchema,
...item.web_schema,
...newRnSchema,
} as FieldConfigItem;
setDroppedItems((i) => [...i, newItem]);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}));
const handleFieldChange = (value: FieldConfigItem[]) => {
const newSchema: Record<string, any> = {};
value?.forEach(({ key, web_schema, rn_schema }, index) => {
newSchema[key] = { web_schema, rn_schema: { ...rn_schema, index } };
});
setDroppedItems(value);
};
return (
<>
<div
style={{ height: 200, width: '100%', backgroundColor: '#f2f2f2', borderRadius: 4, overflowY: 'auto' }}
ref={drop}
>
<DragFormItem items={droppedItems} onChange={handleFieldChange} />
</div>
</>
);
});
Sorting List Items
Utilize react-beautiful-dnd for sorting form items:
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
const DragIcon = () => {
const dragHandleProps = useContext<any>(DragHandleContext);
return <MenuOutlined {...dragHandleProps} style={{ cursor: 'grab', marginRight: 5 }} />;
};
const ListItem = ({ item, provided, onDelete, onEdit }: { item: FieldConfigItem; provided: any; onDelete: (item: FieldConfigItem) => void; onEdit: (item: FieldConfigItem) => void; }) => {
const { web_schema, id, key } = item;
const form = useMemo(() => createForm(), [item]);
return (
<div ref={provided.innerRef} {...provided.draggableProps}>
<DragHandleContext.Provider value={provided.dragHandleProps}>
<Row justify="start" align="middle">
<DragIcon />
<FormProvider form={form}>
<SchemaField schema={new Schema({ type: 'object', properties: { [key || id]: web_schema } })} />
</FormProvider>
<Row style={{ marginLeft: 'auto' }}>
<FieldConfigAction initItem={item} onChange={onEdit} />
<Button shape="circle" icon={<DeleteOutlined />} size="small" onClick={() => onDelete(item)} />
</Row>
</Row>
</DragHandleContext.Provider>
</div>
);
};
export default (props: Props) => {
const { items, onChange } = props;
const [data, setData] = useState<FieldConfigItem[]>([]);
useEffect(() => {
items && setData(items);
}, [items]);
const onDragEnd = (result: any) => {
const { source, destination } = result;
if (source && destination) {
const newData = Array.from(data);
const [removed] = newData.splice(result.source.index, 1);
newData.splice(result.destination.index, 0, removed);
setData(newData);
onChange(newData);
}
};
const handleDelete = (item: FieldConfigItem) => {
const newData = data.filter((i) => i.id !== item.id);
setData(newData);
onChange(newData);
};
const handleEdit = (item: FieldConfigItem) => {
const newData = Array.from(data);
const editIndex = newData.findIndex((i) => i.id === item.id);
newData.splice(editIndex, 1, { ...item, ...getFieldSchema(item) });
setData(newData);
onChange(newData);
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={{ padding: 5 }}>
{data?.map((item, index) => (
<div style={{ marginTop: 5 }}>
<Draggable draggableId={index.toString()} index={index} key={uniqueId()}>
{(provided, snapshot) => (
<ListItem provided={provided} item={item} onDelete={handleDelete} onEdit={handleEdit} />
)}
</Draggable>
</div>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
};
Field Configuration
Fields are categorized into common and custom fields:
- Common Fields: title, default, required, disabled
- Custom Fields: Component-specific fields like sample_image for PhotoInput
When editing, a modal dialog uses schema JSON to render configuration options. On save, the form values are applied manually to update the current item.
Reaction Logic
Configuration includes dependencies, state changes, and executable code:
- Depandencies: Define which fields affect the current one.
- State: Specify what attributes change based on conditions.
- Run: Execute JavaScript code snippets for complex interactions.
Custom Components
PolyInput
A component that toggles between different input types.
LanguageTextRule
Supports multiple language entries with validation ensuring all or none are filled:
const getProperties = (
languages: string[],
componentType: LanguageComponentType,
schemaProps?: Record<string, any>
) => {
const properties: Record<string, any> = {};
languages?.forEach((language) => {
properties[language] = {
type: 'string',
title: language,
'x-decorator': 'FormItem',
'x-decorator-props': { labelWidth: 100 },
'x-component': LanguageComponentMap[componentType],
'x-validator': (value: any, rule: any, ctx: { field: Field; form: Form }) => {
let count = 0;
languages.forEach((lan) => {
const curField = ctx.field.query(`.${lan}`).take() as Field;
if (curField.value) count++;
});
if (count > 0 && count < languages.length) {
return value ? true : { type: 'error', message: `The ${language} value is required` };
} else {
return true;
}
},
...schemaProps,
};
});
return properties;
};
ArrayCards
Custom cards with editable titles, extending Formily’s array-cards:
const isTitleComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Title') > -1;
};
MonacoInput
Monaco-based editor for code editing with syntax highlighting and validation:
const JsonEditor = ({ height = 100, defaultValue = {}, onChange }: Props) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const handleEditorChange = debounce((value: string | undefined) => {
value && onChange(JSON.parse(value));
}, 500);
return (
<Editor
height={height}
defaultLanguage="json"
defaultValue={JSON.stringify(defaultValue, null, 2)}
onChange={handleEditorChange}
options={{ automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false }}
onMount={(editor) => (editorRef.current = editor)}
/>
);
};
Schema JSON Configuration
Allows pasting existing schema JSON, converting it to web_schema and rn_schema, and validating before saving.
Handling Updates
When updating schema JSON:
- Use
form.setValues(cloneDeep(newValues), 'overwrite')to avoid merging issues. - For initial paste scenarios, directly assign
form.values = cloneDeep(newValues).
Listening to Changes
To track updates to form configurations, use onFieldReact instead of onFieldValueChange due to limitations in array element tracking.