Building a custom table input component for Sanity

Peter Rolfsen
7 min readNov 12, 2023

--

I found that there was no good table component for Sanity Studio. Therefore I built my own. I want to share the process so that you can either copy mine, or use it as inspiration and create a better one.

After Sanity 3. It became much easier to create and use custom components in Sanity Studio. I however found that this change broke the table plugins that I found online. This was early 2023.
Though these might be updated at the time of writing this. I still want to share the process of creating a custom one for Sanity Studio.

This component is fully valid HTML.

Sanity Studio

In your /studio folder, create a folder named plugins if it isn't already there and create another folder inside plugins called table.

studio/plugins/table

file structure

In plugins I have the finished Table component and the CSS for it as well as the schema that creates the json for Studio structure.

Files and components

Schema.ts

  • contains the table and row markup for sanity.
// plugins/schema.ts

import { defineType } from 'sanity';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ThListIcon } from '@sanity/icons';
import { Table } from './table';

const Row = {
name: 'row',
title: 'Row',
type: 'object',
fields: [
{
name: 'cells',
type: 'array',
of: [
{
type: 'string',
},
],
},
],
};

export const table = defineType({
name: 'table',
title: 'Table',
type: 'object',
icon: ThListIcon as any,
components: {
input: Table,
},
fields: [
{
name: 'rows',
title: 'Rows',
type: 'array',
of: [Row],
},
],
});

export default table;

Sanity.json

{
"parts": [
{
"path": "./schema.ts"
}
]
}

TableButton.tsx

Tablebutton
  • Basic button component with Icon
// plugins/Compontents/TableButton.tsx

import React from 'react';
import { Tooltip, Button } from '@sanity/ui';

export interface TableButtonProps {
description: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
icon: JSX.Element;
tone?: any;
}

export const TableButton = ({ description, onClick, icon, tone }: TableButtonProps) => {
return (
<Tooltip content={description} placement="top-start" portal>
<Button mode="ghost" padding={4} onClick={onClick} aria-label={description} tone={tone}>
{icon}
</Button>
</Tooltip>
);
};

export default TableButton;

ButtonDash.tsx

ButtonDash
  • A dashboard for the buttons that takes in an array of button-props to map the buttons and create the dashboard.

// plugins/Compontents/ButtonDash

import React from 'react';
import { Inline } from '@sanity/ui';
import { TableButton, TableButtonProps } from './TableButton';
import styles from '../table.css';

interface Props {
Buttons: TableButtonProps[];
}

export const ButtonsDash = ({ Buttons }: Props) => {
return (
<div className={styles.tableWrapper}>
<Inline space={[3, 3, 3]}>
{Buttons.map((button) => (
<TableButton
key={button.description}
description={button.description}
onClick={button.onClick}
icon={button.icon}
tone={button.tone}
/>
))}
</Inline>
</div>
);
};

export default ButtonsDash;

ButtonDash is then used in the Table.tsx component

It is not commented on much. The reason is that I think the function and variable names are quite self explanatory.

What is worth mentioning is that I use react-beautiful-dnd to be able to shift the position of the rows.


// plugins/table.tsx

import { set } from 'sanity';
// eslint-disable-next-line import/no-extraneous-dependencies
import { Button, TextInput, Tooltip } from '@sanity/ui';
import React, { JSXElementConstructor, ReactElement, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import {
AiOutlineHolder,
AiOutlineInsertRowRight,
AiOutlineInsertRowLeft,
AiOutlineInsertRowBelow,
AiOutlineInsertRowAbove,
} from 'react-icons/ai';
import { BsTrash } from 'react-icons/bs';
import { RiDeleteBin6Line } from 'react-icons/ri';
import { RowType, OnChangeType } from './types';
import { ButtonsDash } from './Components/ButtonsDash';

interface TableProps {
onChange: OnChangeType;
value?: {
title?: string;
rows?: RowType[];
};
}

export const generateKey = (prefix: string, index: number) => {
return `${prefix}${index}`;
};

function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

export const Table = (props: TableProps) => {
const { onChange, value: savedValue } = props;
const initialRows = [{ cells: [''] }];
const [rows, setRows] = useState(savedValue?.rows ? deepClone(savedValue.rows) : initialRows);

const updateSchema = (newValue: RowType[]) => {
onChange([set(newValue, ['rows'])]);
};

const onDragEnd = (result: any, columns: any, setColumns: any) => {
const items: RowType[] = Array.from(columns);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setColumns(items);
updateSchema(items);
};

const addRowBelow = () => {
const newRow = { cells: new Array(rows[0].cells.length).fill('') };
const newRows = rows.concat(newRow);
setRows(newRows);
updateSchema(newRows);
};

const addRowAbove = () => {
const newRow = { cells: new Array(rows[0].cells.length).fill('') };
const newRows = rows.slice(0, 0).concat(newRow).concat(rows.slice(0));
setRows(newRows);
updateSchema(newRows);
};

const addColumnRight = () => {
const stateCopy = deepClone(rows);
const newRows = stateCopy.map((row: RowType) => ({
cells: row.cells.concat(['']),
}));
setRows(newRows);
updateSchema(newRows);
};

const addColumnLeft = () => {
const stateCopy = deepClone(rows);
const newRows = stateCopy.map((row: RowType) => ({
cells: [''].concat(row.cells),
}));
setRows(newRows);
updateSchema(newRows);
};

const clearTable = () => {
if (window.confirm('Er du sikker på at du vil slette hele tabellen?')) {
setRows(initialRows);
updateSchema(initialRows);
} else {
return null;
}
return null;
};

const onFocusLost = (value: string, r: number, c: number) => {
onChange(set(value, [`rows[${r}].cells[${c}]`]));
};

const removeRow = (index: number) => {
const newRows = [...rows];
newRows.splice(index, 1);
setRows(newRows);
updateSchema(newRows);
};

const removeColumn = (index: number) => {
const stateCopy = deepClone(rows);
const newRows = stateCopy.map((row: RowType) => {
const updatedRow = { cells: [...row.cells] };
updatedRow.cells.splice(index, 1);
return updatedRow;
});
setRows(newRows);
updateSchema(newRows);
};

const [cellValue, setCellValue] = React.useState('');
const [currentCellIndex, setCellIndex] = React.useState(0);
const [currentRowIndex, setRowIndex] = React.useState(0);
const UpdateCellValue = (cv: string, ci: number, ri: number) => {
setCellValue(cv);
setCellIndex(ci);
setRowIndex(ri);
};

const updateValue = (value: string, i: number, j: number) => {
const stateCopy = deepClone(rows);
stateCopy[i].cells[j] = value;
setCellValue(value);
setRows(stateCopy);
};

const droppable = (
<Droppable droppableId="droppable">
{(provided) => {
return (
<table className="table">
<tbody className="tableData" ref={provided.innerRef} {...provided.droppableProps}>
{rows.map((row, rowIndex) => (
<Draggable
key={generateKey('index', rowIndex)}
draggableId={generateKey('index', rowIndex)}
index={rowIndex}
>
{/* eslint-disable-next-line @typescript-eslint/no-shadow */}
{(provided) => {
return (
<tr
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="joinedRow"
>
<td className="dragIcon">
<AiOutlineHolder />
</td>
<td className="row">
{row.cells.map((cell, cellIndex) => (
<React.Fragment key={generateKey('cell', cellIndex)}>
<div className="cell">
<TextInput
fontSize={[2, 2, 3, 4]}
onChange={(event) =>
updateValue(event.currentTarget.value, rowIndex, cellIndex)
}
onBlur={(event) =>
onFocusLost(event.currentTarget.value, rowIndex, cellIndex)
}
onFocus={(event) =>
UpdateCellValue(event.currentTarget.value, cellIndex, rowIndex)
}
placeholder=""
value={cell}
hidden={cellIndex === rows[0].cells.length - 1}
border={false}
/>
</div>
<div
hidden={cellIndex !== rows[0].cells.length - 1 || rows.length === 1}
>
<Tooltip content="Add row beneath" placement="top-start" portal>
<Button
mode="bleed"
onClick={() => removeRow(rowIndex)}
aria-label="delete row"
>
<BsTrash fill="red" />
</Button>
</Tooltip>
</div>
</React.Fragment>
))}
</td>
</tr>
);
}}
</Draggable>
))}
{provided.placeholder}
<tr className="deleteRow">
{rows[0].cells.map((_, index: number) => (
<td key={generateKey('index', index)} className="deleteColumn">
<Tooltip content="delete column" placement="top-start" portal>
<Button
mode="bleed"
onClick={() => removeColumn(index)}
aria-label="delete column"
>
<BsTrash fill="red" />
</Button>
</Tooltip>
</td>
))}
</tr>
</tbody>
</table>
);
}}
</Droppable>
);

return (
<div className="wrapper">
<ButtonsDash
Buttons={[
{
description: 'Add row on top',
icon: <AiOutlineInsertRowAbove />,
onClick: addRowAbove,
},
{
description: 'Add row to bottom',
icon: <AiOutlineInsertRowBelow />,
onClick: addRowBelow,
},
{
description: 'Add row to the left',
icon: <AiOutlineInsertRowLeft />,
onClick: addColumnLeft,
},
{
description: 'Add row to the right',
icon: <AiOutlineInsertRowRight />,
onClick: addColumnRight,
},
{
description: 'Delete table',
icon: <RiDeleteBin6Line />,
onClick: clearTable,
tone: 'critical',
},
]}
/>
<TextInput
value={cellValue}
onChange={(event) =>
updateValue(event.currentTarget.value, currentRowIndex, currentCellIndex)
}
placeholder=""
className="cellInput"
/>
<DragDropContext onDragEnd={(result) => onDragEnd(result, rows, setRows)}>
{droppable}
</DragDropContext>
</div>
);
};

export default Table;

// plugins/types.d.ts


export type RowType = {
cells: string[];
};

export type UpdateValueType = (value: string, r: number, c: number) => void;
export type RemoveRowType = (r: number) => void;
export type OnFocusLostType = (value: string, r: number, c: number) => void;
export type RemoveColumType = (c: number) => void;
export type OnChangeType = (a: any) => void;

Css for Table.tsx

// plugins/table.css

.wrapper {
display: flex;
flex-direction: column;
gap: 1em;
}

.tableWrapper {
text-align: center;
display: flex;
justify-content: center;
}

.table {
border-collapse: collapse;
margin-top: 2em;
justify-content: flex-start;
display: flex;
}

.table td {
display: flex;
}
.table tr {
display: flex;
width: 100%;
}
.table tbody {
display: flex;
}
.row {
width: 100%;
}
.row span {
cursor: pointer;
}
.row:first-of-type > .cell {
border-color: lightgray;
padding: 0;
}

.cell {
border: 1px solid gray;
width: 100px;
}

.cell span {
margin-right: 0;
height: 100%;
display: flex;
}

.cell input,
.cell textarea {
padding: 5px;
font-size: 1em !important;
}

.removeColumn {
text-align: center;
}
.joinedRow {
display: flex;
flex-direction: row;
align-items: center;
}
.dragIcon {
padding: 0px 15px;
}

.deleteRow {
width: 514px;
margin-left: 90px;
}

.deleteColumn {
text-align: center;
display: flex;
flex-direction: column;
width: 100px;
}
.deleteColumn button {
text-align: center;
}

.tableData {
display: flex;
flex-direction: column;
align-items: center;
}

Feel free to use this code, edit it and make it better.

Import this component into you blockContentComplex

Now inside my studio/schemas/objects folder I create a tableObject, so that I can use it as a valid Sanity Studio component.

// schemas/objects/tableObject.

import { FiColumns as icon } from 'react-icons/fi';

interface PreviewProps {
title: string;
}

export default {
name: 'tableObject',
title: 'Table',
type: 'object',
icon,
fields: [
{
name: 'title',
title: 'Table title',
type: 'string',
},
{
name: 'table',
title: 'Table',
type: 'table',
},
],
preview: {
select: {
title: 'title',
},
prepare({ title }: PreviewProps) {
return {
title,
subtitle: 'Table',
};
},
},
};

I den export it in schemas/schema.ts and place it as a field in blockContentComplex. It should now show up when you click the + in a blockContent field.

--

--