Listings & Dynamic Blocks

A listing block fetches content from the server (e.g. latest news) and renders each result as a separate block. expandListingBlocks(layout, options) walks a layout, fetches results for each listing-type block, and returns { items, paging } where items is an array of block objects with @uid and @type.

You tell it which block types need fetching via a fetchItems map — keys are block types, values are fetcher functions. This means you can have different kinds of listings (Plone queries, RSS feeds, etc.) each with their own fetcher:

const { items, paging } = await expandListingBlocks(layout, {
  blocks,
  paging: { start: 0, size: 6 },
  fetchItems: {
    listing: ploneFetchItems({ apiUrl, contextPath }),
    rssFeed: myRSSFetcher,
  },
});
// paging = { totalPages, totalItems, currentPage, prev, next, pages, seen }

Example: Mixing Listings, Blocks and Paging

A grid can have a mix of listing and static blocks sharing a single paging. The staticBlocks helper wraps non-listing blocks so they participate in the shared page window. The listings use Suspense so they load client-side:

import { Suspense, useState } from 'react';
import { staticBlocks, expandListingBlocks, ploneFetchItems } from '@hydra-js/hydra.js';

function Grid({ blocks, blocks_layout, pageNum, apiUrl, contextPath }) {
  const pagingInput = { start: pageNum * 6, size: 6 };
  const fetchItems = { listing: ploneFetchItems({ apiUrl, contextPath }) };
  const [gridPaging, setGridPaging] = useState({});

  // Walk layout in order, chaining `seen` for position tracking
  let seen = 0;
  return (
    <div className="grid">
      {blocks_layout.items.map(id => {
        if (fetchItems[blocks[id]['@type']]) {
          const mySeen = seen;
          return (
            <Suspense key={id} fallback={<div>Loading...</div>}>
              <ListingItems id={id} blocks={blocks} paging={pagingInput}
                seen={mySeen} fetchItems={fetchItems} onPaging={setGridPaging} />
            </Suspense>
          );
        }
        const result = staticBlocks([id], { blocks, paging: pagingInput, seen });
        seen = result.paging.seen;
        return result.items.map(item =>
          <Block key={item['@uid']} block={item} />
        );
      })}
      {gridPaging.totalPages > 1 && <Paging paging={gridPaging} />}
    </div>
  );
}

async function ListingItems({ id, blocks, paging, seen, fetchItems, onPaging }) {
  const result = await expandListingBlocks([id], {
    blocks, paging, seen, fetchItems,
  });
  onPaging(result.paging);
  return result.items.map(item => <Block key={item['@uid']} block={item} />);
}

expandListingBlocks Options

  • blocks — Map of blockId to block data
  • fetchItems — Required. Map of { blockType: async (block, { start, size }) => { items, total } }. Keys declare which block types to expand; values are fetcher functions. Use ploneFetchItems() for Plone backends.
  • paging — Paging input { start, size } (not mutated). Computed values are returned in the response.
  • seen — Number of items already seen by prior calls (default: 0). Chain paging.seen from one call to the next for grids.
  • itemTypeField — Field on the listing block that holds the item type (default: 'itemType')
  • defaultItemType — Fallback type when field is not set (default: 'summary')

ploneFetchItems Helper

ploneFetchItems({ apiUrl, contextPath, extraCriteria }) — creates a fetcher function for Plone backends, suitable as a value in the fetchItems map. Normalizes results by packaging image_field + image_scales into a self-contained image object { @id, image_field, image_scales }.

For non-Plone backends (RSS feeds, external APIs, etc.), write your own fetcher: async (block, { start, size }) => { items, total }.

Field Mapping

fieldMapping on a listing block controls which fields appear on expanded items — only mapped fields are included. Default: { @id → href, title → title, description → description, image → image }. Values can be a string (rename) or { field, type } for conversions:

"fieldMapping": {
  "@id": { "field": "href", "type": "link" },
  "title": "title",
  "image": { "field": "preview_image", "type": "image" },
  "Subject": { "field": "tags", "type": "string" }
}

Types: string (array→join, image→URL), link (→[{@id}]), image (pass through)

Item Type Selection

Use itemType (or variation) on the listing block to control what @type expanded items get. Combined with inheritSchemaFrom, the listing's sidebar shows fields from the selected item type:

listing: {
    schemaEnhancer: ({ schema }) => {
        schema.properties.itemType = {
            title: 'Display as',
            choices: [['teaser', 'Teaser'], ['card', 'Card']],
        };
        return schema;
    },
    inheritSchemaFrom: {
        typeField: 'itemType',
        blocksField: null,
    },
}

Path Transformation (pathToApiPath)

If your frontend embeds state in the URL path (like pagination), you need to tell hydra.js how to transform the frontend path to the API/admin path. Otherwise, the admin will try to navigate to URLs that don't exist in the CMS.

const bridge = initBridge({
    page: { ... },
    // Transform frontend path to API path by stripping paging segments
    // e.g., /test-page/@pg_block-8-grid_1 -> /test-page
    pathToApiPath: (path) => path.replace(/\/@pg_[^/]+_\d+/, ''),
});

The pathToApiPath function is called whenever hydra.js sends a PATH_CHANGE message to the admin, allowing your frontend to strip or transform URL segments that are frontend-specific (like pagination, filters, or other client-side state).

Paging Values

Both expandListingBlocks and staticBlocks return { items, paging }. You pass { start, size } as input (not mutated) and get back computed paging values:

  • currentPage (number) — Zero-based current page index
  • totalPages (number) — Total number of pages
  • totalItems (number) — Total item count across all blocks
  • prev (number | null) — Previous page index, or null on first page
  • next (number | null) — Next page index, or null on last page
  • pages (array) — Window of ~5 page objects: { start, page } where page is 1-based
  • seen (number) — Running item count — pass to the next call's seen option for position tracking in grids

Notes

Expanded listing items share the listing block's @uid. Selecting any expanded item selects the parent listing block.