Lattice Grid Buy a licence

demo D294

One queue, four tabs, three of them derived

All, Open and Breached SLA narrow each other two levels deep; Profile reads the same queue as a statistic instead of a filter

createTabs(el, { createGrid, tabs }) · from · profile

A support queue behind one tab strip: All holds every ticket, Open narrows to the ones still active, and Breached SLA narrows Open again to the ones now overdue, a chain two levels deep. Resolve a ticket or age one past its deadline in the button bar and watch it leave or arrive in the other tabs without you touching them. A fourth tab, Profile, reads the same queue as a statistical summary rather than a filter, so the strip answers more than one question about one dataset.

Building…
Loading a live grid…

The configuration

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.50.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.50.0/lattice-grid.min.js"></script>

<div id="tabhost" style="height:420px"></div>

<script type="module">
  import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';

  const SLA = { P1: 4, P2: 24, P3: 72 };
  const columns = [
    { field: 'id', title: 'Ticket' },
    { field: 'team', title: 'Team', filter: { type: 'set' } },
    { field: 'priority', title: 'Priority', filter: { type: 'set' } },
    { field: 'status', title: 'Status', filter: { type: 'set' } },
    { field: 'hoursOpen', title: 'Hours open', type: 'number' },
  ];

  const tabs = createTabs(document.getElementById('tabhost'), {
    createGrid: LatticeGrid.createGrid,
    ariaLabel: 'Ticket views',
    tabs: [
      { id: 'all', label: 'All', config: { rowKey: 'id', rows: tickets, columns } },

      // Narrows All: only the tickets still open, following All's own filter.
      { id: 'open', label: 'Open', from: 'all', where: (r) => r.status === 'Open',
        follow: 'filtered', refresh: 'live', config: { columns } },

      // Narrows Open, not All: a chain, two levels deep.
      { id: 'breached', label: 'Breached SLA', from: 'open',
        where: (r) => r.hoursOpen > SLA[r.priority], refresh: 'live', config: { columns } },

      // A different question over the same rows: a statistical profile, not a filter.
      { id: 'profile', label: 'Profile', from: 'all', profile: ['hoursOpen'], refresh: 'live',
        config: { autoHeight: true, columns: [
          { field: 'column', title: 'Measure' }, { field: 'rows', title: 'Tickets', type: 'number' },
          { field: 'mean', title: 'Mean hours', type: 'number' }, { field: 'median', title: 'Median hours', type: 'number' },
        ] } },
    ],
    onTabChange: ({ id }) => console.log('showing', id),
  });

  // All's own grid, to resolve a ticket. rows.apply carries the update, and
  // Open and Breached SLA follow on their own because they still read All,
  // not a copy of its rows.
  const all = tabs.tab('all');
  all.rows.apply({ update: [{ id: 'T-1000', status: 'Resolved' }] });
</script>

One dataset, four tabs, three of them derived

Each tab in the strip is its own grid, configured the way any grid is: rows, columns, a filter menu on the columns that need one. All holds the whole queue. Open and Breached SLA are not copies of it; each names the tab before it as its source and a condition that narrows it, so the tab strip is really one dataset read four ways rather than four datasets that happen to sit under the same tabs. Resolve a ticket in All and it drops out of Open on its own, and out of Breached SLA with it if it was overdue, because both are still reading All, not a snapshot taken when the page loaded.

Profile takes the same queue and asks a different kind of question: not which tickets, but how the whole set behaves, as a row of measures, count, mean, median, spread, fastest and slowest. It reads All directly, so it moves the same way the other tabs do, but it is answering “how healthy is the queue” rather than “which tickets need attention”, off the very rows those tabs are filtering.

Only the tab you are looking at is doing any work. A tab’s grid is not built until you first click it, and once built it stays alive in the background rather than being torn down when you switch away, so your scroll position, your selection and your sort are exactly where you left them when you come back. Try it: scroll or select a row in Open, switch to Breached SLA, then switch back.

How do I build a tab where one tab narrows another?

Give a tab a from naming the tab before it and a where to narrow it, alongside its own config for columns and layout: { id: 'open', from: 'all', where: (row) => row.status === 'Open', config: { columns } }. The strip wires the derivation for you, so the tab always reads the live grid behind the tab it names, not a copy taken once. Chain a third tab off the second the same way and the narrowing composes, All to Open to Breached SLA, two levels deep.

How do I keep a tab’s state after switching away from it?

Nothing extra: a tab’s grid mounts the first time you activate it and then stays mounted, hidden, until the whole strip is torn down, so its scroll position, selection, filters, sort and even an open editor survive a switch by simply never being touched while the tab is not showing.