Developer. Designer

GSoC 2021 with Navidrome Part 2 - Rendering the Data

In my last post, I gave a bird eye view of the problems I’m solving at navidrome. This post dives into some of the technicalities of the same.

React Admin provides a really extensible and modular interface by design. You can also customize the store and add your own side effects using sagas. A typical app looks like the following:

<Admin dataProvider={jsonServerProvider(url)}>
	<Resource name="todos" list={
		<List>
			<Datagrid>
				<TextField source="id" />
				<TextField source="title" />
				<DateField source="count" />
			</Datagrid>
		</List>
	}/>
</Admin>
  • Admin renders a basic app and sets up data contexts for the inner component
  • Resource defines the API resource
  • List is responsible for data fetching, pagination, selection and all manipulation
  • Datagrid is responsible for rendering the elements passed on by the List
  • The Fields inside the Datagrid specify what all columns to render

At a first glance, it would appear that having a custom list would suffice to implement infinite scroll. This would, however also mean that we would have to handle all the data fetching, selection, editing etc all by ourselves. Impelmenting virtualization/windowing with the Datagrid implementation is also not possible unless you edit react-admin itself, which is something we want to avoid.

Hence, the approach taken is to use our own Datagrid component, which implements all the properties of react-admin’s Datagrid so that it can act as a drop-in replacement.

Infinite Scroll and Virtualization

The Datagrid uses Material UI’s Table components under the hood. Hence, we used react-virtualized Table component to implement the Datagrid. To implement the InfiniteScroll, react-virtualized offers a handy InfiniteScroll component which we can wrap around our Table as follows.

<InfiniteScroll
  isRowLoaded={isRowLoaded}
  loadMoreRows={laodMoreRows}
  rowCount={rowCount}
>
  { (onRowsRendered, registerChild) => (
    <Table 
      rowHeight={rowHeight} 
      headerHeight={rowHeight}
      rowGetter={({ index }) => data[ids[index]]} 
      rowCount={rowCount}
      width={width} height={height}
    > 
      {/* columns*/}
    </Table>
  )}
</InfiniteScroll>

Note that in order to calculate the items to be rendered in a window of the given dimensions, it is ideal that the heights of all row remain the same. React-virtualized although does allow passing a function for dynamic heights , but it is much more expensive.

Rendering the fieldss

In order to render the Fields from react-admin, simply using props.children will not work, since the elements can be of any type. We use the React.Children API, which cleans up this data for us to render it.

React.Children.map(children, (c, i) =>
  isValidElement(c) && c.props ? (
    <Column
      key={i}
      label={c.props.source}
      dataKey={c.props.source}
      width={c.props.width || 100}
      flexGrow={c.props.flexGrow || defaultflexGrow}
      cellRenderer={(cellRenderProps) =>
      	cellRenderer({ ...cellRenderProps, columnIndex: i })
      }
      headerRenderer={(headerProps) =>
      	headerRenderer({ ...headerProps, columnIndex: i })
      }
    />
  ) : null
)

Rendering columns

For rendering the cells, we can use react admin’s DatagridCell component. This handles stuff like internationalization & styling for us out of the box.

const cellRenderer = ({ rowData, cellData, columnIndex, dataKey }) => {
  const { basePath, resource } = useListContext()
  const field = children[columnIndex]

  return (
    <DatagridCell
      component="div"
      className={classes.tableCell}
      style=
      field={field}
      record={rowData}
      basePath={basePath}
      resource={resource}
    />
  )
}

Note that the react-admin table component does not use the HTML table tag, but a div. The DatagridCell’s uses the <td> tag by default. We override this by using component prop.

Handling sort on click

You might have noticed earlier that we have a separate function for rendering the header. This is mainly because it looks different and behaves a bit differently. Clicking on a header, for instance toggles the sorting of the particular column.

Here again, we have the <DatagridHeaderCell/> to the rescue:

const headerRenderer = ({ columnIndex }) => {
  const { resource, currentSort } = useListContext()
  const field = children[columnIndex]

  return (
    <DatagridHeaderCell
      component="div"
      className={clsx(classes.tableCell, datagridClasses.headerCell)}
      field={field}
      currentSort={currentSort}
      isSorting={
        currentSort.field === (field.props.sortBy || field.props.source)
      }
      key={field.props.source || columnIndex}
      resource={resource}
      updateSort={updateSortCallback}
      style=
    />
  )
}

To tell the List that we need an updated ordering, we use the setSort function from the List’s context as follows:

const { currentSort, setSort } = useListContext()

const updateSortCallback = useCallback(
  (event) => {
    event.stopPropagation()
    const newField = event.currentTarget.dataset.field
    const newOrder =
      currentSort.field === newField
        ? currentSort.order === 'ASC'
          ? 'DESC'
          : 'ASC'
        : event.currentTarget.dataset.order

    setSort(newField, newOrder)
  },
  [currentSort.field, currentSort.order, setSort]
)

You might have noticed I’ve deliberately left a crucial detail, i.e. the data fetching logic for infinite scroll. Do not worry, is the up for the next part. Stay tuned :)