Migrating Hand-Rolled Tables
How to move a react-bootstrap <Table> or raw <table> off Bootstrap's table
classes, and how to pick what it becomes. Tracked in #563 (part of epic #442);
the end goal is #502: deleting the Bootstrap/Metronic .table-* rules and the
bootstrap/scss/tables import.
Not every table should become @/table/Table. That goal only needs "no
Bootstrap table classes", and a short read-only list gains nothing from a fetch
cycle, a Redux table store and five props to switch its chrome off. Pick the
target by what the table is, not by the fact that it is a <table>.
Decision tree
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Row count alone is not a reason to use Table. If the rows are already
in memory (one detail or aggregate response, a form value, static data),
paging them in the browser adds clicks and a fetch cycle but saves nothing:
render them with SimpleTable, and if the list can be long, cap its height
with a scroll container. Page in the browser
(createClientPaginatedFetcher) only when a list in memory runs to hundreds
of rows and needs Table's other features.
Conversely, a list read with a hook that calls a paginated endpoint
without page/page_size silently shows only the backend's first page.
That is the clearest case for Table with createFetcher: the server pages,
and the table shows every page.
A single button that is the purpose of each row (Link, Select, Retry) is a
column, not a row action, and can stay in SimpleTable. Use Table's 3-dots
menu when a row has several actions or ones that are secondary to reading it.
SimpleTable takes the same Column<T> definitions as Table, so moving a
list from 4 to 3 later is a component swap plus useTable, not a rewrite.
1. Label/value block
A details panel, an expandable-row summary or a "Connection" card: each row is one label and one value about a single object. This is not tabular data.
There are exactly two ways to write one; do not hand-roll a third (a <dl>
grid, a local DetailRow / InfoRow, a borderless <table>). Choose by what
the rows do, not by where the block sits:
1 2 3 4 5 6 7 8 | |
FormTable + FormTable.Item |
Field (+ ExpandableContainer asTable) |
|
|---|---|---|
| For | Editable details and settings panels; read-only configuration overviews | Read-only summaries: expanded rows, detail dialogs, preview cards |
| Empty value | Row stays; pass renderFieldOrDash(value) so it reads as a dash |
Row is left out; pass the raw value |
| Row actions | actions column; *EditField adds the edit button |
None |
| Controls in rows | Label is a <label> pointing at the row's control; required, description, warnTooltip |
Not for controls |
| Extras | FormTable.Card (title, refresh, toolbar), grouped rows, detailsMode |
hasCopy, help tooltip, colon and long-label tooltip added for you |
| In the app today | 118 files; 67 with edit or row actions; one in an expandable row | 128 files; 52 in expandable rows |
When a block is half and half (read-only rows plus one editable row), it is
FormTable: a single panel should not mix both components.
FormTable, a page's settings panel with every property shown:
1 2 3 4 5 6 7 8 9 10 11 12 | |
With editable rows, use the *EditField exports inside an
EditFieldProvider instead of FormTable.Item (docs/forms.md → Edit Field
Architecture). References: src/administration/arrow/settings/ArrowSettingsCard.tsx
(read-only overview), src/customer/details/CustomerDetailsPanel.tsx
(editable).
Field, a read-only summary; in an expandable row, wrapped so labels and
values line up in columns:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
In a dialog or card, list the Fields directly. References:
ConsumptionRecordDetail in
src/administration/arrow/consumption/ConsumptionRecordsList.tsx (expandable
row), the header of
src/administration/arrow/mappings/CustomerBillingSummaryDialog.tsx (dialog).
What Field does for you, so callers do not repeat it:
- Empty values: a row whose value is
undefined,null,''orfalseis left out. Pass the raw value; do not addrenderFieldOrDashor a dash fallback.0is a value and is shown, so pass counts as numbers rather than wrapping them inString(...). The one exception is a property whose absence is itself information (a final amount not confirmed yet): passDASH_ESCAPE_CODEfor it, with a comment, so the row stays. - Labels: a string label gets its colon, and a long one a tooltip; do not
add
:to the translation. - Extras:
hasCopyfor a copy button,tooltipfor a help icon,valueClass/labelClassfor emphasis or a fixed label width.
FormTable itself is still built on react-bootstrap's Table and is used in
over a hundred places; rebuilding it on waldur-ui is one change that clears all
of them, so do not migrate its call sites one by one.
2. Form grid
One row per item with inputs in the cells: a price editor, quota limits, a
mapping step in a wizard. @/table/Table fights this shape: it pages rows
(hiding ones that are being edited or carry a validation error), reserves a
min-height and sizes columns for a page of data.
Build it from waldur-ui's primitives and add the file to the rule's
ALLOWED_FILES in
packages/eslint-plugin-waldur/rules/no-hand-rolled-table.js with a one-line
reason, as PricesTable.tsx already is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
Do not reach for the primitives for anything else: they are hand-rolling with nicer parts, and the lint rule does not see them yet.
3. @/table/Table
A list that needs the server to page it, or needs search, sorting, filters, selection, expandable rows or a menu of row actions. Follow the other table guides; the parts specific to migrating:
- Server data:
createFetcher(sdkFunction)with a memoizedfilter, as in fetching.md. This replaces auseQueryhook that read the list endpoint itself; drop the hook if nothing else uses it.
1 2 3 4 5 6 7 8 9 | |
Reference:
src/administration/arrow/resources/ViewConsumptionHistoryDialog.tsx.
- Rows already in memory that still need
Table(hundreds of rows with search or sorting):createClientPaginatedFetcher(rows). The fetcher closes over the rows anduseTabledoes not key its query on them, so refetch when they change, or the table keeps its first (often empty) result:
1 2 3 4 5 6 7 | |
- The
tableid is global. A table rendered once per row, per dialog or per scope needs the scope's uuid in its id (arrow-consumption-lines-${uuid}), or two copies share one store entry. - Nested in a dialog, tab or expandable row: drop the chrome the parent already provides:
1 2 3 4 5 6 7 8 9 | |
- Several or secondary buttons in a row become row actions
(
ActionsDropdown+ActionItem, see row-actions.md). - Tests that render the list need a table store:
1 2 3 4 5 6 7 8 9 10 11 | |
Rows arrive asynchronously: use findBy*, not getBy*.
4. SimpleTable
A list already in memory, with the main table's look and none of its fetching, paging, sorting or filters. No store, no id, no fetch cycle, and new rows show on the next render with no refetch; tests render it without providers.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
It renders only the header when rows is empty, so keep the caller's own empty
message (rows.length === 0 ? <p>…</p> : <SimpleTable … />). It has no footer
row: put a total in the surrounding card header or below the table.
- A list that can be long keeps every row and caps its height instead of paging (the header scrolls with the rows):
1 2 3 | |
- A button that is the point of the row is a column:
1 2 3 4 5 6 7 8 | |
References: src/administration/arrow/mappings/SuggestedMatches.tsx
(button column), src/administration/arrow/mappings/ArrowLicensesList.tsx
(capped height).
Behaviour changes to call out
Migrating is not a pure restyle. List each of these in the MR when it happens:
| Change | Why it matters |
|---|---|
A capped list (slice(0, 10) + "Showing 10 of N") now shows every row |
Cap the height with a scroll container if it can be long |
| A list read from a paginated endpoint now pages on the server | It showed only the first page before; now it shows every row |
A <tfoot> total is dropped |
Table and SimpleTable have no footer row; show the total above |
| A row button moves into the 3-dots menu | One more click; tests that clicked the button must open the menu |
| A form grid gains a pager | Rows being edited can be paged out of view; use a form grid (2) |
An empty table now shows NoResult |
Check the caller does not also render its own empty message |
Working a batch
One MR per area (Arrow, Matrix, marketplace policies, proposals, reporting, OpenStack), as #563 proposes.
- Inventory the area. The issue's grep misses react-bootstrap's
Table, so search for both:
1 | |
- Classify every site with the decision tree and write the table into the MR description: file, shape, target (1–4), behaviour changes.
- Migrate. Keep translations, cell content and badges as they are; change the container, not the content.
- Verify:
yarn tsgo --noEmit,yarn lint:check, targetedvitestfor the area, and before/after screenshots of each dialog or page touched. - Confirm the area is clean: no
<table, react-bootstrapTableortable-*class left in it outside files on the rule's allowed list.
Framework work before the remaining batches
These remove the workarounds above; each one is cheaper to do once than to repeat across the remaining batches.
- In-memory rows in
useTable: accept the rows directly and refetch when they change, so theuseEffectrefetch and the hand-scoped id go away. - An embedded preset for
Table: one prop in place ofhideTitle,hasActionBar={false},placeholderHasRetry={false}andminHeight="auto". SimpleTable: an empty message and an optional footer row for totals.FormTablerebuilt on waldur-ui, andFieldmoved off react-bootstrap'sRow/Colinto the table module next toSimpleTable, since it is the app's general property list rather than a resource-summary part.- The hand-rolled property lists outside Arrow moved to
Field: localClusterInfoRow(RabbitMQClusterOverview.tsx) andDetailRow(InvitationDetailsCard.tsx), and the<dl>blocks inTicketDraftCard.tsx,RuleTestMatchDialog.tsx,MergeCheckDetailsDialog.tsxandResourceLimitDetails.tsx. no-hand-rolled-table: also flag waldur-ui'sTableprimitives outside the allowed files, and promote the rule toerrorone directory at a time as each batch lands, so a finished area cannot regress.- A
renderWithProvidersoption that supplies the table store.