diff --git a/src/components/Layout/mdx/NestedTable/NestedTableContext.tsx b/src/components/Layout/mdx/NestedTable/NestedTableContext.tsx index fa9be9ddcd..505380f3e3 100644 --- a/src/components/Layout/mdx/NestedTable/NestedTableContext.tsx +++ b/src/components/Layout/mdx/NestedTable/NestedTableContext.tsx @@ -5,7 +5,8 @@ export interface TableProperty { required?: 'required' | 'optional'; // Optional - not present in 2 or 3 column tables description: ReactNode; // ReactNode to preserve markdown elements (links, lists, etc.) type?: ReactNode; // ReactNode to preserve markdown elements (links, etc.) - not present in 2 column tables - typeReference?: string; // ID of referenced table, if type is a table reference + typeReferences: string[]; // IDs of all referenced tables (empty array if none) + typeDisplay?: ReactNode; // Cleaned-up display for the type cell (Table elements replaced with their ID text) } export interface TableData { @@ -61,7 +62,8 @@ export const NestedTableProvider: React.FC = ({ childr (prop, i) => prop.name !== data.properties[i]?.name || prop.required !== data.properties[i]?.required || - prop.typeReference !== data.properties[i]?.typeReference, + prop.typeReferences.length !== data.properties[i]?.typeReferences.length || + prop.typeReferences.some((ref, j) => ref !== data.properties[i]?.typeReferences[j]), ); if (hasChanged) { registryRef.current.set(id, data); diff --git a/src/components/Layout/mdx/NestedTable/NestedTablePropertyRow.tsx b/src/components/Layout/mdx/NestedTable/NestedTablePropertyRow.tsx index 7fc0948394..836de3ee94 100644 --- a/src/components/Layout/mdx/NestedTable/NestedTablePropertyRow.tsx +++ b/src/components/Layout/mdx/NestedTable/NestedTablePropertyRow.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import cn from '@ably/ui/core/utils/cn'; -import { TableProperty, useNestedTable } from './NestedTableContext'; +import { TableData, TableProperty, useNestedTable } from './NestedTableContext'; import { NestedTableExpandButton } from './NestedTableExpandButton'; interface NestedTablePropertyRowProps { @@ -12,12 +12,14 @@ interface NestedTablePropertyRowProps { export const NestedTablePropertyRow: React.FC = ({ property, path, depth = 0 }) => { const { lookup, isExpanded, toggleExpanded, registryVersion } = useNestedTable(); const expandPath = `${path}.${property.name}`; - const expanded = isExpanded(expandPath); - // Look up the referenced table, re-computing when registry changes - const referencedTable = useMemo( - () => (property.typeReference ? lookup(property.typeReference) : undefined), - [property.typeReference, lookup, registryVersion], + // Look up all referenced tables, re-computing when registry changes + const referencedTables = useMemo( + () => + property.typeReferences + .map((ref) => ({ id: ref, table: lookup(ref) })) + .filter((entry): entry is { id: string; table: TableData } => entry.table !== undefined), + [property.typeReferences, lookup, registryVersion], ); return ( @@ -42,10 +44,10 @@ export const NestedTablePropertyRow: React.FC = ({ )} - {/* Type name - only shown for 3 or 4-column tables. Use typeReference if available for cleaner display. */} - {(property.typeReference || property.type) && ( + {/* Type name - use typeDisplay for cleaned-up rendering, fall back to raw type */} + {(property.typeReferences.length > 0 || property.type) && ( - {property.typeReference ?? property.type} + {property.typeDisplay ?? property.type} )} @@ -55,39 +57,40 @@ export const NestedTablePropertyRow: React.FC = ({ {property.description} - {/* Expand/collapse button and nested content */} - {referencedTable && property.typeReference && ( - <> - {/* Collapsed: standalone button */} - {!expanded && ( - toggleExpanded(expandPath)} - /> - )} + {/* Expand/collapse buttons and nested content - one per resolved table reference */} + {referencedTables.map(({ id, table }) => { + const refExpandPath = `${expandPath}.${id}`; + const refExpanded = isExpanded(refExpandPath); - {/* Expanded: button attached to nested container */} - {expanded && ( -
- {/* Hide button as header of the container */} - toggleExpanded(expandPath)} - /> - {/* Nested properties */} -
- {referencedTable.properties.map((nestedProperty) => ( -
- -
- ))} + return ( + + {/* Collapsed: standalone button */} + {!refExpanded && ( + toggleExpanded(refExpandPath)} /> + )} + + {/* Expanded: button attached to nested container */} + {refExpanded && ( +
+ {/* Make the button the header of the container */} + toggleExpanded(refExpandPath)} + /> + {/* Nested properties */} +
+ {table.properties.map((nestedProperty) => ( +
+ +
+ ))} +
-
- )} - - )} + )} + + ); + })}
); diff --git a/src/components/Layout/mdx/NestedTable/parseTable.test.tsx b/src/components/Layout/mdx/NestedTable/parseTable.test.tsx new file mode 100644 index 0000000000..4d386c8b6e --- /dev/null +++ b/src/components/Layout/mdx/NestedTable/parseTable.test.tsx @@ -0,0 +1,339 @@ +import React, { createElement } from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { parseTableChildren } from './parseTable'; +import { NestedTablePropertyRow } from './NestedTablePropertyRow'; +import { NestedTableProvider, TableProperty, useNestedTable } from './NestedTableContext'; + +/** + * Mock component that simulates what MDX produces for . + * getTypeName() checks displayName, which must be 'Table' or 'NestedTable'. + */ +const TableRef: React.FC<{ id?: string }> = () => null; +TableRef.displayName = 'NestedTable'; + +/** + * Builds a
element tree matching what MDX markdown tables produce. + * Each inner array is a row of cell contents. + */ +function buildTableElement(rows: React.ReactNode[][]) { + const colCount = rows[0]?.length ?? 0; + return createElement( + 'table', + null, + createElement( + 'thead', + null, + createElement( + 'tr', + null, + Array.from({ length: colCount }, (_, i) => createElement('th', { key: i }, `Header ${i}`)), + ), + ), + createElement( + 'tbody', + null, + rows.map((cells, rowIdx) => + createElement( + 'tr', + { key: rowIdx }, + cells.map((cell, cellIdx) => createElement('td', { key: cellIdx }, cell)), + ), + ), + ), + ); +} + +describe('parseTableChildren', () => { + describe('type reference extraction', () => { + it('extracts no references from plain text types', () => { + const table = buildTableElement([['name', 'Required', 'A description', 'String']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual([]); + expect(result[0].typeDisplay).toBeUndefined(); + }); + + it('extracts a single Table element reference', () => { + const table = buildTableElement([ + ['name', 'Required', 'A description', createElement(TableRef, { id: 'PresenceData' })], + ]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual(['PresenceData']); + }); + + it('extracts a single reference alongside plain text', () => { + const typeCell = [createElement(TableRef, { id: 'PresenceData', key: 'ref' }), ' or String']; + const table = buildTableElement([['name', 'Required', 'A description', typeCell]]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual(['PresenceData']); + }); + + it('extracts two Table element references', () => { + const typeCell = [ + createElement(TableRef, { id: 'TypeA', key: 'a' }), + ' or ', + createElement(TableRef, { id: 'TypeB', key: 'b' }), + ]; + const table = buildTableElement([['name', 'Required', 'A description', typeCell]]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual(['TypeA', 'TypeB']); + }); + + it('extracts three Table element references', () => { + const typeCell = [ + createElement(TableRef, { id: 'TypeA', key: 'a' }), + ' or ', + createElement(TableRef, { id: 'TypeB', key: 'b' }), + ' or ', + createElement(TableRef, { id: 'TypeC', key: 'c' }), + ]; + const table = buildTableElement([['name', 'Required', 'A description', typeCell]]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual(['TypeA', 'TypeB', 'TypeC']); + }); + + it('extracts an implicit PascalCase reference when it is the sole type', () => { + const table = buildTableElement([['name', 'A description', 'RoomOptions']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual(['RoomOptions']); + }); + + it('does not extract implicit references from non-matching text', () => { + const table = buildTableElement([['name', 'A description', 'string']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual([]); + }); + + it('sets empty typeReferences for 2-column tables', () => { + const table = buildTableElement([['name', 'A description']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].typeReferences).toEqual([]); + expect(result[0].type).toBeUndefined(); + }); + }); + + describe('typeDisplay', () => { + it('is undefined when there are no references', () => { + const table = buildTableElement([['name', 'Required', 'A description', 'String']]); + const result = parseTableChildren(table); + + expect(result[0].typeDisplay).toBeUndefined(); + }); + + it('is the ID string for a single Table reference', () => { + const table = buildTableElement([ + ['name', 'Required', 'A description', createElement(TableRef, { id: 'TypeA' })], + ]); + const result = parseTableChildren(table); + + expect(result[0].typeDisplay).toBe('TypeA'); + }); + + it('preserves text alongside Table references', () => { + const typeCell = [createElement(TableRef, { id: 'TypeA', key: 'ref' }), ' or String']; + const table = buildTableElement([['name', 'Required', 'A description', typeCell]]); + const result = parseTableChildren(table); + + expect(result[0].typeDisplay).toEqual(['TypeA', ' or String']); + }); + + it('replaces multiple Table elements with their IDs', () => { + const typeCell = [ + createElement(TableRef, { id: 'TypeA', key: 'a' }), + ' or ', + createElement(TableRef, { id: 'TypeB', key: 'b' }), + ]; + const table = buildTableElement([['name', 'Required', 'A description', typeCell]]); + const result = parseTableChildren(table); + + expect(result[0].typeDisplay).toEqual(['TypeA', ' or ', 'TypeB']); + }); + }); + + describe('column format handling', () => { + it('parses 4-column tables with required field', () => { + const table = buildTableElement([['myProp', 'Required', 'A description', 'String']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('myProp'); + expect(result[0].required).toBe('required'); + }); + + it('parses 4-column tables with optional field', () => { + const table = buildTableElement([['myProp', 'Optional', 'A description', 'String']]); + const result = parseTableChildren(table); + + expect(result[0].required).toBe('optional'); + }); + + it('parses 3-column tables without required field', () => { + const table = buildTableElement([['myProp', 'A description', 'Number']]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('myProp'); + expect(result[0].required).toBeUndefined(); + }); + + it('parses multiple rows independently', () => { + const table = buildTableElement([ + ['prop1', 'Required', 'First prop', 'String'], + ['prop2', 'Optional', 'Second prop', createElement(TableRef, { id: 'TypeA' })], + ]); + const result = parseTableChildren(table); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('prop1'); + expect(result[0].typeReferences).toEqual([]); + expect(result[1].name).toBe('prop2'); + expect(result[1].typeReferences).toEqual(['TypeA']); + }); + }); +}); + +describe('NestedTablePropertyRow', () => { + /** + * Helper component that registers a table in the NestedTable context. + * Uses useEffect to mirror how real NestedTable components register. + */ + const RegisterTable: React.FC<{ id: string; properties: TableProperty[] }> = ({ id, properties }) => { + const { register } = useNestedTable(); + React.useEffect(() => { + register(id, { id, properties }); + }, [id, properties, register]); + return null; + }; + + const typeAProperties: TableProperty[] = [{ name: 'fieldA', description: 'Field A description', typeReferences: [] }]; + const typeBProperties: TableProperty[] = [{ name: 'fieldB', description: 'Field B description', typeReferences: [] }]; + + it('renders expand buttons for each resolved type reference', async () => { + const property: TableProperty = { + name: 'myProp', + description: 'A test property', + typeReferences: ['TypeA', 'TypeB'], + typeDisplay: 'TypeA or TypeB', + }; + + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Show TypeA/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show TypeB/ })).toBeInTheDocument(); + }); + }); + + it('expands each reference independently', async () => { + const property: TableProperty = { + name: 'myProp', + description: 'A test property', + typeReferences: ['TypeA', 'TypeB'], + typeDisplay: 'TypeA or TypeB', + }; + + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Show TypeA/ })).toBeInTheDocument(); + }); + + // Expand TypeA + fireEvent.click(screen.getByRole('button', { name: /Show TypeA/ })); + + // TypeA is now expanded (Hide), TypeB is still collapsed (Show) + expect(screen.getByRole('button', { name: /Hide TypeA/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show TypeB/ })).toBeInTheDocument(); + + // The expanded section shows TypeA's nested property + expect(screen.getByText('fieldA')).toBeInTheDocument(); + // TypeB's property is not visible + expect(screen.queryByText('fieldB')).not.toBeInTheDocument(); + }); + + it('only renders buttons for references that resolve to registered tables', async () => { + const property: TableProperty = { + name: 'myProp', + description: 'A test property', + typeReferences: ['TypeA', 'UnregisteredType'], + typeDisplay: 'TypeA or UnregisteredType', + }; + + render( + + + {/* UnregisteredType is intentionally NOT registered */} + + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Show TypeA/ })).toBeInTheDocument(); + }); + + // No button for the unregistered type + expect(screen.queryByRole('button', { name: /Show UnregisteredType/ })).not.toBeInTheDocument(); + }); + + it('renders no buttons when there are no type references', () => { + const property: TableProperty = { + name: 'myProp', + description: 'A simple property', + type: 'String', + typeReferences: [], + }; + + render( + + + , + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('displays typeDisplay text when available', async () => { + const property: TableProperty = { + name: 'myProp', + description: 'A test property', + typeReferences: ['TypeA'], + typeDisplay: 'TypeA or String', + }; + + render( + + + + , + ); + + expect(screen.getByText('TypeA or String')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Layout/mdx/NestedTable/parseTable.ts b/src/components/Layout/mdx/NestedTable/parseTable.ts index b4d80cae7b..1bd30ccffa 100644 --- a/src/components/Layout/mdx/NestedTable/parseTable.ts +++ b/src/components/Layout/mdx/NestedTable/parseTable.ts @@ -1,4 +1,4 @@ -import { ReactNode, ReactElement, Children, isValidElement } from 'react'; +import { ReactNode, ReactElement, Children, isValidElement, cloneElement } from 'react'; import { TableProperty } from './NestedTableContext'; import { Table as BaseTable } from '../Table'; @@ -9,12 +9,6 @@ import { Table as BaseTable } from '../Table'; */ const TYPE_REFERENCE_PATTERN = /^[A-Z][a-zA-Z0-9]*(Options|Config|Settings|Data|Info|Params|Type|Enum)$/; -/** - * Pattern for explicit table reference syntax in markdown cells. - * Matches:
or
- */ -const EXPLICIT_TABLE_REFERENCE_PATTERN = //i; - /** * Extracts text content from React children recursively */ @@ -50,21 +44,20 @@ function getTypeName(element: ReactElement): string { } /** - * Finds a Table element in children and returns its id prop + * Finds all Table elements in children and returns their id props */ -function findTableElementId(children: ReactNode): string | undefined { +function findAllTableElementIds(children: ReactNode): string[] { + const ids: string[] = []; + if (!children) { - return undefined; + return ids; } if (Array.isArray(children)) { for (const child of children) { - const result = findTableElementId(child); - if (result) { - return result; - } + ids.push(...findAllTableElementIds(child)); } - return undefined; + return ids; } if (isValidElement(children)) { @@ -73,48 +66,98 @@ function findTableElementId(children: ReactNode): string | undefined { // Check if this is a Table element with an id if ((typeName === 'Table' || typeName === 'NestedTable') && element.props.id) { - return element.props.id; + ids.push(element.props.id); } // Recurse into children - return findTableElementId(element.props.children); + ids.push(...findAllTableElementIds(element.props.children)); } - return undefined; + return ids; } /** - * Checks if a type cell contains a Table reference. + * Finds all Table references in type text. * Supports two syntaxes: - * 1. Explicit:
or
- * 2. Implicit: PascalCase names ending in recognized suffixes (see TYPE_REFERENCE_PATTERN) + * 1. Explicit:
or
(may appear multiple times) + * 2. Implicit: PascalCase names ending in recognized suffixes (only when entire text is one type name) */ -function extractTableReference(typeText: string): string | undefined { +function extractAllTableReferences(typeText: string): string[] { const trimmed = typeText.trim(); - // Check for explicit
syntax (rendered as text in markdown cells) - const explicitMatch = trimmed.match(EXPLICIT_TABLE_REFERENCE_PATTERN); - if (explicitMatch) { - return explicitMatch[1]; + // Check for explicit
syntax (may appear multiple times) + const matches = [...trimmed.matchAll(//gi)]; + if (matches.length > 0) { + return matches.map((m) => m[1]); } - // Check for implicit type name reference + // Fallback: implicit type name reference (only when the entire text is one type name) if (TYPE_REFERENCE_PATTERN.test(trimmed)) { - return trimmed; + return [trimmed]; + } + + return []; +} + +/** + * Walks React children and replaces Table/NestedTable elements with their ID as plain text. + * Produces a clean display like "PresenceData or String" from mixed React children. + */ +function buildTypeDisplay(children: ReactNode): ReactNode { + if (!children) { + return children; + } + + if (typeof children === 'string' || typeof children === 'number') { + return children; + } + + if (Array.isArray(children)) { + return children.map((child, i) => { + const result = buildTypeDisplay(child); + if (isValidElement(result)) { + return cloneElement(result, { key: i }); + } + return result; + }); + } + + if (isValidElement(children)) { + const element = children as ReactElement<{ id?: string; children?: ReactNode }>; + const typeName = getTypeName(element); + + // Replace Table/NestedTable elements with their ID as plain text + if ((typeName === 'Table' || typeName === 'NestedTable') && element.props.id) { + return element.props.id; + } + + // For other elements, recurse into their children + if (element.props.children) { + const newChildren = buildTypeDisplay(element.props.children); + return cloneElement(element, {}, newChildren); + } } - return undefined; + return children; } /** * Extracts type information from a table cell's children. - * Returns both the ReactNode for display and any detected type reference. + * Returns the ReactNode for display, all detected type references, and a cleaned-up display. */ -function extractTypeInfo(cellChildren: ReactNode): { type: ReactNode; typeReference: string | undefined } { +function extractTypeInfo(cellChildren: ReactNode): { + type: ReactNode; + typeReferences: string[]; + typeDisplay: ReactNode | undefined; +} { const typeText = extractText(cellChildren).trim(); - const tableElementId = findTableElementId(cellChildren); - const typeReference = tableElementId || extractTableReference(typeText); - return { type: cellChildren, typeReference }; + const elementIds = findAllTableElementIds(cellChildren); + const typeReferences = elementIds.length > 0 ? elementIds : extractAllTableReferences(typeText); + + // Build a cleaned-up display if there are references that need replacing + const typeDisplay = typeReferences.length > 0 ? buildTypeDisplay(cellChildren) : undefined; + + return { type: cellChildren, typeReferences, typeDisplay }; } /** @@ -208,14 +251,15 @@ export function parseTableChildren(children: ReactNode): TableProperty[] { const name = extractText(nameCell?.props?.children); const requiredText = extractText(requiredCell?.props?.children).toLowerCase(); const description = descriptionCell?.props?.children; - const { type, typeReference } = extractTypeInfo(typeCell?.props?.children); + const { type, typeReferences, typeDisplay } = extractTypeInfo(typeCell?.props?.children); properties.push({ name: name.trim(), required: requiredText.includes('required') ? 'required' : 'optional', description, type, - typeReference, + typeReferences, + typeDisplay, }); } else if (cells.length === 3) { // 3 columns: Name | Description | Type @@ -225,13 +269,14 @@ export function parseTableChildren(children: ReactNode): TableProperty[] { const name = extractText(nameCell?.props?.children); const description = descriptionCell?.props?.children; - const { type, typeReference } = extractTypeInfo(typeCell?.props?.children); + const { type, typeReferences, typeDisplay } = extractTypeInfo(typeCell?.props?.children); properties.push({ name: name.trim(), description, type, - typeReference, + typeReferences, + typeDisplay, }); } else if (cells.length === 2) { // 2 columns: Name | Description @@ -244,6 +289,7 @@ export function parseTableChildren(children: ReactNode): TableProperty[] { properties.push({ name: name.trim(), description, + typeReferences: [], }); } });