Skip to content

Node list: React table with Drupal ajax inside ​

components/node-list, page /node-list. Shows how React and Drupal's own ajax/dialog system live together.

Parts ​

  • View content_rest (config/optional/views.view.content_rest.yml): Better REST export at /api/node with an exposed Content type filter and a Created sort, plus a Page display at /node-list with a template that mounts the SDC.
  • Component: rsuite Table, SelectPicker and Form. Columns are generated from the keys of rows[0]. A column is sortable if its key is in exposed_sorts[].field_identifier.
  • HTML cells: the view's operations (dropbutton) and nothing (custom text with a use-ajax link) fields are HTML strings. The cell renders them with rawHtml(row[col]), which also attaches Drupal behaviors, see Ajax.
  • Look: node-list.scss themes rsuite with Umami's colors and fonts through rsuite's CSS variables.
jsx
<Cell rowKey="nid" dataKey={col}>{(row) => rawHtml(row[col])}</Cell>
yaml
# node-list.component.yml, libraries needed by the HTML inside the cells
libraryOverrides:
  dependencies:
    - react_scaffold/react
    - react_scaffold/react-api-client
    - core/drupal.ajax
    - core/drupal.dropbutton

Full source ​

yaml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Node List
status: stable
description: 'A React-based component that displays a list of nodes with theme variants.'
group: Content
props:
  type: object
  properties:
    endpoint:
      type: string
      title: Endpoint
      description: 'API endpoint for fetching node data'
      default: '/api/node'
      examples:
        - '/api/node'
    variant:
      type: string
      title: Theme Variant
      description: 'Visual theme for the component'
      default: 'light'
      enum:
        - 'light'
        - 'dark'
      meta:enum:
        light: Light
        dark: Dark
      x-translation-context: Theme variants
      examples:
        - 'light'
        - 'dark'
  required:
    - endpoint
libraryOverrides:
  js:
    ../../assets/node-list.js: { attributes: { type: module } }
  css:
    theme:
      ../../assets/node-list.css: { }
  dependencies:
    - react_scaffold/react
    - react_scaffold/react-api-client
    - core/drupal.ajax
    - core/drupal.dropbutton
twig
<div {{ attributes.addClass('node-list').addClass('node-list__' ~ variant).setAttribute('data-endpoint', endpoint).setAttribute('data-theme', variant) }}></div>
jsx
import {NodeList} from './NodeList';
import './node-list.scss'
import { createRoot } from 'react-dom/client';

(function (Drupal, once) {
  const attachNodeList = (element) => {
    createRoot(element).render(
      <NodeList endpoint={element.dataset.endpoint} theme={element.dataset.theme}/>
    );
  };

  Drupal.behaviors.nodeList = {
    attach(context, settings) {
      once('react', '.node-list', context)
        .forEach((element) => executeWhenVisible(element, attachNodeList, "node-list"))
    },
  };
})(Drupal, once);
jsx
import {useState, useEffect} from "react";
import {Table, Column, HeaderCell, Cell} from 'rsuite-table';
import {Form, SelectPicker} from 'rsuite';

export const NodeList = ({endpoint, theme}) => {
  const [response, setResponse] = useState([]);
  const [sort, setSort] = useState(null)
  const [sortType, setSortType] = useState('desc')
  const [filters, setFilters] = useState({})
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);
  const fetch = () => {
    let params = filters
    if (sort) {
      params.sort_by = sort
    }
    if (sortType) {
      params.sort_order = sortType.toUpperCase()
    }
    apiClient(endpoint, params)
      .then((response) => {
        setResponse(response)
        setLoading(false)
      })
      .catch(setError)
  }
  useEffect(fetch, [sort, sortType, filters]);
  const sortColumn = (col, order) => {
    setSortType(order)
    setSort(col)
  }
  const applyFilters = (key) => {
    return (value) => {
      setFilters({...filters, [key]: value})
    }
  }
  const clearFilter = (key) => {
    return () => {
      setFilters({...filters, [key]: null})
    }
  }
  const isSortable = (col) => {
    return response?.exposed_sorts?.filter(sort => sort?.field_identifier === col).length > 0
  }
  const ucfirst = (word) => word.charAt(0).toUpperCase() + word.slice(1)
  const getOptions = (options) => {
    const entries = Object.entries(options)
    return entries.map((data) => {
      return {value: data[0], label: data[1]}
    })
  }
  if (loading) {
    return <div>{Drupal.t('Loading...')}</div>;
  }
  if (error) {
    return <div>{Drupal.t('Error loading nodes')}</div>;
  }
  return (
    <>
      {response?.exposed_filters?.length && (
        <Form>
          {response.exposed_filters.map((filter) =>
            <Form.Group key={filter.identifier}>
              <Form.ControlLabel>{filter.label}</Form.ControlLabel>
              {!filter?.options && (
                <Form.Control
                  onChange={applyFilters(filter.identifier)}
                  onReset={applyFilters(filter.identifier)}
                  name={filter.identifier}/>
              )}
              {filter?.options && (
                <SelectPicker
                  onClean={clearFilter(filter.identifier)}
                  onSelect={applyFilters(filter.identifier)}
                  name={filter.identifier}
                  data={getOptions(filter.options)}/>
              )}
              {filter?.description && (
                <Form.HelpText>{filter?.description}</Form.HelpText>
              )}
            </Form.Group>
          )}
        </Form>
      )}
      <Table data={response.rows}
             className={'rs-theme-' + theme}
             onSortColumn={sortColumn}
             rowHeight={60}
             sortColumn={sort ? sort : response?.exposed_sorts[0]?.field_identifier}
             sortType={sortType}
      >
        {response?.rows?.length && Object.keys(response.rows[0]).map((col) => {
          return <Column
            key={col}
            flexGrow={Math.floor(10 / Object.keys(response.rows[0]).length)}
            minWidth={100}
            sortable={isSortable(col)}
          >
            <HeaderCell>{ucfirst(col)}</HeaderCell>
            <Cell rowKey={'nid'} dataKey={col}>{(row) => rawHtml(row[col])}</Cell>
          </Column>
        })}
      </Table>
    </>
  );
}
scss
@import 'rsuite-table/dist/css/rsuite-table.css';
@import 'rsuite/dist/rsuite-no-reset.min.css';

// Make rsuite blend into the Umami demo theme (palette and fonts of demo_umami).
$green: #008068;
$green-dark: #025c4b;
$green-light: #e6eee0;
$pink: #fcece7;
$grey: #767775;

// Pickers render their menu in a portal on <body>, so these are global.
:root,
.node-list {
  --rs-primary-500: #{$green};
  --rs-text-link: #{$green};
  --rs-text-link-hover: #{$green-dark};
  --rs-text-link-active: #{$green-dark};
  --rs-border-primary: #b8c4b0;
  --rs-input-focus-border: #{$green};
  --rs-listbox-option-hover-bg: #{$green-light};
  --rs-listbox-option-hover-text: #{$green-dark};
  --rs-bg-active: #{$green};
  --rs-picker-value: #{$green-dark};
  --rs-btn-default-hover-bg: #{$green};
  --rs-btn-default-hover-text: #fff;
}

.rs-picker-popup {
  font-family: "Source Sans Pro", Verdana, sans-serif;
  z-index: 1100;
}

.node-list {
  font-family: "Source Sans Pro", Verdana, sans-serif;

  // Exposed filters bar, like the recipe explorer one.
  .rs-form {
    margin-bottom: 1.25rem;
    padding: 1.25rem;
    background: $green-light;
    border-radius: .5rem;
  }

  .rs-form-group {
    margin-bottom: 0;
  }

  .rs-form-control-label {
    display: block;
    margin-bottom: .35rem;
    font-size: .85rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .04em;
  }

  .rs-form-help-text {
    color: $grey;
  }

  .rs-picker-default .rs-picker-toggle.rs-btn {
    border-color: #b8c4b0;
    border-radius: .25rem;
    background: #fff;
  }

  // Table.
  .rs-table {
    border-radius: .5rem;
    background: #fff;
    box-shadow: 0 1px 4px rgba(0, 0, 0, .15);
    font-size: 1rem;
    color: #333;
  }

  .rs-table-row-header .rs-table-cell {
    background: $green;
    color: #fff;
  }

  .rs-table-cell-header .rs-table-cell-content {
    font-size: .85rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .04em;
  }

  .rs-table-cell-header .rs-table-cell-content,
  .rs-table-cell-header .rs-table-cell-header-icon-sort {
    color: #fff;
  }

  .rs-table-row {
    border-bottom-color: $green-light;
  }

  .rs-table-row:not(.rs-table-row-header):hover .rs-table-cell {
    background: $pink;
  }

  .rs-table-cell {
    background: #fff;
  }

  .rs-table-cell-content a {
    color: $green;
    font-weight: 600;
  }

  .rs-table-cell-content a:hover {
    color: $green-dark;
  }

  // Drupal dropbutton ("View") inside cells.
  .dropbutton-wrapper .dropbutton-widget {
    border-color: $green;
    border-radius: .25rem;
  }

  .dropbutton-wrapper .dropbutton-action a {
    color: $green;
  }
}

Look in DevTools ​

Filter the Network tab by Fetch/XHR:

  • node?sort_order=...&type=recipe are apiClient calls from React (initiator apiClient.js),
  • edit?_wrapper_format=drupal_modal is Drupal's ajax opening the node form (initiator jquery.min.js, because core's ajax uses jQuery).

Node list with DevTools

"Edit in Modal" needs permission to edit the node, log in to see it working.