Skip to content

UN-4124 [FIX] Implement antd's expandable API on the shared DataTable so nested cell values expand again - #2288

Merged
vishnuszipstack merged 3 commits into
mainfrom
UN-4124-datatable-expandable
Sep 18, 2026
Merged

vishnuszipstack merged 3 commits into
mainfrom
UN-4124-datatable-expandable

Conversation

@vishnuszipstack

@vishnuszipstack vishnuszipstack commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

What

Why

  • DataTable never declared expandable. Undeclared, the whole object fell into ...props and was spread onto the wrapper <div>, where React ignores it — so expandedRowRender was never called and the table rendered exactly as if the prop had not been passed.
  • The failure was completely silent — no console error, no warning. The only symptom was a button that did nothing.
  • The visible casualty is HITL's review editor: an array or object inside a table cell renders as a truncated JSON blob with an expand button beside it, and clicking that button did nothing at all, leaving nested extracted values unreadable in Table view.

How

  • The expansion panel renders as a sibling <tr> spanning every column, since a <tr> may only contain cells.
  • Keys are compared as strings, because that is what TanStack's getRowId (and therefore row.id) produces. A call-site numbering its rows key: index passes numbers, and [0].includes("0") is false — a mismatch that would have hidden every expansion on its own, even with the rest of the implementation correct.

Can this PR break any existing features? If yes, please list possible items. If no, please explain why.

  • Low risk, because the behaviour being added was previously inert. Every existing call-site that passes no expandable prop renders exactly as before — the new code paths are reached only when expandedRowRender is supplied, which today is a prop nothing could act on.
  • The one behavioural change for existing callers is the extra expand column, and that appears only when expandable is passed.

Relevant Docs

  • Ant Design Table expandable API (the surface being matched).

Related Issues or PRs

Dependencies Versions / Env Variables

  • None.

Notes on Testing

  • Adds 151 lines of coverage to DataTable.test.jsx and extends antd-structure.test.jsx.
  • Full frontend suite on this branch: 659 passed, 2 failed. The 2 failures are in cascade-and-affordances.test.jsx (a React 19 defaultProps check) and are unrelated to this PR — every offender it names lives under frontend/src/plugins/, i.e. the gitignored cloud plugin overlay copied in locally, not OSS source.
  • Verified end to end in a dev namespace: with the fix deployed, clicking the expand button on a nested cell opens the panel and renders the nested values.

Screenshots

...

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

The Ant Design removal (#1683 / #2212) replaced `<Table>` with the shared
DataTable, which never implemented antd's `expandable` API. Undeclared, the
whole object fell into `...props` and was spread onto the wrapper <div>, where
React ignores it — so `expandedRowRender` was never called and the table
rendered as if the prop had not been passed, with no console error.

HITL's review editor is the visible casualty: an array or object inside a table
cell shows a truncated JSON blob with an expand button beside it, and clicking
that button did nothing at all, leaving nested values unreadable in Table view.

Support `expandedRowRender`, controlled `expandedRowKeys` and uncontrolled
`defaultExpandedRowKeys`, `rowExpandable`, `showExpandColumn`, `expandIcon`,
`expandedRowClassName`, `onExpand` and `onExpandedRowsChange`. The panel is a
sibling <tr> spanning every column, since a row may only contain cells.

Keys are compared as strings because that is what TanStack's `getRowId` (and so
`row.id`) produces: a call-site numbering its rows `key: index` passes numbers,
and `[0].includes("0")` is false — a mismatch that would have hidden every
expansion on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with the previous callback key-type issue fully fixed and no new actionable defects identified.

Summary

The PR restores Ant Design-compatible expandable-row behavior in the shared DataTable, including controlled and uncontrolled keys, custom toggles, row eligibility, callbacks, styling, and full-width detail rows.

  • Normalizes keys internally for TanStack row matching while preserving caller key types in callbacks.
  • Adds an optional expand-control column and supports caller-driven expansion without that column.
  • Adds focused component and structural-shim coverage for the HITL nested-value workflow.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Expandable configuration] --> B{Controlled keys?}
    B -->|Yes| C[Use expandedRowKeys]
    B -->|No| D[Use internal keys]
    C --> E[Normalize keys for row.id matching]
    D --> E
    E --> F{Row is expandable and key matches?}
    F -->|Yes| G[Render sibling detail row]
    F -->|No| H[Render data row only]
    I[Toggle or custom expand icon] --> J[Compute caller-typed next keys]
    J --> K[Call onExpand and onExpandedRowsChange]
    J -->|Uncontrolled| D
Loading

Reviews (3) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment thread frontend/src/components/data-table/DataTable.jsx
vishnuszipstack and others added 2 commits September 18, 2026 10:21
Review feedback on #2288.

`onExpandedRowsChange` built its argument from the internal `expandedKeys`
set, which is normalized with `.map(String)` so it can match TanStack's
`row.id`. That normalization leaked out: a controlled caller that passed
`[1]` was handed back `["1"]`, and a parent testing `includes(1)` against it
would never match.

Normalization is now strictly internal. The reported array is built from the
caller's own keys — `controlledExpandedKeys ?? ownExpandedKeys` — appending
`originalRowKey()`, which reverses `getRowId`'s stringification by reading
`rowKey` off the record directly. Collapse compares with `String(k) !== key`
so it can filter a normalized id out of an un-normalized list without
rewriting the survivors' types.

Expansion itself is unaffected: `expandedKeys` is still a string Set, so
matching behaves exactly as before. `defaultExpandedRowKeys` no longer
stringifies on the way into state, since the memo normalizes anyway.

Both directions are covered and mutation-checked: stringifying the appended
key fails the expand test, and filtering the normalized set fails the
collapse test.

Also addresses SonarCloud S9020 — `waitFor` + `getByText` becomes
`findByText` in the nested-panel test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
✅ e2e-api-deployment e2e 3 0 0 0 14.0
✅ e2e-coowners e2e 1 0 0 0 1.0
✅ e2e-etl e2e 1 0 0 0 17.9
✅ e2e-login e2e 2 0 0 0 0.9
✅ e2e-prompt-studio e2e 1 0 0 0 9.3
✅ e2e-smoke e2e 2 0 0 0 0.8
✅ e2e-workflow e2e 1 0 0 0 13.8
❌ ui e2e 0 1 0 0 0.0
TOTAL 11 1 0 0 57.7

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • co-owner-manage — covered by e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • pipeline-etl-execute — covered by e2e-etl
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@vishnuszipstack
vishnuszipstack merged commit 6fe7d8d into main Sep 18, 2026
10 checks passed
@vishnuszipstack
vishnuszipstack deleted the UN-4124-datatable-expandable branch September 18, 2026 05:38

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed alongside Zipstack/unstract-cloud#1778. The controlled/uncontrolled contract, key-type preservation and sibling-<tr> rendering all look right and are well pinned by the tests. Inline comments below — the two I'd treat as blocking are the customer name in the OSS test and the rowKey regression test that can't currently fail; the rest are one-liners or follow-ups.

* HITL's review editor drives expansion from a button inside the cell, not
* from antd's toggle column, so it passes `showExpandColumn: false` and a
* controlled `expandedRowKeys` of row indices. The shim dropped `expandable`
* entirely, so the panel never rendered: a customer reviewing a MARS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this names a customer ("MARS") in the open-source repo, and the fixture below (vendor: "Fruta", sap_mapping, hitl_flag, testResults) reads as lifted from a real document. Please genericise both the comment and the fixture keys/values.

While here, the comment can be one line — the test is self-describing:

// Call-site drives expansion from its own controls: showExpandColumn:false + controlled expandedRowKeys of row indices.

* and no way to read the nested values in Table view (UN-4124).
*/
describe("Table expandable (UN-4124)", () => {
const row = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture has no id field, so with the default rowKey="id" getRowId falls back to the index — "0" — which happens to equal String(key). The harness therefore passes whether or not rowKey="key" is honoured, i.e. it can't catch the exact regression the cloud PR fixes. Add an id: "INV-2043" (or similar) to the row so the default keying would actually break it.

render(
<DataTable
columns={columns}
dataSource={[{ key: 0, name: "Row 1" }]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in antd-structure.test.jsx: this row has no id, so String(row?.id ?? index) is "0" regardless of rowKey. Give it an id that differs from key so the test only passes when rowKey="key" is respected.

id: "__expand",
header: () => null,
enableSorting: false,
size: 48,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size is never read — the only width the shim applies is header.column.columnDef.meta?.width (see the <TableHead> style further down), so this is dead config of exactly the kind this PR is fixing. Suggest:

meta: { width: expandable?.columnWidth ?? 48 },

which also picks up antd's columnWidth sub-key. Harmless under auto layout (LinkedPromptsSection), but the column would take an equal share under tableLayout="fixed".

/*
* The untouched key behind `row.id`. `getRowId` stringifies whatever `rowKey`
* resolves to, so this is the only way back to the value the call-site
* actually holds — falling back to the row index, which is what `getRowId`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This claim is only true for a string rowKey. getRowId does String(rowKey(row)) for the function form with no index fallback, so the two disagree: a function returning undefined gives every row the id "undefined", while this reports the index — the parent echoes [0] back, "0" !== "undefined", and the toggle looks dead.

One-liner fix in getRowId: String(rowKey(row) ?? index). Then the comment here is accurate and can shrink to // Caller's un-stringified key; getRowId stringifies, so row.id can't be handed back.

[columns, rowSelection],

/*
* Expansion, in antd's shape. Keys are compared as strings because that is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: [0].includes("0") describes a bug that no longer exists in this code (it's a Set of strings now). Reads fine as one line: // Compared as strings: getRowId stringifies, so a caller's key 0 is row.id "0".

);

/*
* antd hides the toggle column for `showExpandColumn: false` — the idiom for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: references HITL from OSS again — rot risk since the plugin can change independently. // showExpandColumn:false = table driven by the caller's own controls. says the same thing.

toggleExpanded(row.id, record, originalRowKey(record, row.index));
};
if (typeof expandIcon === "function") {
return expandIcon({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

antd hands a custom expandIcon { prefixCls, expanded, record, expandable, onExpand }. The standard antd recipe starts with if (!expandable) return <span className="...-spaced" />, which with this call shape renders the spacer for every row. Since the shim already returns null for non-expandable rows above, passing expandable: true here is enough.

colSpan={table.getVisibleLeafColumns().length}
className="p-2"
>
{expandedRowRender(row.original, row.index, 0, true)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: when expandedRowRender returns null (HITL does this when no column is expanded for the row) this still emits an empty <tr><td> strip. Matches antd, so fine to leave — but if you want to be kinder than antd, const content = expandedRowRender(...) and skip the row when content == null. Also className="p-2" duplicates shadcn TableCell's own default.

* button did nothing whatsoever — nested values were unreadable in Table view
* (UN-4124).
*/
describe("DataTable expandable", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage is solid on the controlled path. Untested branches worth a cheap vi.fn() each: function rowKey (+ the originalRowKey round-trip), custom expandIcon (onExpand(record, e) contract), defaultExpandedRowKeys, expandedRowClassName fn + string (app CSS targets .ant-table-expanded-row, nothing pins it), onExpand(false, …) on collapse, stopPropagation vs an onRow.onClick spy, and that the expanded row does not receive onRow props. Also the rowExpandable test could assert the toggle button is hidden too, not just the panel.

The preamble block above duplicates the prop JSDoc + incident narrative; tests are self-describing, so I'd drop it, and drop (UN-4124) from the describe title in antd-structure.test.jsx.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants