Lattice Grid Buy a licence

demo D29

Multi-value cells in htmx

Tags in one cell, with containsAny and containsAll filtering

multiple: true

This grid holds several values in one cell, the way tags or skills attach to a record, and filters them with 'contains any' and 'contains all'. It fits fields where a row belongs to more than one category, without splitting into extra columns.

Building…
Loading a live grid…

This is the htmx version. A server-rendered table is upgraded in place, so the page is readable before any script runs and gains the full grid when the module loads, which suits a progressively enhanced app.

The configuration

<link rel="stylesheet" href="https://cdn.latticegrid.dev/lattice-grid@1.107.0/lattice-grid.min.css">

<!-- A real, server-rendered table: readable with JavaScript off. data-field on
     each header names the column that cell becomes, and data-id on each row is
     the key the grid carries it under. -->
<table id="multi-value-table" data-lattice-grid>
  <thead>
    <tr>
      <th data-field="hostname">Hostname</th>
      <th data-field="tags">Tags</th>
      <th data-field="site">Site</th>
      <th data-field="ports">Ports</th>
    </tr>
  </thead>
  <tbody>
    <tr data-id="1"><td>peering-0001.london.example.net</td><td>["monitored","gdpr","managed"]</td><td>3</td><td>8</td></tr>
    <tr data-id="2"><td>leaf-0002.london.example.net</td><td>["monitored","managed"]</td><td>3</td><td>12</td></tr>
    <tr data-id="3"><td>peering-0003.zurich.example.net</td><td>["monitored","pci","gdpr","ipv6","legacy","managed"]</td><td>6</td><td>8</td></tr>
    <!-- the rest of the rows, rendered by your server -->
  </tbody>
</table>

<script type="module">
  import { readTable, hydrateTable } from 'https://cdn.latticegrid.dev/lattice-grid@1.107.0/modules/htmx.esm.min.js';

  // A row can hold several tags at once, each shown as its own pill.
  const TAGS = [
    { id: 'pci', label: 'PCI', variant: 'danger' },
    { id: 'gdpr', label: 'GDPR', variant: 'warning' },
    { id: 'ipv6', label: 'IPv6', variant: 'info' },
    { id: 'bgp', label: 'BGP', variant: 'info' },
    { id: 'managed', label: 'Managed', variant: 'success' },
    { id: 'legacy', label: 'Legacy', variant: 'neutral' },
    { id: 'monitored', label: 'Monitored', variant: 'success' },
  ];
  const SITES = [
    { id: 1, label: 'Aberdeen edge', group: 'Edge' },
    { id: 2, label: 'London core', group: 'Core' },
    { id: 3, label: 'Manchester core', group: 'Core' },
    { id: 4, label: 'Slough transit', group: 'Transit' },
  ];

  // A table cell carries text, so the column whose value is a whole object
  // travels as JSON and is read back here. Everything else
  // arrives as the number, boolean or string it already looks like.
  const rows = readTable(document.getElementById('multi-value-table')).rows.map((row) => ({
    ...row,
    tags: JSON.parse(row.tags),
  }));

  // The markup carries the rows; a server-rendered table's column types are
  // declared in the config hydrateTable takes. The grid replaces the table
  // where it stood.
  const grid = hydrateTable(document.getElementById('multi-value-table'), {
    toolPanel: {
      side: 'left',
      panels: ['columns', 'filters', 'views', 'quick'],
      actions: ['undo', 'redo', 'export', 'restore', 'maximise'],
      exportName: 'lattice-demo',
    },
    columns: [
      { field: 'hostname', title: 'Hostname', layout: { width: 280, pin: 'start' } },
      {
        field: 'tags', title: 'Tags', layout: { flex: 1, min: 240 },
        // multiple: true makes the cell an array rather than a single value, and
        // brings the set operators with it: containsAny, containsAll and
        // containsNone, rather than equality against a joined string.
        lookup: { options: TAGS, multiple: true, separator: ', ' },
        cell: { decoration: 'pill' },
      },
      {
        field: 'tags', id: 'count', title: 'Tag count', type: 'number', layout: { width: 130 },
        value: { deps: ['tags'], pure: true, compute: (d) => (Array.isArray(d.tags) ? d.tags.length : 0) },
        total: 'avg',
      },
      {
        id: 'inScope', title: 'In audit scope', type: 'boolean', layout: { width: 150 },
        // containsAll, written out: this does to a value what the filter below
        // does to the rows.
        value: {
          deps: ['tags'], pure: true,
          compute: (d) => {
            const t = Array.isArray(d.tags) ? d.tags : [];
            return t.includes('pci') && t.includes('gdpr');
          },
        },
      },
      {
        field: 'site', title: 'Site', type: 'lookup', layout: { width: 180 },
        lookup: { options: SITES, unknownLabel: 'decommissioned' },
      },
      { field: 'ports', title: 'Ports', type: 'number', layout: { width: 110 } },
    ],
    // The set operators read from the columnar store, so raise the threshold to
    // keep them working across the whole dataset.
    source: { mode: 'memory', columnarBelow: 2_000_000 },
    // Land filtered, so the operator is visible rather than described. Clear it
    // from the filters panel to get the other rows back.
    state: {
      filters: { op: 'and', conditions: [{ col: 'tags', op: 'containsAll', value: ['pci', 'gdpr'] }] },
    },
    rows,
  });
</script>

Storing several values in one cell with multi-value columns

A multi-value cell holds more than one entry against a single row and column, the pattern behind tags, labels, skills and any field where a record can belong to several categories at once rather than exactly one. A developer reaches for it whenever a plain lookup column is too narrow for the data: a support ticket with three labels, a contact with two roles, a product in four categories. Lattice Grid turns a column into this shape with multiple: true, which changes how the cell reads and renders a value from a single code to an array of codes, each shown as its own chip within the cell, and changes how the filter menu behaves to match. Filtering follows the same split a reader would expect: containsAny matches a row if it carries at least one of the selected values, containsAll matches only rows carrying every one of them, and the two give different result sets over the same tag column whenever a row holds more than one tag. Rendering stays cheap at scale because each chip is a plain DOM node inside a recycled cell rather than a nested component, so a multi-value column in a JavaScript data grid scrolling past thousands of rows pays no more per row than the number of chips actually on screen.

How do you filter a column that holds several values per row?

Set multiple: true on the column so each cell stores an array rather than a single value, then choose the filter operator: containsAny for rows matching at least one selected tag, containsAll for rows matching every selected tag. Lattice Grid applies whichever operator is set against the full array on each row.