Back to Wekan

Design: the Table Page

docs/Design/Page/Table.md

10.4120.0 KB
Original Source

Design: the Table Page

One design, one implementation, for every paginated table in WeKan: a title, an optional status line, a controls row (search + pagination) and a table of rows.

This page is the single source for everything those pages have in common. A page-specific design doc describes only what is different about that page and links back here — it must not restate the layout, the controls or the paging rules, because they are defined once, here and in one template.

Everything that makes a table page work. Paths are from the repository root.

File PathFile TypeDescription
client/components/settings/tablePage.jade.jade templateThe table page: title, optional status row, controls, table. The row order is defined here, once.
client/components/settings/tablePage.css.css stylesheetThe layout: full width, equal columns, wrapping cells, and the ≤ 800px stacking of menu and table. No button colours — see the next row.
client/components/main/paginationControls.css.css stylesheetThe colours of every prev/next pager in WeKan, table pages included: the per-user theme accent, falling back to the WeKan default blue.
client/components/forms/forms.css.css stylesheetThe global button rules the pager stylesheet has to out-specify. Read the note in paginationControls.css before touching either.
models/lib/tablePage.js.js module, pure helperspageInfo(), buildRows(), buildHeader(), columnWidthPercent(). No DOM and no database, so they are unit-testable.
client/components/settings/adminReports.js.js Blaze template logicThe column spec for each Admin Panel table, the paging state, the subscriptions, and the shared control handlers.
client/components/settings/adminReports.jade.jade templateAdmin Panel / Problems: the side menu, and which page it renders.
client/components/settings/adminReports.css.css stylesheetOnly what is not shared: the CPU status card and the side-menu separator.
client/components/settings/translationBody.jade.jade templateAdmin Panel / Settings / Translation: the pane, its interactive row and its "New" header.
client/components/settings/translationBody.js.js Blaze template logicThe Translation column spec, its paging state and its share of the shared control handlers.
server/publications/translation.js.js publicationTranslation: one page of custom translation strings (limit/skip).
client/features/settings.js.js import listRegisters the template, its stylesheet and the logic into the client bundle. A file that is never imported is simply not loaded.
server/publications/attachments.js.js publication + methodFiles Report: one page of attachments, and getAttachmentsReportCount.
server/publications/rules.js.js publication + methodRules Report: one page of rules, and getRulesReportCount.
server/publications/boards.js.js publication + methodBoards Report: one page of boards, and getBoardsReportCount.
server/publications/cards.js.js publication + methodCards Report and Broken cards: one page of cards each, with getCardsReportCount / getBrokenCardsReportCount.
server/publications/impersonationReport.js.js publication + methodImpersonation Report: one page of events, and getImpersonationReportCount.
server/publications/recoveryReport.js.js publication + methodRecovery: one page of recovery events, and getRecoveryReportCount.
models/eventLog.js.js model + methodsThe Security / Speed / Tests / CPU usage streams: eventLogPage, eventLogCount, and the {stream, at} index that keeps them fast.
tests/tablePage.test.cjs.cjs Node testThe one suite for all of the above: the pure helpers, the template, the layout rules this page promises, the themed pager, that paging stays server-side and index-backed, and the "one implementation" guarantee. It also checks that every path in this table still exists.

Pages that do not use this design

Pages with a paginated table that are not built from this design, and why. Listed so nobody has to re-derive the answer by reading the code, and so the gap is visible rather than forgotten.

Table nameMenu pathWhy it does not use this design
People — its three non-table panesAdmin Panel / PeopleLocked users, Roles and Shared templates are not tables: Locked users is a form of numeric lockout settings with a Save button, Roles is a checkbox list, and Shared templates is a checkbox list of scopes. There is no paginated set of rows to render, so this design does not apply to them and forcing it would only add a table around a form. All four of People's TABLE panes — Domains, Organizations, Teams and People — are converted and listed below.

People is fully converted: its four table panes render through this design, and its search, filter dropdown, action buttons and total are the shared controls row. Its separate Search button is gone — every table page searches on Enter.

Pages that use this design

Admin Panel

Menu path is what you click to reach the page.

Table nameMenu pathDescription
PeopleAdmin Panel / People / PeopleEvery user with e-mail, admin flag, active state, lockout status and created date. Interactive rows (rowTemplate) and two template headers: the new-user row and the select-all checkbox.
TeamsAdmin Panel / People / TeamsEvery team with its details and per-team feature switches. Same shape as Organizations: a rowTemplate and three headerTemplate columns.
OrganizationsAdmin Panel / People / OrganizationsEvery organization with its details and per-organization feature switches. Interactive rows, so it supplies a rowTemplate; three of its headers carry a select-all pair, supplied as headerTemplate.
DomainsAdmin Panel / People / DomainsEvery e-mail domain in use with the number of users on it. The first People pane converted to this design.
SecurityAdmin Panel / Problems / SecuritySecurity events from the event log: blocked uploads, rejected URL schemes, auth failures. One row per event, newest first.
SpeedAdmin Panel / Problems / SpeedSlow-operation events — what took too long, where, and for how long.
TestsAdmin Panel / Problems / TestsTest-run events recorded on the server.
CPU usageAdmin Panel / Problems / CPU usagePast high-CPU periods. Adds a status row: the live CPU percent, core count and load average, because the table itself is history, not the current state.
Broken cardsAdmin Panel / Problems / Broken cardsCards with no board, swimlane or list, or a type that is not a card type. A column spec like the reports beside it — it used to run on the global-search results list, which is why it was the one report here with a different set of controls.
Files ReportAdmin Panel / Problems / Files ReportEvery attachment: file name, size, MIME type, and the attachment / board / card ids.
Rules ReportAdmin Panel / Problems / Rules ReportEvery automation rule with its board, action type and trigger type.
Boards ReportAdmin Panel / Problems / Boards ReportEvery board with its id, permission, archived state, members, organizations and teams.
Cards ReportAdmin Panel / Problems / Cards ReportEvery card with its board, swimlane, list, members and assignees.
Impersonation ReportAdmin Panel / Problems / Impersonation ReportWho impersonated whom, on which board, when and why.
RecoveryAdmin Panel / Problems / RecoveryDatabase recovery events with severity, database and detail.
TranslationAdmin Panel / Settings / TranslationThe custom translation strings: language, source text, translation. Interactive rows (an Edit link and the ⋯ menu) and one template header, the "New" link.

Card, board and member history

Designed, not yet implemented — see History.

Table nameMenu pathDescription
Card historyCard / hamburger menu / HistoryEvery change to this card, restorable.
Member historyMember settings / HistoryOne user's changes, across the boards the caller can see.
Board historyBoard Settings / HistoryEvery change on the board and everything inside it.
Swimlane historySwimlane menu / HistoryChanges to the swimlane and its lists and cards.
List historyList menu / HistoryChanges to the list and its cards.

Layout

Rows, top to bottom. This order is fixed, and is defined once in client/components/settings/tablePage.jade:

  1. Title — what the page is. Rendered only when the page supplies one (titleKey or title). Inside the Admin Panel it does not: every pane there gets one heading from the active left-menu entry, rendered by the section around it — see Left Menu. A table page that printed a title of its own as well would show the same words twice, which is why People, Organizations, Teams, Domains, Translation and the Problems reports pass no title. The heading carries .admin-pane-title either way, so a table pane and a form pane have the same title at the same size.
  2. Status (optional) — live state that the table itself cannot show, e.g. the current CPU percent on the CPU usage page. Rendered from a named template passed in as statusTemplate; pages without one render no status row.
  3. Controls — the search field on the start side, pagination (‹ page X / N ›) pushed to the end side.
  4. Table — header row plus one row per record. It is always rendered, empty or not: several pages put controls in the header — the "New" link of Organizations, Teams, People and Translation, the select-all pairs — so hiding the table when there are no rows hid the only way to create the first row. An empty pane shows the header and the "no items" message below the table, not instead of it.

Width behaviour:

  • Wide windows — the left menu of the section keeps its width and the table sits beside it, filling the rest.
  • Narrow windows (≤ 800px) — the left menu goes full width on top and the table sits below it. This deliberately overrides the older "always side by side" rule: side by side on a phone left the table a few dozen pixels wide with its right-hand columns unreachable.
  • The table is width: 100% with table-layout: fixed, so all columns get the same percentage of the width and the table can never grow wider than the panel. That is what keeps the right-hand side of the table inside the browser window instead of off the right edge.
  • Cell text wraps: overflow-wrap: anywhere breaks even a long unbroken id, URL or file name inside its column instead of widening it — so a long value wraps onto a second line rather than being cut off at the column edge. Like the width, the wrapping is !important: app-wide table td { white-space: nowrap } rules had been switching it off. A column can opt out with nowrap (used for the datetime columns), which is !important too so the opt-out still works.
  • Only the table's own wrapper may scroll sideways, and only as a last resort; the page itself never does.
  • Nothing outside this design may force a width. Two admin stylesheets used to set min-width: 1200px !important; width: max-content !important on a bare table selector — one of them (peopleBody.css) with no page in the selector at all, so it reached every table in WeKan and its !important beat this page's own layout. The result was the report this design exists to prevent: the right-hand columns off screen until you scrolled. Both are scoped away from .table-page-table now, and neither forces a floor on any admin table any more. The same rules also made a short table shrink: with width: max-content a two-column table sat at a third of the panel instead of filling it. width, max-width and table-layout on .table-page-table are therefore !important — a guarantee a stylesheet in another folder can silently override is not a guarantee.

The controls row shares one height (34px) and no margins, set in tablePage.css: the search field, the filter, every action button and the prev/next pair line up on the same top and bottom edge. The global button rule in forms.css is display: block; margin-bottom: 14px, written for stacked form buttons — a control that keeps that margin is centred higher than one that does not, which is exactly how People's "Unlock all users" ended up lower than "Teams" beside it.

Data loading

A table page loads one page of rows, never the whole collection.

  • pageInfo(total, page, perPage) in models/lib/tablePage.js returns { page, totalPages, hasPrev, hasNext, skip, limit }. The same call feeds the subscription and the "page X / N" counter, so what is fetched and what is displayed cannot drift apart.
  • The publication applies limit/skip server-side and sends only that page, so only those rows reach minimongo. The client re-applies the publication's sort so the displayed order matches the server page; it must not re-slice an already-paginated set.
  • The total row count comes from a separate count method, called when the page is opened or the search changes — not on every prev/next click. The total cannot change because you moved to the next page, and recounting there added a second round trip to every click.
  • perPage is TABLE_PAGE_ROWS_PER_PAGE (25), one constant for every table.

Columns

A page differs from another only in its column list:

js
{ label | labelKey, value(doc), align: 'end', nowrap: true, cls, userId(doc), data(doc) }
  • label is a literal, labelKey an i18n key.
  • value(doc) returns the cell text. A missing field renders as an empty cell, never the string undefined.
  • userId(doc), when given, renders the cell as a link that opens the same "Edit user" popup as Admin Panel / People.
  • align: 'end' right-aligns (sizes, counts); nowrap keeps a datetime on one line; data(doc) sets data-sev, which colours high/critical severities.

buildHeader(columns) and buildRows(docs, columns) turn that list into what the template iterates. Both are pure functions in models/lib/tablePage.js with unit tests, so a row can never end up with fewer cells than the header — which is how a hand-written table gets its columns shifted by one.

Interactive rows and headers

Some panes cannot be expressed as text cells: their rows carry inline checkboxes and edit links, and their headers carry controls. Two slots cover that without every page reinventing the table:

  • rowTemplate — a template name plus docs, an array of data contexts. The page owns its <tr> and is then responsible for matching the column count; the guarantee that a row can never be shorter than the header applies only to the cells form.
  • headerTemplate / headerData on a column — the <th> still belongs to the shared template; only its contents come from the page.

Everything else — layout, paging, search, the themed pager, the total — is shared either way. Prefer the column spec; reach for these only when the pane is genuinely interactive.

Controls

The controls row holds, in reading order: the search field, any filters, any actions, the total, and the pagination pushed to the end side.

Filters, actions and the total are on by default — there is no flag to turn them on. A page that supplies a filter or an action gets it rendered, and the total is shown whenever the page knows one. They came from Admin Panel / People, where they were hand-written into that page's own markup; they are general enough for any table, so they live here.

js
filters: buildFilters([{ id, labelKey, options: [{ value, labelKey|label }] }], current)
actions: buildActions([{ id, labelKey, icon, cls }])
total:   <number>          // shown when non-zero
totalLabelKey: 'people-number'   // optional label before it
  • A filter is one <select>; the page reads data-filter and the selected value. The option matching current is selected, compared as strings so a numeric value still matches.
  • An action is one button; the page reads data-action to know which was pressed. It is filled with the theme accent (an action, not navigation), and it brings no geometry of its own — a page that gave its action button a class with a margin and a height of its own put it at a different height from every other control in the row.
  • The total counts the whole result set, not the page — it sits with the controls for that reason, and inherits its colour so it stays readable on a dark theme.

Three controls are always present, with the same class names on every page:

  • .js-table-page-search — type and press Enter. Searching resets to page 1 and recounts.
  • .js-table-page-prev / .js-table-page-next — disabled at the ends via hasPrev / hasNext.
  • .js-table-page-edit-user — a username cell, opening the Edit user popup.

Because the class names are shared, the handlers are shared too: the page being acted on is identified by which report is open, not by a per-page class. Adding a table page therefore adds no new event handler, no new markup and no new CSS.

Theme

A table page never invents a colour. Its buttons and counter follow whatever theme is in force:

  • Per-user theme — Member Settings → Change color sets --theme-accent (and --theme-accent-2) on :root. Everything themed picks that up immediately, for that user only.
  • WeKan default — with no custom colour chosen, --theme-accent is unset and every rule falls back to the WeKan blue #01628c through var(--theme-accent, #01628c).

The prev/next buttons, the action buttons and the "3 / 42" counter are styled in one place for the whole app — client/components/main/paginationControls.css — which the Admin Panel tables, People / Organizations / Teams / Domains, All Boards, the board Table view, Archived boards and the Cron settings tables all share. A table page gets that look by using the shared class names (.table-page-pagination, .table-page-page-info); it adds nothing of its own.

Do not restate those colours in a page stylesheet. That file spells out :hover, :focus, :active and :active:hover deliberately, because the global button rules in client/components/forms/forms.css set the same states from --theme-accent with a black / dark-grey fallback, at equal or higher specificity:

css
button              { background: var(--theme-accent, #000) }
button:focus        { background: var(--theme-accent, #222) }
button:active       { background: var(--theme-accent, #111) }
button:active:hover { background: #e6e6e6 }

A partial copy — say only the base state and :hover — therefore looks right until the button is clicked, and then loses to button:focus / button:active:hover and leaves a black or grey button sitting on the page for as long as it keeps focus. That is why the colours live in one file that covers every state, and why tablePage.css carries layout only.

The rest of the page inherits: the title and cell text take the surrounding text colour, and the counter uses color: inherit so it stays readable in both the light and the dark theme rather than being pinned to a hard-coded grey.

RTL and dates

  • The layout is plain block order plus logical properties (margin-inline-start, text-align: start), so it mirrors under dir=rtl without duplicated markup.
  • Datetime columns use the app's configured date format helper, so a table page matches the rest of the UI.

Adding a table page

  1. Add the column list.
  2. Point it at a publication that honours limit/skip and a count method.
  3. Add the menu item.

Nothing else: no template, no CSS, no handlers, no paging code.

The files are listed at the top of this page.