Taking full control of editing - Renderers as Editors in ag-Grid

  |   Tutorial

ag-Grid provides a great editing experience out of the box. However, you may run into some limitations when performing validation on editing cells. This is because ag-Grid editors will always exit edit-mode if another cell is clicked.

In case where you need extra flexibility in handling cell content, you can create a cell renderer and take control of the full editing lifecycle. We'll demonstrate this approach in a ToDo List application written in TypeScript and React. The app is documented using TypeDoc and is available on GitHub and StackBlitz.

💡 If you would like to generate documentation for this application you can clone this Github repository and then run the script named 'doc' (see the package.json file).

We'll be going through:

Using Cell Renderers as Editors


ag-Grid provides a great editing experience out of the box. However, you may run into some limitations when performing validation on editing cells. This is because ag-Grid editors will always exit edit-mode if another cell is clicked.

In case where you need extra flexibility in handling cell content, you can create a cell renderer and take full control of the editing lifecycle.

Entering Edit Mode


-

Entering edit mode when using renderers as editors requires a flag that lets the renderers know whether they are in edit mode or read mode. You'll also have to update that flag accordingly.

React context

Our demo ag-Grid subscribes to a React Context object that holds the following variables:

  • mockEditingId - the id of the row node currently in edit mode.
  • setMockEditingId - a function that takes an id and updates mockEditingId.
// src/context/MockEditingContext.tsx
export interface IMockEditingContext {
    /** ID of the node in {@link Grid} in mock-edit mode */
    mockEditingId: string,
    /** function to update the mockEditingId */
    setMockEditingId: (id: string) => void
}
📔 The demo application refers to edit mode as mock-edit mode. This is simply to differentiate between the out of the box ag-Grid editing and the renderers being used as editors

Hooking renderers to the React context

When the pen icon on a row node is clicked, mockEditingId is set to the id of that row node (provided that there are no other nodes currently editing).

// src/components/ActionsRenderer/ActionsRenderer.tsx
private enterMockEditMode: React.MouseEventHandler<HTMLSpanElement> = (): void => {
	if (this.context.mockEditingId !== null) {
		alert('You can only edit one todo at a time');
		return;
	}
	const nodeId: string = this.props.node.id;
	this.context.setMockEditingId(nodeId);
}

The Grid component then invokes a refresh on the row node so that each renderer can be updated to reflect the updated editing state:

// src/components/Grid/Grid.tsx
public componentDidUpdate(prevProps: GridProps): void {
	if (prevProps.mockEditingId !== this.props.mockEditingId) {
		const idToUpdate: string = this.props.mockEditingId === null 
			? prevProps.mockEditingId : this.props.mockEditingId;
	const nodeToUpdate: RowNode = this.gridApi.getRowNode(idToUpdate);
	const refreshCellsParams: RefreshCellsParams = { rowNodes: [nodeToUpdate], force: true };
	this.gridApi.refreshCells(refreshCellsParams);
	}
}

Each cell renderer reads the updated context and renders an input element if the row node is in edit mode or a span element displaying the cell value if the row node is in read mode:

// src/components/DescriptionRenderer/DescriptionRenderer.tsx
public render(): React.ReactElement {
	let component: React.ReactElement;
	const isSelected: boolean = this.props.node.isSelected();

	if (this.props.isMockEditing) {
		const inputStyles: React.CSSProperties = 
	{ background: isSelected ? '#D5F1D1' : 'whitesmoke' };
		component =
			<input
				ref={this.inputRef}
				value={this.state.value}
				onChange={this.inputChangedHandler}
				style={inputStyles} />
	} else {
		component = <span className={isSelected ? "strike" : ''}> {this.state.value}</span>
	}

	return (
		<div className="todo-wrapper">
			{component}
		</div>
	);
}

Now let's take a look at how you can exit edit mode.

Exiting Edit Mode


-

After you've finished emending a ToDo item, you'll either want to commit the changes to the dataset or roll back all changes and revert each editor's new value back to its original value.

In either case you'll need to be able to iterate over the editors.

Getting the editors

Each editor implements the same interface:

// src/interfaces/mockCellEditor.ts
export interface IMockCellEditor extends ICellRenderer {
    /** returns a tuple with the colId and the updated value in the cell */
    getValue: () => [any, any],
    /** resets the cell value to that before editing */
    reset: () => void,
}

Knowing this, we can create a function that identifies whether a component implements the IMockCellEditor interface as follows:

// src/interfaces/mockCellEditor.ts
export const instanceOfIMockCellEditor = (component: any) => {
    return Object.getPrototypeOf(component).hasOwnProperty('getValue')
        && typeof component.getValue === 'function'
        && Object.getPrototypeOf(component).hasOwnProperty('reset')
        && typeof component.reset === 'function';
}

We can leverage the ag-Grid API  to retrieve all cell renderer instances on the editing row node and then filter out the ones which aren't implementing the IMockCellEditor interface as shown below:

// src/components/Grid/Grid.tsx
private getMockEditors = (node: RowNode): IMockCellEditor[] => {
	const mockEditors: IMockCellEditor[] = 
    	this.gridApi.getCellRendererInstances({ rowNodes: [node] })
		.map(cellRenderer => 
        	(cellRenderer as unknown as ReactComponent).getFrameworkComponentInstance())
		.filter(cellRenderer => instanceOfIMockCellEditor(cellRenderer));
	return mockEditors;
}

Committing changes

To commit changes, we're iterating over editors and generating an updated ToDo. The row node is then updated via a transaction update as shown below:

// src/components/Grid/Grid.tsx
private commitChanges = (): void => {
	const mockEditingNode: RowNode = this.gridApi.getRowNode(this.context.mockEditingId);
	const updatedToDo: ToDo = { ...mockEditingNode.data };

	const mockEditors: IMockCellEditor[] = this.getMockEditors(mockEditingNode);
	mockEditors.forEach(mockEditor => {
		const [field, updatedValue]: ['description' | 'deadline', any] = mockEditor.getValue();
		updatedToDo[field] = updatedValue;
	});
	this.gridApi.applyTransaction({ update: [updatedToDo] });
}

All we have to do after that is set mockEditingId to null, which will cause a refresh on the row node, allowing it to exit edit mode!

// src/components/ActionsRenderer/ActionsRenderer.tsx
private saveChanges: React.MouseEventHandler<HTMLSpanElement> = (): void => {
	this.props.commit(); 
	setTimeout(() => this.context.setMockEditingId(null), 0);
}

Rolling back changes

To roll back all changes, reset is invoked on each mock-editor, reverting the displayed values in the editors back to their pre-edited values using the code below:

// src/components/Grid/Grid.tsx
private rollbackChanges = (): void => {
	const mockEditingNode: RowNode = this.gridApi.getRowNode(this.context.mockEditingId);
	const mockEditors: IMockCellEditor[] = this.getMockEditors(mockEditingNode);
	mockEditors.forEach(mockEditor => {
		mockEditor.reset();
	});
}

Again, mockEditingId is set to null after rolling back the changes, causing the row node to refresh and exit mock-edit mode.

// src/components/ActionsRenderer/ActionsRenderer.tsx
private undoChanges: React.MouseEventHandler<HTMLSpanElement> = (): void => {
	this.props.rollback();
	setTimeout(() => this.context.setMockEditingId(null), 0);
}

Lastly, we'll show you how to style a row node according to its selection status.

Styling rows based on their selection status


-

Binding selection status to a checkbox

The CheckboxRenderer component renders a span element that resembles a HTML checkbox element. The renderer's state is bound to the row node's selection status as shown below:

// src/components/CheckboxRenderer/CheckboxRenderer.tsx
/** bind the completed state variable to the nodes selection status */
public componentDidMount(): void {
	const isNodeSelected: boolean = this.props.node.isSelected();
	this.setState({ completed: isNodeSelected });
}

/** update the nodes selection status upon checking and refresh the node */
private completeToDo: React.MouseEventHandler<HTMLSpanElement> = (): void => {
	this.props.node.setSelected(true);
	const refreshCellsParams: RefreshCellsParams = { 
		rowNodes: [this.props.node], 
		force: true 
	};
	this.props.api.refreshCells(refreshCellsParams);
}

/** update the nodes selection status upon un-checking and refresh the node */
private uncompleteToDo: React.MouseEventHandler<HTMLSpanElement> = (): void => {
	this.props.node.setSelected(false);
	const refreshCellsParams: RefreshCellsParams = { 
		rowNodes: [this.props.node], 
		force: true 
	};
	this.props.api.refreshCells(refreshCellsParams);
}

/** update the completed state variable after each refresh of the node */
public refresh(): boolean {
	const isNodeSelected: boolean = this.props.node.isSelected();
	this.setState({ completed: isNodeSelected })
	return true;
}

Using selection status to style editors

The editors in the application also use the row node's selected status for styling purposes - placing an animated strikethrough across the ToDo item property fields when the row node is selected to visually indicate that the ToDo item has been completed. This is show in the code below:

  // src/components/DescriptionRenderer/DescriptionRenderer.tsx
public render(): React.ReactElement {
        let component: React.ReactElement;
        const isSelected: boolean = this.props.node.isSelected();

        if (this.props.isMockEditing) {
            const inputStyles: React.CSSProperties =
				{ background: isSelected ? '#D5F1D1' : 'whitesmoke' };
            component =
                <input
                    ref={this.inputRef}
                    value={this.state.value}
                    onChange={this.inputChangedHandler}
                    style={inputStyles} />
        } else {
            component = <span className={isSelected ? "strike" : ''}> {this.state.value}</span>
        }

        return (
            <div className="todo-wrapper">
                {component}
            </div>
        );
    }

Customising the ag-Grid theme

You'll notice that the selected row nodes have a light green background colour instead of the default light blue used in the ag-Grid alpine theme. In this case, we're customizing the theme configuration of the ag-Grid SASS varaiables as shown below:

/* src/components/Grid/Grid.scss */
.ag-theme-alpine {
	@include ag-theme-alpine((
		odd-row-background-color: null,
		font-size: 16px,
		font-family: ("Roboto", sans-serif),
		row-hover-color: null,
		range-selection-border-color: rgba(0,0,0,0),
		cell-horizontal-padding: 10,
		selected-row-background-color: lightgreen,
	));

What's next?


We hope that you find this article useful if you ever need to use cell renderers as editors or style row nodes according to their selection status. Remember you can download the project from GitHub and experiment with it further.

If you would like to try out ag-Grid check out our getting started guides (JS / React / Angular / Vue)

Happy coding!

Read more posts about...