DEV Community

玄魂
玄魂

Posted on

Developing VTable Custom Edit Component with React

The content of this article is based on the user interview of @VisActor/VTable.


Introduction to Business Scenarios

In traditional evaluation systems, multiple reviewers usually need to collaborate on annotating the same dataset on a Feishu form. This process involves multiple users editing the same document and uploading the annotated data to the evaluation platform. However, this approach has certain drawbacks: firstly, the original data is not effectively accumulated on the platform, resulting in the inability to form a complete closed loop for dataset construction; secondly, users need to manually upload the annotated data, which not only reduces efficiency but also results in a suboptimal user experience.

To address these issues, improve user annotation efficiency, and reduce reliance on offline Feishu forms, our platform adopts the VTable visual editing solution. This solution allows users to directly edit the data in the table form in our system, enabling direct data storage, historical record retention, and other functions.

Through the VTable editor interface and related event listeners, it is easy to integrate HTML or React/Vue components to expand editing capabilities. This article demonstrates a generalizable solution through an example.

Introduction to VTable

VTable is a key component of the VisActor open-source visualization solution launched by ByteDance - a high-performance table component. It is known for its ultra-high performance and rich visualization capabilities. For more details, please refer to:

  1. Official website: https://www.visactor.io/vtable

    1. Github: https://github.com/VisActor/VTable

Editing Capabilities of VTable

VTable currently offers two main types of editing capabilities:

  • Cell editing
  • Data filling

Data filling uses the fill handle component.

Cell editing is based on the @visactor/vtable-editors component. This article mainly introduces the custom table editing capabilities based on the @visactor/vtable-editors component.

@visactor/vtable-editors

This component comes with built-in editors such as text input boxes, date pickers, drop-down lists, etc., which users can directly use or extend and customize.

First, make sure that the VTable library @visactor/vtable and the related editor package @visactor/vtable-editors have been correctly installed. You can use the following commands to install them:

To install VTable:

//Install using npm
npm install @visactor/vtable
//Install using yarn
yarn add @visactor/vtable
Enter fullscreen mode Exit fullscreen mode

Install @visactor/vtable-editors:

//Install using npm
npm install @visactor/vtable-editors
//Install using yarn
yarn add @visactor/vtable-editors
Enter fullscreen mode Exit fullscreen mode

Import the required type of editor module in the code (you can customize the implementation or reference the editor class in the vtable-editors package):

// Editor classes provided by vtable-editors
import { DateInputEditor, InputEditor, ListEditor } from '@visactor/vtable-editors';
Enter fullscreen mode Exit fullscreen mode

Next, create the editor instance you need to use:

const inputEditor = new InputEditor();
const dateInputEditor = new DateInputEditor();
const listEditor = new ListEditor({ values: ['', ''] });
Enter fullscreen mode Exit fullscreen mode

In the above example, we created a text input box editor (InputEditor), a date picker editor (DateInputEditor), and a drop-down list editor (ListEditor). You can choose the appropriate editor type according to actual needs.

To use the created editor instance, it needs to be registered in VTable.

// Register the Editors to VTable
VTable.register.editor('name-editor', inputEditor);
VTable.register.editor('name-editor2', inputEditor2);
VTable.register.editor('number-editor', numberEditor);
VTable.register.editor('date-editor', dateInputEditor);
VTable.register.editor('list-editor', listEditor);
Enter fullscreen mode Exit fullscreen mode

Next, you need to specify the editor to use in the columns configuration (if it is a pivot table, configure the editor in indicators):

columns: [
  { title: 'name', field: 'name', editor(args)=>{
    if(args.row%2==0)
      return 'name-editor';
    else
      return 'name-editor2';
  } },
  { title: 'age', field: 'age', editor: 'number-editor' },
  { title: 'gender', field: 'gender', editor: 'list-editor' },
  { title: 'birthday', field: 'birthDate', editor: 'date-editor' },
]
Enter fullscreen mode Exit fullscreen mode

Now users can start editing by double-clicking a cell, and then choose the editor to input.

Customize the Editor

If the few editors provided by the VTable-editors library cannot meet your needs, you can customize an editor. To do this, you need to create a class, implement the requirements of the editor interface (IEditor), and provide necessary methods and logic.

You can understand the relationship between the editor and VTable by combining the following flowchart:

Here is an example code of a custom editor, which is a relatively complex cascading list selector, inheriting from the IEditor interface in @visactor/vtable-editors. The interfaces that must be implemented in IEditor are onStart, onEnd, and getValue.

The IEditor interface is defined as follows:

export interface IEditor<V = any> {
  /** * Called when the cell enters edit mode */
  onStart: (context: EditContext<V>) => void;
  /** * Called when the cell exits edit mode */
  onEnd: () => void;
  /**
If this function is provided, VTable will call this function when the user clicks elsewhere.
If this function returns a false value, VTable will call onEnd and exit edit mode.
If this function is not defined or this function returns a true value, VTable will not do anything.
This means that you need to manually call the endEdit provided in onStart to end the edit mode.
   */
  isEditorElement?: (target: HTMLElement) => boolean;
  /** et the current value of the editor. It will be called after onEnd is called.*/
  getValue: () => V;
  /**
Verify whether the new input value is valid
   */
  validateValue?: () => boolean;
}

export interface EditContext<V = any> {
  /**  The container element where the VTable instance is located */
  container: HTMLElement;
  /** Position information of the cell being edited */
  referencePosition: ReferencePosition;
  /** The current value of the cell that is entering the edit mode */
  value: V;
  /**
Callback used to end the edit mode。
   *
In most cases, you don't need to use this callback,
 because VTable already comes with the behavior of pressing the Enter key to end the edit mode; and the behavior of clicking elsewhere with the mouse to end the edit mode can also be obtained through the isEditorElement function.
   *
However, if you have special requirements,
 such as you want to provide a "complete" button inside the editor, 
 or you have external elements like Tooltip that cannot be obtained, 
 you can save this callback and manually end the edit mode when you need it.
   */
  endEdit: () => void;
  col: number;
  row: number;
}
Enter fullscreen mode Exit fullscreen mode

Practical Customization of Editors

Function Definition

Our goal is to define a React cascading component Cascader, with the aim of editing interactions through this component and updating the results to VTable.

For convenience, we directly use the Cascader component of arco-design. The integration method of other React components is also similar.

Code Implementation

We first import the necessary components and related definitions, and import the IEditor interface definition from @visactor/vtable-editors.

import { Cascader } from '@arco-design/web-react';
import React from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import type { IEditor } from '@visactor/vtable-editors';
Enter fullscreen mode Exit fullscreen mode

Next, we implement the CascaderEditor class, the overall definition is as follows:



export class CascaderEditor extends IEditor{
  editorType: string;
  cascaderOptions: null | []; // All columns information
  field: null | string; // The field of the selected cell
  inputRef: React.RefObject<HTMLInputElement>;
  root: null | Root; // In order to mount reactDOM
  container: null | HTMLElement;
  element: null | HTMLElement;

  constructor(editorConfig: any) {
    this.editorType = 'Cascader';
    this.cascaderOptions = null;
    this.field = null;
    this.root = null;
    this.element = null;
    this.container = null;
    this.init(editorConfig);
    this.inputRef = React.createRef();
  }
  /**
   * @description:
   * @param {any} editorConfig
   * @return {*}
   */
  init(editorConfig: any) {
    const { options, value } = editorConfig;
    const filed = value.field;
    this.cascaderOptions = options;
    this.field = filed;
  }
  /**
   * @description: Overwrite the built-in methods of the editor
   */
  onStart(editorContext:{container: HTMLElement | null, referencePosition: any, value: string}) {....}

//Create Component
  createElement(selectMode: string, Options: [], defaultValue: (string | string[])[]) {....}
  //Positioning
  adjustPosition(rect: { top: string; left: string; width: string; height: string }) {...}
  /**
   * @description:Overwrite the built-in methods of the editor
   * @param {object} rect
   * @return {*}
   */
  onEnd() {
    console.log('endEditing cascader');
  }
  /**
   * @description:Overwrite the built-in methods of the editor
   * @param {object} rect
   * @return {*}
   */
  exit() {
    this.container.removeChild(this.element);
  }
  /**
   * @description:Overwrite the built-in methods of the editor, execute when targetIsOnEditor is false
   * @param {object} rect
   * @return {*}
   */
  getValue() {...  }
  /**
   * @description:Overwrite the built-in methods of the editor
   */
  setValue(value: (string | string[])[]) {....}
  /**
   * @description: It will be executed every time you click, the purpose is to judge whether the current clicked area is within the editor range
   * @param {Node} target The element that was clicked
   * @return {Boolean}
   */
  isEditorElement(target: Node | null) {....}
  bindSuccessCallback(successCallback: any) {
    this.successCallback = successCallback;
  }
  /**
   * @param {object} rect
   * @return {*}
   */
  changeValue(value: []) {....}
  /**
   * @description: Filter out the corresponding option from the full cascaderOptions based on the field
   * @param {*} value When entering the edit state, the text in the input box is also the value in the records
   * @param {*} field
   * @param {*} cascaderOptions Full options
   * @return {*}
   */
  getCascaderOptions(value: string, field: null | string, cascaderOptions: null | []) {.....}
  /**
   * @description: Return the corresponding value based on the text
   * @param {*} options
   * @param {*} searchTexts
   * @return {*}
   */
  findValuesAndParents(options: [], searchTexts: string) {.....}
  isClickPopUp(target: { classList: { contains: (arg0: string) => any }; parentNode: any }) {....}
}
Enter fullscreen mode Exit fullscreen mode

After the user triggers the edit state through interaction, VTable will call the onStart method. We initialize the React component in the onStart method and use editorContext to get the position of the cell and position the component. The onStart method is as follows:

  /**
   * @description: Overwrite the built-in methods of the editor
   * @param {HTMLElement} container
   * @param {any} referencePosition
   * @param {string} value
   * @return {*}
   */
  onStart(editorContext:{container: HTMLElement | null, referencePosition: any, value: string}) {
    const {container,referencePosition} = editorContext;
    this.container = container;
    const { selectMode, options } = this.getCascaderOptions(value, this.field, this.cascaderOptions);
    const defaultOptions = this.findValuesAndParents(options, value);
    this.createElement(selectMode, options, defaultOptions);
    setTimeout(() => {
      value && this.setValue(value);
      (null == referencePosition ? void 0 : referencePosition.rect) && this.adjustPosition(referencePosition.rect);
      this.element?.focus();
    }, 0);
  }
Enter fullscreen mode Exit fullscreen mode

The onStart method first calls the getCascaderOptions method, which returns the options and selectMode of the component. The implementation of this method is as follows::

  /**
   * @description: Filter out the corresponding option from the full cascaderOptions based on the field
   * @param {*} Value - When entering the edit state, the text in the input box is also the value in the records
   * @param {*} field
   * @param {*} cascaderOptions Full options
   * @return {*}
   */
  getCascaderOptions(value: string, field: null | string, cascaderOptions: null | []) {
    const advancedConfig = cascaderOptions.filter((option) => option.name === field);
    const selectMode = advancedConfig[0]?.advancedConfig?.selectMode;
    const options = advancedConfig[0]?.advancedConfig?.Cascader;
    return { selectMode, options };
  }
Enter fullscreen mode Exit fullscreen mode

Then call the findValuesAndParents method to return the value selected by the user on the component. The implementation of the findValuesAndParents method is as follows:

/**
   * @description: Return the corresponding value based on the text
   * @param {*} options
   * @param {*} searchTexts
   * @return {*}
   */
  findValuesAndParents(options: [], searchTexts: string) {
    const searchLabels = searchTexts?.split(', ').map((text) => text.trim());
    const results: any[][] = [];

    function search(options, parents: any[]) {
      for (const option of options) {
        // Record the current node's value and parent_id
        const currentParents = [...parents, option.value];
        // If a matching label is found, add its value and parent_id to the result
        if (searchLabels?.includes(option.label)) {
          results.push(currentParents);
        }
        // If there are child nodes, search recursively
        if (option?.children && option.children.length > 0) {
          search(option.children, currentParents);
        }
      }
    }

    search(options, []);
    return results;
  }
Enter fullscreen mode Exit fullscreen mode

Next, call the createElement method to load the component.

/**
   * @description:Overwrite the built-in methods of the editor,
   * @param {string} selectMode
   * @param {*} Options
   * @param {*} defaultValue
   * @return {*}
   */
  createElement(selectMode: string, Options: [], defaultValue: (string | string[])[]) {
    const div = document.createElement('div');
    div.style.position = 'absolute';
    div.style.width = '100%';
    div.style.padding = '4px';
    div.style.boxSizing = 'border-box';
    div.style.backgroundColor = '#232324';
    this.container?.appendChild(div);
    this.root = createRoot(div);
    this.root.render(
      <Cascader
        ref={this.inputRef}
        options={Options}
        expandTrigger="hover"
        onChange={this.changeValue.bind(this)}
        mode={selectMode}
        defaultValue={defaultValue}
        maxTagCount={1}
        style={{ border: 'none' }}
        bordered={false}
      />
    );
    this.element = div;
  }
Enter fullscreen mode Exit fullscreen mode

At this point, the react component has been displayed, and we update the value of VTable through the setValue method. The implementation of setValue is as follows:

/**
   * @description:Overwrite the built-in methods of the editor,
   * @param {object} rect
   * @return {*}
   */
  setValue(value: (string | string[])[]) {
    if (this.inputRef.current) {
      this.inputRef.current.value = value;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Call the adjustPosition method to adjust the position of the component. The implementation of the adjustPosition method is as follows:

  /**
   * @description:Overwrite the built-in methods of the editor,
   * @param {object} rect
   * @return {*}
   */
  adjustPosition(rect: { top: string; left: string; width: string; height: string }) {
    if (this.element) {
      (this.element.style.top = rect.top + 'px'),
        (this.element.style.left = rect.left + 'px'),
        (this.element.style.width = rect.width + 'px'),
        (this.element.style.height = rect.height + 'px');
    }
  }
Enter fullscreen mode Exit fullscreen mode

If you want VTable to automatically end the edit mode, you need to provide the isEditorElement method to determine whether the mouse is clicked inside the component. The implementation is as follows:

/**
   * @description: It will be executed every time you click, the purpose is to judge whether the current clicked area is within the editor range
   * @param {Node} target The element that was clicked
   * @return {Boolean}
   */
  isEditorElement(target: Node | null) {
    // When the cascader is created, a dom is appended after the canvas, and the popup is appended at the end of the body. Whether it is a popup or a dom, it should be considered as clicking on the editor area.
    return this.element?.contains(target) || this.isClickPopUp(target);
  }
Enter fullscreen mode Exit fullscreen mode

When you need to update the value of a cell, VTable will call the getValue method. In this example, the implementation of this method is as follows:

/**
   * @description:Overwrite the built-in methods of the editor,
   * @param {object} rect
   * @return {*}
   */
  getValue() {
    return this.inputRef?.current?.value;
  }
Enter fullscreen mode Exit fullscreen mode

Register and Use the Editor

First, reference the custom editor definition.

// Custom implemented editor class
import { CascaderEditor, InputNumberEditor, SelectEditor, TextAreaEditor } from '@/client/components/TableEditor';
Enter fullscreen mode Exit fullscreen mode

Before using the editor, you need to register the editor instance in VTable.

  useEffect(() => {
    if (!dataTable?.datasetQueryDataList?.columns || !clickedCellValue?.field) return;
    const cascaderEditor = new CascaderEditor({
      options: dataTable?.datasetQueryDataList?.columns,
      value: clickedCellValue,
    });
    VTable?.register?.editor('cascader-editor', cascaderEditor);
  }, [dataTable?.datasetQueryDataList?.columns, clickedCellValue, VTable]);
Enter fullscreen mode Exit fullscreen mode

In the above example, we created the dataTable?.datasetQueryDataList?.columns returned according to the interface, and the cell data clickedCellValue clicked by the current user, set the parameters of the custom CascaderEditor, and registered and used after initializing the editor. The aboveVTable?.register?.editor('cascader-editor', cascaderEditor)is.

Next, you need to specify the editor to use in the columns configuration (if it is a pivot table, configure the editor in indicators):

  const buildTableColumns = useCallback(
    (columns: DatasetColumnSchema[], isView: boolean) => {
      const temp = columns.map((colItem) => {
        const dataType = colItem?.dataType;
        if (dataType === DatasetColumnDataType.Category) {
          return {
            field: colItem.name,
            title: colItem.displayName,
            editor: 'cascader-editor',
            icon: 'edit',
          };
        } else if (dataType === DatasetColumnDataType.Int) {
          return {
            field: colItem.name,
            title: colItem.displayName,
            editor: 'input-number-editor',
            icon: 'edit',
          };
        } else if (dataType === DatasetColumnDataType.Boolean) {
          return {
            field: colItem.name,
            title: colItem.displayName,
            editor: 'list-editor',
            icon: 'edit',
          };
        } else {
          return {
            field: colItem.name,
            title: colItem.displayName,
            editor: 'text-editor',
            icon: 'edit',
          };
        }
      });

      !isView &&
        temp.unshift({
          field: 'isCheck',
          title: '',
          width: 30,
          headerType: 'checkbox',
          cellType: 'checkbox',
        });
      return temp;
    },
    [dataTable?.datasetQueryDataList]
  );
Enter fullscreen mode Exit fullscreen mode

Listen to Edit Events

VTable provides the function of listening to edit events. You can listen to edit data events and execute corresponding logic in the event callback.

Below is an example code of listening to edit events:

const tableInstance = new VTable.ListTable(option);
tableInstance.on('change_cell_value', () => {
  // Edit Cell Data
});
Enter fullscreen mode Exit fullscreen mode

Data Acquisition After Editing

After the user completes the editing and submits the data, you can get the edited data for subsequent processing. You can directly take the value of records.

// Get the full data of the current table
tableInstance.records;
Enter fullscreen mode Exit fullscreen mode

Full Code

Full code:

https://visactor.io/vtable/demo-react/functional-components/arco-select-editor

Implementation Effect

Double-click the cell to enter the edit mode, as shown below:

Some Expectations

VTable also provides the React-VTable component. The overall solution for integrating pop-up type React components will be further improved in React-VTable , making the combination of React components and VTable more user-friendly and powerful.

Collection of Table Requirements and Practical Scenarios

The business party in this practical scenario received a beautiful gift from VisActor.

We continue to collect typical business scenarios and cases in terms of tables, including requirements, and welcome everyone to contact us.

discordhttps://discord.gg/3wPyxVyH6m

twiterhttps://twitter.com/xuanhun1

VisActor official website: www.visactor.io/

On this moonless night, I look forward to you lighting up the starry sky. Thank you for giving us a star.:

githubhttps://github.com/VisActor/VTable

More reference:

  1. VTable-not just a high-performance data analysis table
  2. VisActor — Narrative-oriented Intelligent Visualization Solution
  3. Unveiling the visualization capabilities of the DataWind product in Volcano Engine
  4. More Demos

Top comments (0)