A Datatable Saga

This is a story about how we built 73 datatables with react-table and next.js for a dashboard. The problem was well defined and we had a set of well understood constraints, so the goal was not to create a solution that works for everything but one that works well for our context.

The problem

The Mashonaland West Provincial Education Director need a reporting dashboard for the province. There are 8 districts with hundreds of schools within them and they all submit termly reports to the province. The province in turn then sends a collated report to the Permanent Secretary at national level. These reports contain mostly tabular data covering almost all aspects of running a primary or secondary school in Zimbabwe.

Brainstorming

Right off, we knew that we are going to be creating a tonne of datatables. I had never worked with tables before, most of the times if I need a table layout, I would just use a grid. However in this case, that wasn’t an option.

The report contains a lot of structured data, and is almost all number inputs. It can all be put into 6 categories, namely

  • Registration
  • Enrollment
  • Staffing
  • Services
  • Business Ventures
  • Infrastructure

We went ahead and designed all the tables in Excel to get an idea of what we will be looking at when we start building them in code(yes building the whole platform in Excel wasn’t an option, it was a good prototype though). I quickly realised there was no way we were going to be writing code for every individual form with a datatable. This got me thinking, what if I could write a declarative definition for all the datatables and then have components and utilities that will build the form.

This not only makes it faster for us to build, but makes it easier to react and adapt to changing requirements.

The Datatable form

We knew that every datatable form could be described as having columns, rows, a title and an optional caption(we called it a message)

type DatatableForm = {
  columns: string[];
  rowTitles: string[];
  formTitle?: string;
  tableTitleColumnLabel?: string;
  message?: string;
};

However, as we can see, the columns is not compatible with react-table Column. We need to do some grappling and make it compatible and then figure out how to build a datatable with the provided props. We also know the column title is going to be written in English and not in programmer casing(pascal, snake, bla bla). In addition we also know the column can have either a text label cell or a number input cell.

const makeColumnsForDatatable = (data: {
  title?: string;
  columns: string[];
}): [] => {
  const newColumns = [
    {
      header: data.title,
      accessorKey: "title",
      cell: "label",
      meta: {
        type: "text",
      },
    },
  ];
  return newColumns.concat(
    data.columns.map((column) => ({
      header: column,
      // create a key in snake casing from the column string
      accessorKey: column.toLowerCase().split(" ").join("_") as string,
      cell: "input",
      meta: {
        type: "number",
      },
    }))
  );
};

This makeColumnsForDatatable would be used to columns with type compatible with a custom type that we will then use to create the actual react-table columns. We also need to create the initial data for the table to initialize the rows and give them titles using the rowTitles field.

export const makeDatatableInitialDataFromColumnsAndRowTitles = (
  columns: TColumn[],
  rowTitles: string[]
) => {
  return rowTitles.map((title) => {
    const rowData: { [key: string]: any } = {};
    columns.forEach((column) => {
      rowData[column.accessorKey] = 0;
    });
    rowData.title = title;
    return rowData;
  });
};

These two functions would be use in the DatatableForm like this

function DatatableForm(props: DatatableFormProps) {
  const columns = makeColumnsForDatatable({
    title: props.tableTitleColumnLabel,
    columns: props.columns,
  });
  const [data, setData] = useState(
    makeDatatableInitialDataFromColumnsAndRowTitles(columns, props.rowTitles)
  );

  // ...rest of the code

  return (
    <form>
      {
        // rest of the code
      }
      <Datatable
        withTotalColumn
        data={data}
        columns={columns}
        onUpdate={onUpdate}
        message={props.message}
      />
      {
        // rest of the code
      }
    </form>
  );
}

The datatable and helpers

The table itself was built with shadcn table components and react-table. It has a InputCell and a LabelCell for labels and inputs(lol). It accepts props with a generic in the data field, all described by

type DatatableProps<T> = {
  data: T[];
  columns: TColumn[];
  message?: string;
  withTotalColumn?: boolean;
  withTotalRow?: boolean;
  onUpdate?: (index: number, columnId: string, value: any) => void;
};

Inside the datatable we do the heavylifting of creating the react-table columns by using a utility function. It accepts an optional param withTotal which tells it whether to add a Total column at the end of the table.

const createColumns = <T extends unknown>(
  columns: TColumn[],
  withTotal?: boolean
): any[] => {
  const columnHelper = createColumnHelper<T>();
  const accessorColumns = columns.map((column): any => {
    const { cell, header, meta, accessorKey } = column;
    return columnHelper.accessor(accessorKey, {
      cell: cell === "input" ? EditableTableCell : TableLabelCell,
      header: header,
      meta: meta,
      width: 150,
    });
  });
  if (withTotal) {
    const totalColumn: ColumnDef<T> = {
      id: "total",
      header: () => <span className="flex justify-end w-full">Total</span>,
      accessorFn: (row: T) => {
        const values = getAllValuesOfRowObject(row as any);
        return calculateArraySum(values);
      },
      cell: (props) => (
        <TableLabelCell {...props} className={"justify-end w-full flex"} />
      ),
    };

    return [...accessorColumns, totalColumn];
  }

  return accessorColumns;
};

Then we build the actual Datatable component

const Datatable = <T extends object>(
  props: PropsWithChildren<DatatableProps<T>>
) => {
  const [data, setData] = useState<T[]>(() => [...props.data]);
  const [editedRows, setEditedRows] = useState({});

  const columns = useMemo(
    () => createColumns<T>(props.columns, props.withTotalColumn),
    [props.columns, props.withTotalColumn]
  );

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    meta: {
      editedRows,
      setEditedRows,
      updateData: props.onUpdate,
    },
  });

  return (
    <div>
      {
        // ...rest of the code
      }
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
};

Was it worth it?

Absolutely. The above code allowed us to create our tables in an object that looks something like this

const registrationForms: {
  audits: DatatableForm;
  school_registration: DatatableForm;
} = {
  audits: {
    columns: ["Infant satelite", "Primary", "Secondary"],
    tableTitleColumnLabel: "Status",
    rowTitles: ["Audited", "Not Audited"],
    formTitle: "Audits carried out",
    message: "All input to be provided as numbers",
  },
  school_registration: {
    columns: ["Infant satelite", "Primary", "Secondary"],
    tableTitleColumnLabel: "Status",
    rowTitles: ["Registered", "Not Registered"],
    formTitle: "Number of schools registered",
    message: "All input to be provided as numbers",
  },
};

const venturesForm: {
  overview: DatatableForm;
} = {
  overview: {
    columns: ["Number of ventures", "Value"],
    tableTitleColumnLabel: "",
    rowTitles: ["Primary", "Secondary"],
    formTitle: "Business ventures carried out",
    message: "All input to be provided as numbers",
  },
};

const tabularForms = {
  registration: registrationForms,
  ventures: venturesForm,
};

export default tabularForms;

Which we would then use to create the forms like this

export default function Audits() {
  return (
    <FormWithDatatable
      form={tabularForms.registration.audits}
      nextPage={"/some-page"}
    />
  );
}

At the end of the day, we could delegate the work of creating the forms to a less experienced dev and allow the top talent to handle more mentally demanding tasks.

Note If you are using react-table and having issues with sum coumns, there is an open issue concerning that and it seems to be a bug. Here’s a link to the issue.