Skip to content

Saved Table Views Developer Guide

Ed Mozley edited this page Aug 30, 2026 · 1 revision

Saved table views β€” Developer Guide

How saved views are put together, and the decisions worth not re-litigating.

The user page is Saved table views. Related: Assets Β· Roles and Permissions

Built for discussion #96 (dschipfel), expanded by Ed.


1. πŸ“ The files involved

File What it does
includes/table_views.php The visibility rules. Every read and write goes through it
api/table-views/list.php Views on one table, plus the reader's teams and their default
api/table-views/save.php Create or update
api/table-views/delete.php Delete
api/table-views/use.php Stamp last-used, and set or clear the personal default
assets/js/data-table.js Capture, apply, the library and the editor
includes/data-table-skeleton.php The Views and Save view toolbar buttons
assets/css/data-table.css The library, the editor, the buttons
tests/table-views.php 31 assertions, touches the database

2. ⭐ It lives in the engine, not in a module

asset-management/table.php is a thin page over assets/js/data-table.js, and so are tasks/table, calendar/table and change-management/table. Views are built into the engine, so all four have them.

A module opts in with one line in its createDataTable() config:

viewsKey: 'assets',   // 'assets' | 'tasks' | 'calendar' | 'changes'

A table with no viewsKey hides both buttons rather than showing ones that do nothing.

⚠️ viewsApi is derived from prefApi, not set again. The four host pages sit at different depths (../api/… vs ../../api/…), and a second copy of that path is a second chance to get it wrong β€” which would present as a Views button that silently does nothing.


3. Schema

table_views
  id, table_key, name, description
  owner_id      -- FK analysts, ON DELETE SET NULL
  visibility    -- 'private' | 'team' | 'public'
  team_id       -- FK teams, ON DELETE SET NULL
  config        -- MEDIUMTEXT, the engine's own state as JSON
  created_datetime, updated_datetime, last_used_datetime

config is opaque to the database on purpose: the engine owns that shape, and a column per setting would need a migration every time it gained one. It is validated as JSON on the way in β€” a malformed one would break the table for everybody it is shared with, and the moment to find that out is at save.

owner_id is SET NULL rather than CASCADE, so a team or public view survives the person who wrote it leaving. A private view with no owner then matches nobody: unreachable rather than exposed.

No tenant_id, and none is needed. A view is a way of looking at rows; its filters are applied to rows the reader was already allowed to load.


4. πŸ”΄ Visibility: one clause, and why it is bracketed

Everything reads through:

function tableViewVisibleClause(PDO $conn, int $analystId): array

Three answers β€” mine, my team's, everyone's β€” and a fourth that must never happen: somebody else's private view. Four endpoints each writing their own clause is four chances to get that wrong.

⚠️ It returns a BRACKETED group. Callers append it to a WHERE that already carries table_key, and an unbracketed set of alternatives would bind that condition to the last branch only β€” a tasks view would appear on the asset table. There is an assertion for exactly this, because it is a one-character mistake.

Analysts belong to many teams. analyst_teams is a join table; there is no analysts.team_id. So "their team" was ambiguous and Ed chose: sharing names a specific team, and tableViewSave() refuses a team the saver does not belong to β€” otherwise anybody could share into any team by posting its id.

An analyst in no teams is a real case: the clause is built from their teams, so with none it has to fall back to owner-or-public rather than producing broken SQL. Asserted.

Write access is owner-only. can_edit is computed server-side and returned with each row, so the browser shows the same answer the write path enforces rather than working it out from owner_id itself.


5. Two things that live on the reader, not the view

  • The default is a row in user_preferences (dtview_default_<table_key>). It is a fact about the reader: two people can have different defaults pointing at the same shared view, and one changing theirs must not change the other's.
  • Last used is one timestamp on the view, not one per reader. It answers "is anybody still using this?", which is what decides whether it can be deleted. "When did I last use it?" would need a row per person per view, and nobody has asked for that.

6. Capture and apply

captureViewConfig() reads columnState, sort and filters. Filters are held as a Set per column, so they are unpacked to arrays going out and repacked coming back.

⚠️ searchTerm is deliberately excluded. A filter is how you like to look at things; a search is a question you asked once.

applyColumnConfig() is one function shared by preferences and views β€” both restore the same thing, and two copies would drift the first time a column was added. Its merge is what makes a view survive a changing table: unknown keys dropped, columns the config never mentioned appended at their defaultOrder.

That merge is why a view naming six columns can render eight, which is correct and worth knowing before somebody "fixes" it.

applyDefaultView() runs after loadPreferences(), so a chosen view beats the loose column state β€” picking a view is the more deliberate act. A default pointing at a deleted or now-unreachable view silently falls back to the table's own defaults rather than erroring every morning.


7. Traps hit while building it

  • .dt-btn is scoped .dt-toolbar .dt-btn. The modal footer buttons shipped with no padding, border or radius, rendering as bare text on a coloured block. .dt-modal .dt-btn now defines them. Third time in one day a class was used outside the scope it is defined in.
  • The English fallback must interpolate. vt() fell back to raw strings without substituting, so before the strings existed the library rendered Created {d} and by {name} literally. A safety net that produces visible nonsense is not a safety net.
  • Save view fetches the list before opening the editor. The teams an analyst can share into arrive with the view list, so opening from a cold toolbar would show "A team" disabled the first time and working the second.

8. Testing

php tests/table-views.php     # 31 assertions

Everything it makes is named ZZTV and removed in a finally. It picks real analysts with differing team membership rather than creating them, because the whole feature turns on analyst_teams being what it says it is β€” and skips with a message if analyst 1 is not in two teams, rather than passing vacuously.

Every visibility answer is checked from the side that must be refused as well as the side that must work, with positive controls: somebody who can see a public view cannot update or delete it, and can_edit says so before they try.

Beyond the unit tests, the library was driven in a real browser on the asset table: applying a saved view took it from 10 columns and 597 rows to 4 columns and 14 rows, with the sort applied and the button marked active.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally