Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions frontend/src/components/data-table/DataTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ import { cn } from "@/lib/utils";
* import it rather than build their own.
*/

/**
* One cell's value, read the way antd reads it.
*
* `dataIndex` is either a key or a PATH: `["product", "name"]` is antd's
* documented nested form and means `record.product.name`. The flat lookup this
* replaces indexed the record with the array itself, and JavaScript stringifies
* that to the property name `"product,name"` — so the value was always
* undefined, and silently so, because a column with no `render` hands it
* straight to the cell. LLMWhisperer's API Keys table declares its Plan column
* exactly that way and lost the whole column to a blank strip.
*/
function cellValue(record, dataIndex) {
if (Array.isArray(dataIndex)) {
return dataIndex.reduce((v, k) => (v == null ? undefined : v[k]), record);
}
return record?.[dataIndex];
}

/**
* One antd column → one TanStack column def.
*
Expand All @@ -53,6 +71,14 @@ import { cn } from "@/lib/utils";
* version used would collide across levels.
*/
function toColumn(c, path) {
/*
* Three places spell this identity, and they must agree: here, `columnKey`
* (ColumnFilter.jsx) and `toSorterInfo` below, which matches on it to answer
* antd's `onChange`. A PATH `dataIndex` with no `key` coerces to
* `"product,name"` in all three, so they still agree — which is why this is
* deliberately NOT normalised to `dataIndex.join(".")`. Tidying it here alone
* would silently stop sorting and filtering reporting for a nested column.
*/
const id = String(c.key ?? c.dataIndex ?? path);
// `column` rides along so the header can render antd's filter affordance,
// which is declared on the antd def and has no TanStack equivalent.
Expand All @@ -76,7 +102,31 @@ function toColumn(c, path) {

return {
id,
accessorKey: c.dataIndex,
/*
* A path needs an accessor FUNCTION: TanStack's `accessorKey` is a single
* key, so handing it the array repeats the same stringified-`"product,name"`
* lookup one layer down.
*
* This is NOT inert. TanStack defaults every column to `sortUndefined: 1`,
* and `getSortedRowModel` reads `row.getValue()` to apply it BEFORE it ever
* consults `sortingFn` — so undefined-valued rows sort to the end even
* though `sortingFn` is `() => 0`. A nested column was accidentally exempt
* while every one of its values was undefined; with a real accessor it now
* behaves exactly as an equivalent string column already does (verified:
* both reorder identically on a sparse column). That quirk is pre-existing
* and cross-cutting, not introduced here, and no nested column declares a
* `sorter` today.
*
* The string case keeps `accessorKey` because it is unchanged, not because
* its behaviour is right: TanStack deep-reads a DOTTED string while the
* cell below reads it as a literal key, which is antd's own reading. Those
* two disagree for `dataIndex: "a.b"`. Pre-existing on both halves, no
* literal dotted `dataIndex` exists in either repo, and reconciling it is a
* behaviour change beyond this fix.
*/
...(Array.isArray(c.dataIndex)
? { accessorFn: (record) => cellValue(record, c.dataIndex) }
: { accessorKey: c.dataIndex }),
header: c.title,
enableSorting: Boolean(c.sorter),
/*
Expand All @@ -95,7 +145,9 @@ function toColumn(c, path) {
: () => 0,
meta,
cell: ({ row }) => {
const value = c.dataIndex ? row.original?.[c.dataIndex] : undefined;
const value = c.dataIndex
? cellValue(row.original, c.dataIndex)
: undefined;
// antd's render(value, record, index) contract.
return c.render ? c.render(value, row.original, row.index) : value;
},
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/components/data-table/DataTable.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1521,3 +1521,128 @@ describe("DataTable controlled filters", () => {
});
});
});

/**
* antd's `dataIndex` is either a key or a PATH — `["product", "name"]` reads
* `record.product.name`. The flat lookup this guards indexed the record with
* the array itself, which JavaScript stringifies to the property name
* `"product,name"`, so the value was always undefined and — because a column
* with no `render` hands it straight to the cell — silently blank.
*
* LLMWhisperer's API Keys table declares its Plan column exactly this way and
* lost the whole column to an empty strip after the migration.
*/
describe("DataTable nested dataIndex", () => {
const nested = [
{ id: 1, product: { id: "free", name: "LLM Whisperer Free" } },
];

it("resolves an array dataIndex as a path into the record", () => {
render(
<DataTable
columns={[
{
title: "Plan",
dataIndex: ["product", "name"],
key: "product_name",
},
]}
dataSource={nested}
rowKey="id"
/>,
);
expect(screen.getByText("LLM Whisperer Free")).toBeInTheDocument();
});

it("renders an empty cell rather than throwing on a missing segment", () => {
expect(() =>
render(
<DataTable
columns={[
{
title: "Plan",
dataIndex: ["product", "name"],
key: "product_name",
},
]}
dataSource={[{ id: 1, product: null }, { id: 2 }]}
rowKey="id"
/>,
),
).not.toThrow();
// Two rows, both with an EMPTY Plan cell — not the empty state, and not a
// stand-in like "undefined" or "N/A" that a laxer resolver would print.
const cells = document.querySelectorAll("tbody td");
expect(cells).toHaveLength(2);
for (const cell of cells) {
expect(cell).toHaveTextContent("");
}
});

it("walks a path of any depth, including an array index", () => {
render(
<DataTable
columns={[
{
title: "Owner",
dataIndex: ["subscription", "owners", 0, "email"],
key: "owner",
},
]}
dataSource={[
{ id: 1, subscription: { owners: [{ email: "a@example.com" }] } },
]}
rowKey="id"
/>,
);
expect(screen.getByText("a@example.com")).toBeInTheDocument();
});

/*
* The identity a nested column reports back through antd's `onChange`.
*
* With no `key`, the column id and `toSorterInfo`'s lookup both coerce the
* array to `"product,name"` — agreeing only because NEITHER normalises it.
* Normalising the id alone (to `"product.name"`, say) is exactly the tidy-up
* a later reader would attempt, and it would silently stop a nested column
* reporting its sort, with every other test here still green.
*/
it("reports a keyless nested column through onChange when sorted", async () => {
const onChange = vi.fn();
render(
<DataTable
columns={[
{ title: "Plan", dataIndex: ["product", "name"], sorter: true },
]}
dataSource={nested}
rowKey="id"
onChange={onChange}
/>,
);
await userEvent.click(screen.getByText("Plan"));
expect(onChange).toHaveBeenCalled();
const sorter = onChange.mock.calls.at(-1)[2];
expect(sorter.field).toEqual(["product", "name"]);
expect(sorter.order).toBe("ascend");
});

it("hands the resolved nested value to render, as antd does", () => {
const renderCell = vi.fn((value) => `plan: ${value}`);
render(
<DataTable
columns={[
{
title: "Plan",
dataIndex: ["product", "name"],
key: "product_name",
render: renderCell,
},
]}
dataSource={nested}
rowKey="id"
/>,
);
expect(renderCell).toHaveBeenCalledWith("LLM Whisperer Free", nested[0], 0);
expect(screen.getByText("plan: LLM Whisperer Free")).toBeInTheDocument();
});
});
Loading