Skip to main content

Collapsible rows

Collapsible rows is not supported natively, but it is fairly easy to implement it, the whole idea is to build a flat array of rows to display and pass that flat array to DSG instead of passing the nested data.

Setup

We first need two states. groups as our nested data, and openedGroups that is an array of indices.

const [groups, setGroups] = useState([])
const [openedGroups, setOpenedGroups] = useState([])

groups is shaped like so, with an array of collapsible `children:

[
{
"name": "Senior Integration Producer",
"children": [
{
"firstName": "Daniella",
"lastName": "Zulauf",
"salary": 2793
},
{
"firstName": "Jackie",
"lastName": "Mraz",
"salary": 2892
},
]
},
{
"name": "Senior Directives Assistant",
"children": [
{
"firstName": "Lucienne",
"lastName": "Mohr",
"salary": 2618
}
]
},
]

We can then create a function to toggle a group's visibility:

// We use useCallback to avoid creating a new function each time
const toggleGroup = useCallback((i) => {
setOpenedGroups((opened) => {
if (opened.includes(i)) {
// Remove i from the array
return opened.filter((x) => x !== i)
}

// Add i to the array
return [...opened, i]
})
}, [])

Flattening rows

Then we need to flatten groups to create the rows that can be used by DSG:

const rows = useMemo(() => {
// We start with an empty array that we'll return at the end
const result = []

// Go over each group
for (let i = 0; i < groups.length; i++) {

result.push(/* Add a row for that group */)

// If group is opened, add one row per child
if (openedGroups.includes(i)) {
for (let j = 0; j < groups[i].children.length; j++) {

result.push(/* Add a row for that child */)
}
}
}

return result
}, [groups, openedGroups]) // Recompute the rows when groups or openedGroups changes

Now the question is, what to put in those rows? First we can add a type key to discriminate between groups and children, and a groupIndex and childIndex (for children only) for easier manipulation later. Then we add any data we want to display, for groups it might look like this:

result.push({
// To discriminate between groups and children
type: 'GROUP',
// Index of the group, just for reference
groupIndex: i,
// Sum of all salaries of children (will not be editable)
salary: groups[i].children.reduce((acc, cur) => acc + (cur.salary ?? 0), 0),
// Whether or not the group is opened, to display a chevron for example
opened: openedGroups.includes(i),
// The name to display to the user
name: groups[i].name,
})

And for children:

result.push({
type: 'CHILD',
// Children have both indices
groupIndex: i,
childIndex: j,
// We spread the firstName, lastName, and salary
...groups[i].children[j],
})

Final result

const [groups, setGroups] = useState([/* Your groups */])
const [openedGroups, setOpenedGroups] = useState([])

// Function to toggle group visibility
const toggleGroup = useCallback((i) => {
setOpenedGroups((opened) => {
if (opened.includes(i)) {
return opened.filter((x) => x !== i)
}

return [...opened, i]
})
}, [])

// Flatten version of groups
const rows = useMemo(() => {
const result = []

for (let i = 0; i < groups.length; i++) {
result.push({
type: 'GROUP',
salary: groups[i].children.reduce((acc, cur) => acc + (cur.salary ?? 0), 0),
groupIndex: i,
opened: openedGroups.includes(i),
name: groups[i].name,
})

if (openedGroups.includes(i)) {
for (let j = 0; j < groups[i].children.length; j++) {
result.push({
type: 'CHILD',
groupIndex: i,
childIndex: j,
...groups[i].children[j],
})
}
}
}

return result
}, [groups, openedGroups])

const handleChange = (newRows, operations) => {
for (const operation of operations) {
if (operation.type === 'UPDATE') {
for (const row of newRows.slice(operation.fromRowIndex, operation.toRowIndex)) {
if (row.type === 'CHILD') {
groups[row.groupIndex].children[row.childIndex] = {
firstName: row.firstName,
lastName: row.lastName,
salary: row.salary,
}
}
}
}

if (operation.type === 'CREATE') {
const groupIndex = newRows[operation.fromRowIndex - 1].groupIndex
const childIndex = newRows[operation.fromRowIndex - 1].childIndex ?? -1

groups[groupIndex].children = [
...groups[groupIndex].children.slice(0, childIndex + 1),
...newRows
.slice(operation.fromRowIndex, operation.toRowIndex)
.map((row) => ({
firstName: row.firstName,
lastName: row.lastName,
salary: row.salary,
})),
...groups[groupIndex].children.slice(childIndex + 1),
]
}

if (operation.type === 'DELETE') {
const deletedRows = rows
.slice(operation.fromRowIndex, operation.toRowIndex)
.reverse()

for (const deletedRow of deletedRows) {
if (deletedRow.type === 'CHILD') {
groups[deletedRow.groupIndex].children.splice(deletedRow.childIndex, 1)
}
}
}
}

setGroups([...groups])
}

return (
<DataSheetGrid<Row>
value={rows}
onChange={handleChange}
columns={[
{
title: 'Group',
disabled: ({ rowData }) => rowData.type === 'CHILD',
isCellEmpty: () => true,
component: ({ rowData, focus, stopEditing }) => {
useEffect(() => {
if (focus) {
toggleGroup(rowData.groupIndex)
stopEditing({ nextRow: false })
}
}, [focus, rowData.groupIndex, stopEditing])

if (rowData.type === 'CHILD') {
return null
}

return (rowData.opened ? '👇' : '👉️') + rowData.name
},
},
{
...keyColumn('firstName', textColumn),
title: 'First name',
disabled: ({ rowData }) => rowData.type === 'GROUP',
},
{
...keyColumn('lastName', textColumn),
title: 'Last name',
disabled: ({ rowData }) => rowData.type === 'GROUP',
},
{
...keyColumn('salary', intColumn),
title: 'Salary',
disabled: ({ rowData }) => rowData.type === 'GROUP',
},
]}
/>
)
Group
First name
Last name
Salary
1
👉️ Global Creative Director
2
👉️ International Infrastructure Planner
3
👉️ Global Research Technician
4
👉️ Dynamic Research Planner
rows