PAD + React

One page, two answers

The page renders HTML for the browser and answers JSON to fetch(): the same PHP filters, sorts and pages the catalogue, and $padExpose names what may leave the server.

$padExposepadFormat=json useEffectAbortController
Live
  • 🖥️ Atlas Monitor 27 - 349
  • 🎧 Aurora Headphones - 189
  • 🔦 Beam Headlamp - 34
  • 🔊 Boom Speaker - 129
  • 🫖 Brew Kettle - 49
  • 👜 Canvas Tote - 22

How it is built

PAD, on the server React, in the browser

<div data-island="CatalogSearch" data-props="{^start}">
  <ul>
    {items}<li>{$emoji} {$name} - {$price}</li>{/items}
  </ul>
</div>

<script type="text/babel" src="{$padRoot}react/examples/search.js"></script>
<?php

  // One page, two answers. The browser asking ?examples/search gets search.pad with the
  // first result in it; the component asks the same page with padFormat=json and gets
  // $result as JSON - the templates never run for that request. $padExpose is the list of
  // what may leave the server: nothing else of this page does.

  $query    = trim ( reactText ( 'q' ) );
  $category = reactText ( 'category' );
  $order    = reactText ( 'sort', 'name' );
  $perPage  = 6;

  $products   = reactData ( 'products' );
  $categories = array_values ( array_unique ( array_column ( $products, 'category' ) ) );

  $found = array_values ( array_filter ( $products, fn ( $product ) =>
             ( $category === '' or $product ['category'] === $category ) and
             ( $query    === '' or stripos ( $product ['name'] . ' ' . $product ['blurb'], $query ) !== FALSE ) ) );

  usort ( $found, match ( $order ) {
    'cheap'  => fn ( $a, $b ) => $a ['price']  <=> $b ['price'],
    'dear'   => fn ( $a, $b ) => $b ['price']  <=> $a ['price'],
    'rating' => fn ( $a, $b ) => $b ['rating'] <=> $a ['rating'],
    default  => fn ( $a, $b ) => strcasecmp ( $a ['name'], $b ['name'] )
  } );

  $pages  = max ( 1, (int) ceil ( count ( $found ) / $perPage ) );
  $pageNo = min ( $pages, max ( 1, (int) padRequest ( 'p', 1 ) ) );

  $result = [ 'items' => array_slice ( $found, ( $pageNo - 1 ) * $perPage, $perPage ),
              'total' => count ( $found ),
              'page'  => $pageNo,
              'pages' => $pages ];

  $start = [ 'result' => $result, 'categories' => $categories ];
  $items = $result ['items'];

  $padExpose = [ 'result' ];

?>
// The catalogue search. The first result came with the page, in the props; every change of
// the query, the category, the order or the page asks the same PAD page again for JSON -
// PadReact.useData aborts the request before it, so a fast typist never sees an old answer
// arrive last. The request log under it shows each round trip.

function CatalogSearch({ result: first, categories }) {
  const [query, setQuery] = React.useState('');
  const [category, setCategory] = React.useState('');
  const [sort, setSort] = React.useState('name');
  const [page, setPage] = React.useState(1);

  const q = PadReact.useDebounced(query.trim(), 250);
  const { data, loading, error } = PadReact.useData('examples/search', { q, category, sort, p: page }, { result: first });
  const result = data.result;

  // a new search starts on its first page
  const change = set => value => { set(value); setPage(1); };

  return (
    <div>
      <div className="toolbar">
        <div className="search">
          <input className="input" value={query} onChange={event => change(setQuery)(event.target.value)}
                 placeholder="Search the catalogue - try 'light' or 'usb'" aria-label="Search the catalogue" />
        </div>
        <select className="select" value={sort} onChange={event => change(setSort)(event.target.value)} aria-label="Order">
          <option value="name">Name</option>
          <option value="cheap">Price, low first</option>
          <option value="dear">Price, high first</option>
          <option value="rating">Rating</option>
        </select>
        {loading && <span className="spinner" aria-label="Asking PAD" />}
      </div>

      <div className="pills" style={{ marginBottom: 18 }}>
        {['', ...categories].map(name => (
          <button key={name || 'all'} className={'pill' + (name === category ? ' is-on' : '')} onClick={() => change(setCategory)(name)}>
            {name || 'All'}
          </button>
        ))}
      </div>

      {error && <div className="callout"><p>PAD could not answer: {error.message}</p></div>}

      <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', opacity: loading ? .55 : 1, transition: 'opacity .2s' }}>
        {result.items.map(item => (
          <div key={item.id} className="card rise" style={{ padding: 16, display: 'flex', gap: 14, alignItems: 'center' }}>
            <span style={{ fontSize: 34 }}>{item.emoji}</span>
            <div style={{ minWidth: 0 }}>
              <strong>{item.name}</strong>
              <div className="muted small">{item.category} &middot; <Stars rating={item.rating} /></div>
              <div style={{ fontWeight: 700, marginTop: 2 }}>{money(item.price)}</div>
            </div>
          </div>
        ))}
      </div>

      {result.total === 0 && <div className="empty-state"><span className="big">🔎</span>Nothing in the catalogue matches.</div>}

      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 18, flexWrap: 'wrap' }}>
        <button className="btn-sm btn-ghost" disabled={result.page <= 1} onClick={() => setPage(result.page - 1)}>← Previous</button>
        <span className="muted small">page {result.page} of {result.pages} &middot; {result.total} found</span>
        <button className="btn-sm btn-ghost" disabled={result.page >= result.pages} onClick={() => setPage(result.page + 1)}>Next →</button>
        <a className="small" style={{ marginLeft: 'auto' }} target="_blank" rel="noopener"
           href={PadReact.url('examples/search', { q, category, sort, p: page, padFormat: 'json' })}>Open this answer as JSON ↗</a>
      </div>

      <RequestLog />
    </div>
  );
}

PadReact.island('CatalogSearch', CatalogSearch);