Skip to content

Commit 8cfee33

Browse files
committed
fix(tree): Support tree table
1 parent d8900b3 commit 8cfee33

File tree

8 files changed

+305
-79
lines changed

8 files changed

+305
-79
lines changed
Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,31 @@
11
import React, { ReactNode } from 'react';
22
import {
3-
Table,
4-
TableProps,
5-
Tbody,
6-
Td,
73
TdProps,
8-
Th,
9-
Thead,
104
ThProps,
11-
Tr,
125
TrProps
136
} from '@patternfly/react-table';
14-
import { useInternalContext } from '../InternalContext';
7+
import { DataViewTableTree, DataViewTableTreeProps } from '../DataViewTableTree';
8+
import { DataViewTableBasic, DataViewTableBasicProps } from '../DataViewTableBasic';
159

10+
// Table header typings
1611
export type DataViewTh = ReactNode | { cell: ReactNode; props?: ThProps };
12+
export const isDataViewThObject = (value: DataViewTh): value is { cell: ReactNode; props?: ThProps } => value != null && typeof value === 'object' && 'cell' in value;
13+
14+
// Basic table typings
15+
export interface DataViewTrObject { row: DataViewTd[], id?: string, props?: TrProps }
1716
export type DataViewTd = ReactNode | { cell: ReactNode; props?: TdProps };
18-
export type DataViewTr = DataViewTd[] | { row: DataViewTd[], id?: string, props?: TrProps };
17+
export type DataViewTr = DataViewTd[] | DataViewTrObject;
1918

20-
export const isDataViewThObject = (value: DataViewTh): value is { cell: ReactNode; props?: ThProps } => value != null && typeof value === 'object' && 'cell' in value;
2119
export const isDataViewTdObject = (value: DataViewTd): value is { cell: ReactNode; props?: TdProps } => value != null && typeof value === 'object' && 'cell' in value;
2220
export const isDataViewTrObject = (value: DataViewTr): value is { row: DataViewTd[], id?: string } => value != null && typeof value === 'object' && 'row' in value;
2321

22+
// Tree table typings
23+
export interface DataViewTrTree extends DataViewTrObject { id: string, children?: DataViewTrTree[] }
2424

25-
export interface DataViewTableProps extends Omit<TableProps, 'onSelect' | 'rows'> {
26-
/** Columns definition */
27-
columns: DataViewTh[];
28-
/** Current page rows */
29-
rows: DataViewTr[];
30-
/** Custom OUIA ID */
31-
ouiaId?: string;
32-
}
33-
34-
export const DataViewTable: React.FC<DataViewTableProps> = ({
35-
columns,
36-
rows,
37-
ouiaId = 'DataViewTable',
38-
...props
39-
}: DataViewTableProps) => {
40-
const { selection } = useInternalContext();
41-
const { onSelect, isSelected, isSelectDisabled } = selection ?? {};
25+
export type DataViewTableProps = DataViewTableBasicProps | DataViewTableTreeProps;
4226

43-
return (
44-
<Table aria-label="Data table" ouiaId={ouiaId} {...props}>
45-
<Thead data-ouia-component-id={`${ouiaId}-thead`}>
46-
<Tr ouiaId={`${ouiaId}-tr-head`}>
47-
{onSelect && isSelected && <Th key="row-select" />}
48-
{columns.map((column, index) => (
49-
<Th
50-
key={index}
51-
{...(isDataViewThObject(column) && (column?.props ?? {}))}
52-
data-ouia-component-id={`${ouiaId}-th-${index}`}
53-
>
54-
{isDataViewThObject(column) ? column.cell : column}
55-
</Th>
56-
))}
57-
</Tr>
58-
</Thead>
59-
<Tbody>
60-
{rows.map((row, rowIndex) => {
61-
const rowIsObject = isDataViewTrObject(row);
62-
return (
63-
<Tr key={rowIndex} ouiaId={`${ouiaId}-tr-${rowIndex}`} {...(rowIsObject && (row?.props ?? {}))}>
64-
{onSelect && isSelected && (
65-
<Td
66-
key={`select-${rowIndex}`}
67-
select={{
68-
rowIndex,
69-
onSelect: (_event, isSelecting) => {
70-
onSelect?.(isSelecting, rowIsObject ? row : [ row ])
71-
},
72-
isSelected: isSelected?.(row) || false,
73-
isDisabled: isSelectDisabled?.(row) || false,
74-
}}
75-
/>
76-
)}
77-
{(rowIsObject ? row.row : row).map((cell, colIndex) => {
78-
const cellIsObject = isDataViewTdObject(cell);
79-
return (
80-
<Td
81-
key={colIndex}
82-
{...(cellIsObject && (cell?.props ?? {}))}
83-
data-ouia-component-id={`${ouiaId}-td-${rowIndex}-${colIndex}`}
84-
>
85-
{cellIsObject ? cell.cell : cell}
86-
</Td>
87-
)
88-
})}
89-
</Tr>
90-
)})}
91-
</Tbody>
92-
</Table>
93-
);
94-
};
27+
export const DataViewTable: React.FC<DataViewTableProps> = ({ isTreeTable, ...props }: DataViewTableProps) => (
28+
isTreeTable ? (<DataViewTableTree {...(props as DataViewTableTreeProps)} />) : (<DataViewTableBasic {...(props as DataViewTableBasicProps)}/>)
29+
);
9530

9631
export default DataViewTable;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from 'react';
2+
import {
3+
Table,
4+
TableProps,
5+
Tbody,
6+
Td,
7+
Tr,
8+
} from '@patternfly/react-table';
9+
import { useInternalContext } from '../InternalContext';
10+
import { DataViewTableHeader } from '../DataViewTableHeader';
11+
import { DataViewTh, DataViewTr, isDataViewTdObject, isDataViewTrObject } from '../DataViewTable';
12+
13+
export interface DataViewTableBasicProps extends Omit<TableProps, 'onSelect' | 'rows'> {
14+
/** Columns definition */
15+
columns: DataViewTh[];
16+
/** Current page rows */
17+
rows: DataViewTr[];
18+
/** Custom OUIA ID */
19+
ouiaId?: string;
20+
}
21+
22+
export const DataViewTableBasic: React.FC<DataViewTableBasicProps> = ({
23+
columns,
24+
rows,
25+
ouiaId = 'DataViewTableBasic',
26+
...props
27+
}: DataViewTableBasicProps) => {
28+
const { selection } = useInternalContext();
29+
const { onSelect, isSelected, isSelectDisabled } = selection ?? {};
30+
31+
return (
32+
<Table aria-label="Data table" ouiaId={ouiaId} {...props}>
33+
<DataViewTableHeader columns={columns} ouiaId={ouiaId} />
34+
<Tbody>
35+
{rows.map((row, rowIndex) => {
36+
const rowIsObject = isDataViewTrObject(row);
37+
return (
38+
<Tr key={rowIndex} ouiaId={`${ouiaId}-tr-${rowIndex}`} {...(rowIsObject && (row?.props ?? {}))}>
39+
{onSelect && isSelected && (
40+
<Td
41+
key={`select-${rowIndex}`}
42+
select={{
43+
rowIndex,
44+
onSelect: (_event, isSelecting) => {
45+
onSelect?.(isSelecting, rowIsObject ? row : [ row ])
46+
},
47+
isSelected: isSelected?.(row) || false,
48+
isDisabled: isSelectDisabled?.(row) || false,
49+
}}
50+
/>
51+
)}
52+
{(rowIsObject ? row.row : row).map((cell, colIndex) => {
53+
const cellIsObject = isDataViewTdObject(cell);
54+
return (
55+
<Td
56+
key={colIndex}
57+
{...(cellIsObject && (cell?.props ?? {}))}
58+
data-ouia-component-id={`${ouiaId}-td-${rowIndex}-${colIndex}`}
59+
>
60+
{cellIsObject ? cell.cell : cell}
61+
</Td>
62+
)
63+
})}
64+
</Tr>
65+
)})}
66+
</Tbody>
67+
</Table>
68+
);
69+
};
70+
71+
export default DataViewTableBasic;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewTableBasic';
2+
export * from './DataViewTableBasic';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react';
2+
import {
3+
Th,
4+
Thead,
5+
TheadProps,
6+
Tr
7+
} from '@patternfly/react-table';
8+
import { useInternalContext } from '../InternalContext';
9+
import { DataViewTh, isDataViewThObject } from '../DataViewTable';
10+
11+
export interface DataViewTableHeaderProps extends TheadProps {
12+
/** Indicates whether table is a tree */
13+
isTreeTable?: boolean;
14+
/** Columns definition */
15+
columns: DataViewTh[];
16+
/** Custom OUIA ID */
17+
ouiaId?: string;
18+
}
19+
20+
export const DataViewTableHeader: React.FC<DataViewTableHeaderProps> = ({
21+
isTreeTable = false,
22+
columns,
23+
ouiaId = 'DataViewTableHeader',
24+
...props
25+
}: DataViewTableHeaderProps) => {
26+
const { selection } = useInternalContext();
27+
const { onSelect, isSelected } = selection ?? {};
28+
29+
return (
30+
<Thead data-ouia-component-id={`${ouiaId}-thead`} {...props}>
31+
<Tr ouiaId={`${ouiaId}-tr-head`}>
32+
{onSelect && isSelected && !isTreeTable && <Th key="row-select" screenReaderText='Data selection table header cell' />}
33+
{columns.map((column, index) => (
34+
<Th
35+
key={index}
36+
{...(isDataViewThObject(column) && (column?.props ?? {}))}
37+
data-ouia-component-id={`${ouiaId}-th-${index}`}
38+
>
39+
{isDataViewThObject(column) ? column.cell : column}
40+
</Th>
41+
))}
42+
</Tr>
43+
</Thead>
44+
);
45+
};
46+
47+
export default DataViewTableHeader;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewTableHeader';
2+
export * from './DataViewTableHeader';
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React from 'react';
2+
import {
3+
Table,
4+
TableProps,
5+
Tbody,
6+
Td,
7+
TdProps,
8+
TreeRowWrapper,
9+
} from '@patternfly/react-table';
10+
import { useInternalContext } from '../InternalContext';
11+
import { DataViewTableHeader } from '../DataViewTableHeader';
12+
import { DataViewTh, DataViewTrTree, isDataViewTdObject } from '../DataViewTable';
13+
14+
export interface DataViewTableTreeProps extends Omit<TableProps, 'onSelect' | 'rows'> {
15+
/** Columns definition */
16+
columns: DataViewTh[];
17+
/** Current page rows */
18+
rows: DataViewTrTree[];
19+
/** Optinal icon for the leaf rows */
20+
leafIcon?: React.ReactNode;
21+
/** Optinal icon for the expanded parent rows */
22+
expandedIcon?: React.ReactNode;
23+
/** Optinal icon for the collapsed parent rows */
24+
collapsedIcon?: React.ReactNode;
25+
/** Custom OUIA ID */
26+
ouiaId?: string;
27+
}
28+
29+
export const DataViewTableTree: React.FC<DataViewTableTreeProps> = ({
30+
columns,
31+
rows,
32+
leafIcon = null,
33+
expandedIcon = null,
34+
collapsedIcon = null,
35+
ouiaId = 'DataViewTableTree',
36+
...props
37+
}: DataViewTableTreeProps) => {
38+
const { selection } = useInternalContext();
39+
const { onSelect, isSelected, isSelectDisabled } = selection ?? {};
40+
const [ expandedNodeIds, setExpandedNodeIds ] = React.useState<string[]>([]);
41+
const [ expandedDetailsNodeNames, setExpandedDetailsNodeIds ] = React.useState<string[]>([]);
42+
43+
const getDescendants = (node: DataViewTrTree): DataViewTrTree[] => {
44+
if (!node.children || !node.children.length) {
45+
return [ node ];
46+
} else {
47+
let children: DataViewTrTree[] = [];
48+
node.children.forEach((child) => {
49+
children = [ ...children, ...getDescendants(child) ];
50+
});
51+
return children;
52+
}
53+
};
54+
55+
const areAllDescendantsSelected = (node: DataViewTrTree) => getDescendants(node).every((n) => isSelected?.(n));
56+
const areSomeDescendantsSelected = (node: DataViewTrTree) => getDescendants(node).some((n) => isSelected?.(n));
57+
58+
const isNodeChecked = (node: DataViewTrTree) => {
59+
if (areAllDescendantsSelected(node)) {
60+
return true;
61+
}
62+
if (areSomeDescendantsSelected(node)) {
63+
return null;
64+
}
65+
return false;
66+
};
67+
68+
/**
69+
Recursive function which flattens the data into an array of flattened TreeRowWrapper components
70+
params:
71+
- nodes - array of a single level of tree nodes
72+
- level - number representing how deeply nested the current row is
73+
- posinset - position of the row relative to this row's siblings
74+
- currentRowIndex - position of the row relative to the entire table
75+
- isHidden - defaults to false, true if this row's parent is expanded
76+
*/
77+
const renderRows = (
78+
[ node, ...remainingNodes ]: DataViewTrTree[],
79+
level = 1,
80+
posinset = 1,
81+
rowIndex = 0,
82+
isHidden = false
83+
): React.ReactNode[] => {
84+
if (!node) {
85+
return [];
86+
}
87+
const isExpanded = expandedNodeIds.includes(node.id);
88+
const isDetailsExpanded = expandedDetailsNodeNames.includes(node.id);
89+
const isChecked = isNodeChecked(node);
90+
let icon = leafIcon;
91+
if (node.children) {
92+
icon = isExpanded ? expandedIcon : collapsedIcon;
93+
}
94+
95+
const treeRow: TdProps['treeRow'] = {
96+
onCollapse: () =>
97+
setExpandedNodeIds((prevExpanded) => {
98+
const otherExpandedNodeIds = prevExpanded.filter((id) => id !== node.id);
99+
return isExpanded ? otherExpandedNodeIds : [ ...otherExpandedNodeIds, node.id ];
100+
}),
101+
onToggleRowDetails: () =>
102+
setExpandedDetailsNodeIds((prevDetailsExpanded) => {
103+
const otherDetailsExpandedNodeIds = prevDetailsExpanded.filter((id) => id !== node.id);
104+
return isDetailsExpanded ? otherDetailsExpandedNodeIds : [ ...otherDetailsExpandedNodeIds, node.id ];
105+
}),
106+
onCheckChange: (isSelectDisabled?.(node) || !onSelect) ? undefined : (_event, isChecking) => onSelect?.(isChecking, getDescendants(node)),
107+
rowIndex,
108+
props: {
109+
isExpanded,
110+
isDetailsExpanded,
111+
isHidden,
112+
'aria-level': level,
113+
'aria-posinset': posinset,
114+
'aria-setsize': node.children?.length ?? 0,
115+
isChecked,
116+
ouiaId: `${ouiaId}-tree-toggle-${node.id}`,
117+
checkboxId: `checkbox_id_${node.id?.toLowerCase().replace(/\s+/g, '_')}`,
118+
icon,
119+
}
120+
};
121+
122+
const childRows =
123+
node.children && node.children.length
124+
? renderRows(node.children, level + 1, 1, rowIndex + 1, !isExpanded || isHidden)
125+
: [];
126+
127+
return [
128+
<TreeRowWrapper key={node.id} row={{ props: treeRow.props }}>
129+
{node.row.map((cell, colIndex) => {
130+
const cellIsObject = isDataViewTdObject(cell);
131+
return (
132+
<Td
133+
key={colIndex}
134+
treeRow={colIndex === 0 ? treeRow : undefined}
135+
{...(cellIsObject && (cell?.props ?? {}))}
136+
data-ouia-component-id={`${ouiaId}-td-${rowIndex}-${colIndex}`}
137+
>
138+
{cellIsObject ? cell.cell : cell}
139+
</Td>
140+
)
141+
})}
142+
</TreeRowWrapper>,
143+
...childRows,
144+
...renderRows(remainingNodes, level, posinset + 1, rowIndex + 1 + childRows.length, isHidden)
145+
];
146+
};
147+
148+
return (
149+
<Table isTreeTable aria-label="Data table" ouiaId={ouiaId} {...props}>
150+
<DataViewTableHeader isTreeTable columns={columns} ouiaId={ouiaId} />
151+
<Tbody>
152+
{renderRows(rows)}
153+
</Tbody>
154+
</Table>
155+
);
156+
};
157+
158+
export default DataViewTableTree;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewTableTree';
2+
export * from './DataViewTableTree';

0 commit comments

Comments
 (0)