Building a Simplified Designable Form Platform with Formily

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:

  1. Use form.setValues(cloneDeep(newValues), 'overwrite') to avoid merging issues.
  2. 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.

Tags: formily designable drag-and-drop React schema-json

Posted on Wed, 21 Oct 2026 16:15:43 +0000 by vino4all