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/nodewith an exposed Content type filter and a Created sort, plus a Page display at/node-listwith a template that mounts the SDC. - Component: rsuite
Table,SelectPickerandForm. Columns are generated from the keys ofrows[0]. A column is sortable if its key is inexposed_sorts[].field_identifier. - HTML cells: the view's
operations(dropbutton) andnothing(custom text with ause-ajaxlink) fields are HTML strings. The cell renders them withrawHtml(row[col]), which also attaches Drupal behaviors, see Ajax. - Look:
node-list.scssthemes 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.dropbuttonFull 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.dropbuttontwig
<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=recipeareapiClientcalls from React (initiatorapiClient.js),edit?_wrapper_format=drupal_modalis Drupal's ajax opening the node form (initiatorjquery.min.js, because core's ajax uses jQuery).

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