Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -909,10 +909,13 @@ nestedTable.isExpanded("node-42")
nestedTable.getExpandedItems()
```

`expand`, `collapse` and `toggle` target rows by item `id` or with a
predicate, and apply to the rows currently rendered. `expandAll` installs a
policy that also applies to rows revealed later, and an explicit `collapse`
always wins over an active policy.
`expand`, `collapse` and `toggle` target rows by their identity or with a
predicate, and apply to the rows currently rendered. The identity is
resolved like everywhere else in the collection: `source.idProvider(item)`
when the source defines one, otherwise the item's `id` (string-normalized,
so `42` and `"42"` are equivalent; identities must be unique among sibling
rows). `expandAll` installs a policy that also applies to rows revealed
later, and an explicit `collapse` always wins over an active policy.

`expandTo` expands a specific path of item ids (ancestor → descendant)
without touching the rest of the tree. Ids not rendered yet stay pending and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,63 @@ describe("Nested table expansion control", () => {
expect(screen.getByText("Child Six")).toBeInTheDocument()
})
})

it("restores the policy's eager load mode when re-expanding via expandTo", async () => {
const pending: Array<() => void> = []
const fetchChildren = vi.fn(
({
item,
pagination,
}: {
item: Person
pagination?: ChildrenPaginationInfo
}) =>
new Promise((resolve) => {
pending.push(() => {
const all = item.children ?? []
const currentPage = (pagination?.currentPage ?? 0) + 1
const start = (currentPage - 1) * CHILDREN_PER_PAGE
resolve({
records: all.slice(start, start + CHILDREN_PER_PAGE),
paginationInfo: {
total: all.length,
perPage: CHILDREN_PER_PAGE,
currentPage,
pagesCount: Math.ceil(all.length / CHILDREN_PER_PAGE),
hasMore: currentPage * CHILDREN_PER_PAGE < all.length,
},
})
})
})
)
const table = renderNestedTable(
{
defaultExpanded: (ctx) => ctx.item.id === "p2",
defaultExpandedChildren: "all",
},
{ fetchChildren } as unknown as Partial<TestSource>
)
await waitForRootRows()
await waitFor(() => expect(fetchChildren).toHaveBeenCalledTimes(1))

// Collapse while page 1 is in flight, then let it land in the cache.
act(() => table.control.collapse("p2"))
await act(async () => {
pending.shift()?.()
})

// Re-expanding via expandTo WITHOUT options must also clear the stale
// collapse marker and restore the policy's eager mode (parity with
// expand/toggle): the remaining page loads instead of "show more".
act(() => table.control.expandTo(["p2"]))
await waitFor(() => expect(fetchChildren).toHaveBeenCalledTimes(2))
await act(async () => {
pending.shift()?.()
})
await waitFor(() => {
expect(screen.getByText("Child Six")).toBeInTheDocument()
})
})
})

describe("user interaction", () => {
Expand Down Expand Up @@ -1086,6 +1143,95 @@ describe("Nested table expansion control", () => {
})
expect(screen.getByText("A1a")).toBeInTheDocument()
})

it("keeps a row expanded when its siblings are reordered by a refetch", async () => {
// Stable filter/navigation references: swapping the dataAdapter below
// must NOT look like a filters change (which resets overrides by
// design) — only the data order changes.
const stableSourceBits = {
currentFilters: {},
currentNavigationFilters: {},
} as unknown as Partial<TestSource>
let api!: {
control: NestedTableController<Person>
setOverrides: (overrides: Partial<TestSource>) => void
}
render(
<MutableSourceHarness
initialOverrides={stableSourceBits}
onApi={(a) => {
api = a
}}
/>
)
await waitForRootRows()

act(() => api.control.expand("p2"))
await waitFor(() => {
expect(screen.getByText("Child Three")).toBeInTheDocument()
})

// A refetch returns the same items in reverse order (no filters or
// sortings change, so no expansion reset is involved): the row keys
// by identity, not by position, so p2 must stay expanded.
act(() =>
api.setOverrides({
dataAdapter: {
fetchData: async () => ({ records: [...tree].reverse() }),
},
} as unknown as Partial<TestSource>)
)
await waitFor(() => {
const parents = screen.getAllByText(/^Parent/)
expect(parents[0]).toHaveTextContent("Parent Two")
})
expect(screen.getByText("Child Three")).toBeInTheDocument()
})

it("targets rows through source.idProvider when the items have no id", async () => {
type PersonByEmail = {
name: string
email: string
children?: PersonByEmail[]
}
const people: PersonByEmail[] = [
{
name: "Alice",
email: "alice@corp.com",
children: [{ name: "Alice Jr", email: "alice.jr@corp.com" }],
},
{
name: "Bob",
email: "bob@corp.com",
children: [{ name: "Bob Jr", email: "bob.jr@corp.com" }],
},
]
const table = renderNestedTable(undefined, {
idProvider: (item: PersonByEmail) => item.email,
dataAdapter: { fetchData: async () => ({ records: people }) },
fetchChildren: ({ item }: { item: PersonByEmail }) => ({
records: item.children ?? [],
paginationInfo: {
total: item.children?.length ?? 0,
perPage: 10,
currentPage: 1,
pagesCount: 1,
hasMore: false,
},
}),
} as unknown as Partial<TestSource>)
await waitFor(() => {
expect(screen.getByText("Bob")).toBeInTheDocument()
})

// The univocal column designated by idProvider is the target identity
act(() => table.control.expand("bob@corp.com"))
await waitFor(() => {
expect(screen.getByText("Bob Jr")).toBeInTheDocument()
})
expect(screen.queryByText("Alice Jr")).not.toBeInTheDocument()
expect(table.control.isExpanded("bob@corp.com")).toBe(true)
})
})

describe("stale fetches and misbehaving adapters", () => {
Expand Down Expand Up @@ -1150,6 +1296,57 @@ describe("Nested table expansion control", () => {
})
})

it("retries a failed children fetch when the search changes", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {})
const fetchChildren = vi.fn(({ search }: { search?: string }) => {
if (search === undefined) return Promise.reject(new Error("boom"))
return Promise.resolve({
records: [{ id: `c-${search}`, name: `Child ${search}` }],
paginationInfo: {
total: 1,
perPage: 2,
currentPage: 1,
pagesCount: 1,
hasMore: false,
},
})
})
let api!: {
control: NestedTableController<Person>
setSearch: (search: string | undefined) => void
}
render(
<SearchOnlyMutableSourceHarness
nested={{ defaultExpanded: (ctx) => ctx.item.id === "p1" }}
fetchChildren={
fetchChildren as unknown as TestSource["fetchChildren"]
}
onApi={(a) => {
api = a
}}
/>
)
await waitForRootRows()
await waitFor(() => expect(fetchChildren).toHaveBeenCalledTimes(1))
// Let the rejection settle into hasError (no retry loop: still 1 call)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(fetchChildren).toHaveBeenCalledTimes(1)

// The search change clears the settled error and must re-arm the
// auto-load guard: the policy-open row retries with the new term
// instead of staying open and empty forever.
act(() => api.setSearch("Grand"))
await waitFor(() => expect(fetchChildren).toHaveBeenCalledTimes(2))
await waitFor(() => {
expect(screen.getByText("Child Grand")).toBeInTheDocument()
})
consoleError.mockRestore()
})

it("stops eager loading when a page adds no records despite hasMore: true", async () => {
const fetchChildren = vi.fn(() => ({
records: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,26 @@ const NestedRowContent = <
const sentinelRef = useRef<HTMLTableCellElement | null>(null)
const addRow = useAddRow()

// Data identity resolved the same way as the rest of the collection
// (Kanban's getKey, item navigation): `source.idProvider` when the source
// defines one, otherwise the item's `id`. Undefined for id-less items,
// which key by position and cannot be targeted by id from the controller.
const itemKey = props.source.idProvider
? String(props.source.idProvider(props.item, props.index))
: "id" in props.item &&
props.item.id !== undefined &&
props.item.id !== null
? String(props.item.id)
: undefined

// Row identity for the expansion overrides, the children cache and the
// controller registry. Prefixed with the parent's rowId (or the group for
// root rows) so equal depth/index positions in different branches or
// groups can never collide — e.g. the first id-less child of two expanded
// parents used to share the same key.
// controller registry: the parent row's id (or the group for roots)
// namespaces the item's own identity, so equal positions in different
// branches or groups can never collide and — unlike a positional key —
// the expansion survives sibling reorders. Identities must be unique
// among siblings; id-less items fall back to their position.
const parentKey = props.nestedRowProps?.parentRowId ?? `g${props.groupIndex}`
const rowId = `${parentKey}/${props.nestedRowProps?.depth ?? 0}-${"id" in props.item ? props.item.id + "-" + props.index : props.index}`
const rowId = `${parentKey}/${itemKey ?? `i${props.index}`}`

const {
setRowExpanded,
Expand Down Expand Up @@ -196,8 +209,8 @@ const NestedRowContent = <
// and onExpandedChange must see current field values, not the first-render
// snapshot. Re-registering on item identity change is two cheap Map ops.
useEffect(
() => registerNestedRow(rowId, props.item, depth),
[registerNestedRow, rowId, props.item, depth]
() => registerNestedRow(rowId, props.item, depth, itemKey),
[registerNestedRow, rowId, props.item, depth, itemKey]
)

/**
Expand Down Expand Up @@ -236,13 +249,21 @@ const NestedRowContent = <
// since `hasFetched` never became true in that case.
const previousHasFetchedRef = useRef(hasFetched)
const previousIsLoadingRef = useRef(isLoading)
const previousHasErrorRef = useRef(hasError)
useEffect(() => {
const fetchInvalidated = previousHasFetchedRef.current && !hasFetched
const fetchCancelled =
previousIsLoadingRef.current && !isLoading && !hasFetched && !hasError
// A filters/search reset also clears a settled error (hasError drops to
// false with nothing fetched and nothing in flight — a new fetch clears
// it too, but with isLoading true): re-arm so a row kept open by a
// policy retries with the new context instead of staying empty forever.
const errorCleared =
previousHasErrorRef.current && !hasError && !hasFetched && !isLoading
previousHasFetchedRef.current = hasFetched
previousIsLoadingRef.current = isLoading
if (fetchInvalidated || fetchCancelled) {
previousHasErrorRef.current = hasError
if (fetchInvalidated || fetchCancelled || errorCleared) {
autoLoadRequestedRef.current = false
}
}, [hasFetched, isLoading, hasError])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export interface NestedTableControllerInternal<
export type NestedRowRegistryEntry<R extends RecordType> = {
item: R
depth: number
/**
* Resolved data identity — `source.idProvider(item)` when the source
* defines one (same convention as Kanban and item navigation), otherwise
* the item's `id`. Controller targets match against it. `undefined` for
* id-less items, which key by position and cannot be targeted by id.
*/
itemKey?: string
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export type NestedExpansionCriteria<R extends RecordType> =

/**
* Identifies the row(s) an imperative operation applies to.
* - `string | number`: matched against the item's `id` property
* - `string | number`: matched against the item's identity —
* `source.idProvider(item)` when the source defines one (same convention
* as the Kanban visualization and item navigation), otherwise the item's
* `id` property. The comparison is string-normalized, so `42` and `"42"`
* are equivalent. Identities must be unique among sibling rows.
* - predicate: matched against every currently rendered expandable row
*
* Only rows that are currently rendered can be targeted — a row hidden under
Expand Down
Loading
Loading