Developing VTable Custom Edit Component with React
玄魂
Posted on April 23, 2024
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:
Official website: https://www.visactor.io/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
Install @visactor/vtable-editors:
//Install using npm
npm install @visactor/vtable-editors
//Install using yarn
yarn add @visactor/vtable-editors
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';
Next, create the editor instance you need to use:
const inputEditor = new InputEditor();
const dateInputEditor = new DateInputEditor();
const listEditor = new ListEditor({ values: ['女', '男'] });
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);
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' },
]
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;
}
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';
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 }) {....}
}
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);
}
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 };
}
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;
}
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;
}
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;
}
}
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');
}
}
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);
}
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;
}
Register and Use the Editor
First, reference the custom editor definition.
// Custom implemented editor class
import { CascaderEditor, InputNumberEditor, SelectEditor, TextAreaEditor } from '@/client/components/TableEditor';
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]);
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]
);
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
});
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;
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.
discord:https://discord.gg/3wPyxVyH6m
twiter:https://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.:
github:https://github.com/VisActor/VTable
More reference:
Posted on April 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024