From bb30d3a0da932e3c5113aab45ceb98a2ef7fac5e Mon Sep 17 00:00:00 2001 From: Bilal Shafi Date: Fri, 20 Sep 2024 06:52:09 +0500 Subject: [PATCH] [DataGrid] Row spanning (#14124) Signed-off-by: Bilal Shafi Co-authored-by: Sycamore <71297412+samuelsycamore@users.noreply.github.com> --- .../getting-started/getting-started.md | 2 +- .../data-grid/row-spanning/RowSpanning.js | 130 +++++++ .../data-grid/row-spanning/RowSpanning.tsx | 130 +++++++ .../row-spanning/RowSpanningCalender.js | 152 ++++++++ .../row-spanning/RowSpanningCalender.tsx | 163 ++++++++ .../RowSpanningCalender.tsx.preview | 15 + .../row-spanning/RowSpanningClassSchedule.js | 158 ++++++++ .../row-spanning/RowSpanningClassSchedule.tsx | 160 ++++++++ .../row-spanning/RowSpanningCustom.js | 95 +++++ .../row-spanning/RowSpanningCustom.tsx | 95 +++++ .../RowSpanningCustom.tsx.preview | 14 + .../data-grid/row-spanning/row-spanning.md | 51 ++- docs/data/pages.ts | 2 +- .../x/api/data-grid/data-grid-premium.json | 3 +- docs/pages/x/api/data-grid/data-grid-pro.json | 3 +- docs/pages/x/api/data-grid/data-grid.json | 3 +- .../x/api/data-grid/grid-actions-col-def.json | 1 + docs/pages/x/api/data-grid/grid-col-def.json | 1 + .../data-grid/grid-single-select-col-def.json | 1 + .../data-grid-premium/data-grid-premium.json | 3 + .../data-grid-pro/data-grid-pro.json | 3 + .../data-grid/data-grid/data-grid.json | 3 + .../data-grid/grid-actions-col-def.json | 3 + .../api-docs/data-grid/grid-col-def.json | 3 + .../data-grid/grid-single-select-col-def.json | 3 + .../src/DataGridPremium/DataGridPremium.tsx | 5 + .../useDataGridPremiumComponent.tsx | 4 + .../src/DataGridPro/DataGridPro.tsx | 5 + .../DataGridPro/useDataGridProComponent.tsx | 4 + .../x-data-grid/src/DataGrid/DataGrid.tsx | 5 + .../src/DataGrid/useDataGridComponent.tsx | 6 + .../src/DataGrid/useDataGridProps.ts | 1 + .../src/components/cell/GridCell.tsx | 28 +- .../useGridKeyboardNavigation.ts | 97 ++--- .../features/keyboardNavigation/utils.ts | 85 +++++ .../features/rows/gridRowSpanningSelectors.ts | 19 + .../features/rows/gridRowSpanningUtils.ts | 64 ++++ .../hooks/features/rows/useGridRowSpanning.ts | 350 ++++++++++++++++++ .../virtualization/useGridVirtualScroller.tsx | 14 +- packages/x-data-grid/src/internals/index.ts | 4 + .../src/models/colDef/gridColDef.ts | 4 + .../src/models/gridStateCommunity.ts | 2 + .../src/models/props/DataGridProps.ts | 5 + .../src/tests/rowSpanning.DataGrid.test.tsx | 251 +++++++++++++ 44 files changed, 2070 insertions(+), 80 deletions(-) create mode 100644 docs/data/data-grid/row-spanning/RowSpanning.js create mode 100644 docs/data/data-grid/row-spanning/RowSpanning.tsx create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCalender.js create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCalender.tsx create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCalender.tsx.preview create mode 100644 docs/data/data-grid/row-spanning/RowSpanningClassSchedule.js create mode 100644 docs/data/data-grid/row-spanning/RowSpanningClassSchedule.tsx create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCustom.js create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCustom.tsx create mode 100644 docs/data/data-grid/row-spanning/RowSpanningCustom.tsx.preview create mode 100644 packages/x-data-grid/src/hooks/features/keyboardNavigation/utils.ts create mode 100644 packages/x-data-grid/src/hooks/features/rows/gridRowSpanningSelectors.ts create mode 100644 packages/x-data-grid/src/hooks/features/rows/gridRowSpanningUtils.ts create mode 100644 packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts create mode 100644 packages/x-data-grid/src/tests/rowSpanning.DataGrid.test.tsx diff --git a/docs/data/data-grid/getting-started/getting-started.md b/docs/data/data-grid/getting-started/getting-started.md index f3820ef2c15ec..421cc64832d59 100644 --- a/docs/data/data-grid/getting-started/getting-started.md +++ b/docs/data/data-grid/getting-started/getting-started.md @@ -188,7 +188,7 @@ The enterprise components come in two plans: Pro and Premium. | [Column pinning](/x/react-data-grid/column-pinning/) | ❌ | ✅ | ✅ | | **Row** | | | | | [Row height](/x/react-data-grid/row-height/) | ✅ | ✅ | ✅ | -| [Row spanning](/x/react-data-grid/row-spanning/) | 🚧 | 🚧 | 🚧 | +| [Row spanning](/x/react-data-grid/row-spanning/) | ✅ | ✅ | ✅ | | [Row reordering](/x/react-data-grid/row-ordering/) | ❌ | ✅ | ✅ | | [Row pinning](/x/react-data-grid/row-pinning/) | ❌ | ✅ | ✅ | | **Selection** | | | | diff --git a/docs/data/data-grid/row-spanning/RowSpanning.js b/docs/data/data-grid/row-spanning/RowSpanning.js new file mode 100644 index 0000000000000..109566ed72271 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanning.js @@ -0,0 +1,130 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid } from '@mui/x-data-grid'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; + +export default function RowSpanning() { + const [enabled, setEnabled] = React.useState(true); + + return ( + + setEnabled(event.target.checked)} + control={} + label="Enable row spanning" + /> + + + + + ); +} + +const columns = [ + { + field: 'code', + headerName: 'Item Code', + width: 85, + cellClassName: ({ row }) => (row.summaryRow ? 'bold' : ''), + }, + { + field: 'description', + headerName: 'Description', + width: 170, + }, + { + field: 'quantity', + headerName: 'Quantity', + width: 80, + // Do not span the values + rowSpanValueGetter: () => null, + }, + { + field: 'unitPrice', + headerName: 'Unit Price', + type: 'number', + valueFormatter: (value) => (value ? `$${value}.00` : ''), + }, + { + field: 'totalPrice', + headerName: 'Total Price', + type: 'number', + valueGetter: (value, row) => value ?? row?.unitPrice, + valueFormatter: (value) => `$${value}.00`, + cellClassName: ({ row }) => (row.summaryRow ? 'bold' : ''), + }, +]; + +const rows = [ + { + id: 1, + code: 'A101', + description: 'Wireless Mouse', + quantity: 2, + unitPrice: 50, + totalPrice: 100, + }, + { + id: 2, + code: 'A102', + description: 'Mechanical Keyboard', + quantity: 1, + unitPrice: 75, + }, + { + id: 3, + code: 'A103', + description: 'USB Dock Station', + quantity: 1, + unitPrice: 400, + }, + { + id: 4, + code: 'A104', + description: 'Laptop', + quantity: 1, + unitPrice: 1800, + totalPrice: 2050, + }, + { + id: 5, + code: 'A104', + description: '- 16GB RAM Upgrade', + quantity: 1, + unitPrice: 100, + totalPrice: 2050, + }, + { + id: 6, + code: 'A104', + description: '- 512GB SSD Upgrade', + quantity: 1, + unitPrice: 150, + totalPrice: 2050, + }, + { + id: 7, + code: 'TOTAL', + totalPrice: 2625, + summaryRow: true, + }, +]; diff --git a/docs/data/data-grid/row-spanning/RowSpanning.tsx b/docs/data/data-grid/row-spanning/RowSpanning.tsx new file mode 100644 index 0000000000000..f9ac1d6204460 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanning.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; + +export default function RowSpanning() { + const [enabled, setEnabled] = React.useState(true); + + return ( + + setEnabled((event.target as HTMLInputElement).checked)} + control={} + label="Enable row spanning" + /> + + + + + ); +} + +const columns: GridColDef<(typeof rows)[number]>[] = [ + { + field: 'code', + headerName: 'Item Code', + width: 85, + cellClassName: ({ row }) => (row.summaryRow ? 'bold' : ''), + }, + { + field: 'description', + headerName: 'Description', + width: 170, + }, + { + field: 'quantity', + headerName: 'Quantity', + width: 80, + // Do not span the values + rowSpanValueGetter: () => null, + }, + { + field: 'unitPrice', + headerName: 'Unit Price', + type: 'number', + valueFormatter: (value) => (value ? `$${value}.00` : ''), + }, + { + field: 'totalPrice', + headerName: 'Total Price', + type: 'number', + valueGetter: (value, row) => value ?? row?.unitPrice, + valueFormatter: (value) => `$${value}.00`, + cellClassName: ({ row }) => (row.summaryRow ? 'bold' : ''), + }, +]; + +const rows = [ + { + id: 1, + code: 'A101', + description: 'Wireless Mouse', + quantity: 2, + unitPrice: 50, + totalPrice: 100, + }, + { + id: 2, + code: 'A102', + description: 'Mechanical Keyboard', + quantity: 1, + unitPrice: 75, + }, + { + id: 3, + code: 'A103', + description: 'USB Dock Station', + quantity: 1, + unitPrice: 400, + }, + { + id: 4, + code: 'A104', + description: 'Laptop', + quantity: 1, + unitPrice: 1800, + totalPrice: 2050, + }, + { + id: 5, + code: 'A104', + description: '- 16GB RAM Upgrade', + quantity: 1, + unitPrice: 100, + totalPrice: 2050, + }, + { + id: 6, + code: 'A104', + description: '- 512GB SSD Upgrade', + quantity: 1, + unitPrice: 150, + totalPrice: 2050, + }, + { + id: 7, + code: 'TOTAL', + totalPrice: 2625, + summaryRow: true, + }, +]; diff --git a/docs/data/data-grid/row-spanning/RowSpanningCalender.js b/docs/data/data-grid/row-spanning/RowSpanningCalender.js new file mode 100644 index 0000000000000..30aef71a55d11 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCalender.js @@ -0,0 +1,152 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid } from '@mui/x-data-grid'; + +const slotTimesLookup = { + 0: '09:00 - 10:00', + 1: '10:00 - 11:00', + 2: '11:00 - 12:00', + 3: '12:00 - 13:00', + 4: '13:00 - 14:00', + 5: '14:00 - 15:00', + 6: '15:00 - 16:00', + 7: '16:00 - 17:00', +}; + +const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + +const rows = [ + { + id: 0, + time: slotTimesLookup[0], + slots: ['Maths', 'Chemistry', 'Physics', 'Music', 'Maths'], + }, + { + id: 1, + time: slotTimesLookup[1], + slots: ['English', 'Chemistry', 'English', 'Music', 'Dance'], + }, + { + id: 2, + time: slotTimesLookup[2], + slots: ['English', 'Chemistry', 'Maths', 'Chemistry', 'Dance'], + }, + { + id: 3, + time: slotTimesLookup[3], + slots: ['Lab', 'Physics', 'Maths', 'Chemistry', 'Physics'], + }, + { + id: 4, + time: slotTimesLookup[4], + slots: ['', '', '', '', ''], + }, + { + id: 5, + time: slotTimesLookup[5], + slots: ['Lab', 'Maths', 'Chemistry', 'Chemistry', 'English'], + }, + { + id: 6, + time: slotTimesLookup[6], + slots: ['Music', 'Lab', 'Chemistry', 'English', ''], + }, + { + id: 7, + time: slotTimesLookup[7], + slots: ['Music', 'Dance', '', 'English', ''], + }, +]; + +const slotColumnCommonFields = { + sortable: false, + filterable: false, + pinnable: false, + hideable: false, + cellClassName: (params) => params.value, +}; + +const columns = [ + { + field: 'time', + headerName: 'Time', + width: 120, + }, + { + field: '0', + headerName: days[0], + valueGetter: (value, row) => row?.slots[0], + ...slotColumnCommonFields, + }, + { + field: '1', + headerName: days[1], + valueGetter: (value, row) => row?.slots[1], + ...slotColumnCommonFields, + }, + { + field: '2', + headerName: days[2], + valueGetter: (value, row) => row?.slots[2], + ...slotColumnCommonFields, + }, + { + field: '3', + headerName: days[3], + valueGetter: (value, row) => row?.slots[3], + ...slotColumnCommonFields, + }, + { + field: '4', + headerName: days[4], + valueGetter: (value, row) => row?.slots[4], + ...slotColumnCommonFields, + }, +]; + +const rootStyles = { + width: '100%', + '& .Maths': { + backgroundColor: 'rgba(157, 255, 118, 0.49)', + }, + '& .English': { + backgroundColor: 'rgba(255, 255, 10, 0.49)', + }, + '& .Lab': { + backgroundColor: 'rgba(150, 150, 150, 0.49)', + }, + '& .Chemistry': { + backgroundColor: 'rgba(255, 150, 150, 0.49)', + }, + '& .Physics': { + backgroundColor: 'rgba(10, 150, 255, 0.49)', + }, + '& .Music': { + backgroundColor: 'rgba(224, 183, 60, 0.55)', + }, + '& .Dance': { + backgroundColor: 'rgba(200, 150, 255, 0.49)', + }, +}; + +export default function RowSpanningCalender() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx b/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx new file mode 100644 index 0000000000000..368f4c4de9b9d --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx @@ -0,0 +1,163 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; + +const slotTimesLookup = { + 0: '09:00 - 10:00', + 1: '10:00 - 11:00', + 2: '11:00 - 12:00', + 3: '12:00 - 13:00', + 4: '13:00 - 14:00', + 5: '14:00 - 15:00', + 6: '15:00 - 16:00', + 7: '16:00 - 17:00', +}; + +const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + +type Subject = + | 'Maths' + | 'English' + | 'Lab' + | 'Chemistry' + | 'Physics' + | 'Music' + | 'Dance'; + +type Row = { id: number; time: string; slots: Array }; + +const rows: Array = [ + { + id: 0, + time: slotTimesLookup[0], + slots: ['Maths', 'Chemistry', 'Physics', 'Music', 'Maths'], + }, + { + id: 1, + time: slotTimesLookup[1], + slots: ['English', 'Chemistry', 'English', 'Music', 'Dance'], + }, + { + id: 2, + time: slotTimesLookup[2], + slots: ['English', 'Chemistry', 'Maths', 'Chemistry', 'Dance'], + }, + { + id: 3, + time: slotTimesLookup[3], + slots: ['Lab', 'Physics', 'Maths', 'Chemistry', 'Physics'], + }, + { + id: 4, + time: slotTimesLookup[4], + slots: ['', '', '', '', ''], + }, + { + id: 5, + time: slotTimesLookup[5], + slots: ['Lab', 'Maths', 'Chemistry', 'Chemistry', 'English'], + }, + { + id: 6, + time: slotTimesLookup[6], + slots: ['Music', 'Lab', 'Chemistry', 'English', ''], + }, + { + id: 7, + time: slotTimesLookup[7], + slots: ['Music', 'Dance', '', 'English', ''], + }, +]; + +const slotColumnCommonFields: Partial = { + sortable: false, + filterable: false, + pinnable: false, + hideable: false, + cellClassName: (params) => params.value, +}; + +const columns: GridColDef[] = [ + { + field: 'time', + headerName: 'Time', + width: 120, + }, + { + field: '0', + headerName: days[0], + valueGetter: (value, row) => row?.slots[0], + ...slotColumnCommonFields, + }, + { + field: '1', + headerName: days[1], + valueGetter: (value, row) => row?.slots[1], + ...slotColumnCommonFields, + }, + { + field: '2', + headerName: days[2], + valueGetter: (value, row) => row?.slots[2], + ...slotColumnCommonFields, + }, + { + field: '3', + headerName: days[3], + valueGetter: (value, row) => row?.slots[3], + ...slotColumnCommonFields, + }, + { + field: '4', + headerName: days[4], + valueGetter: (value, row) => row?.slots[4], + ...slotColumnCommonFields, + }, +]; + +const rootStyles = { + width: '100%', + '& .Maths': { + backgroundColor: 'rgba(157, 255, 118, 0.49)', + }, + '& .English': { + backgroundColor: 'rgba(255, 255, 10, 0.49)', + }, + '& .Lab': { + backgroundColor: 'rgba(150, 150, 150, 0.49)', + }, + '& .Chemistry': { + backgroundColor: 'rgba(255, 150, 150, 0.49)', + }, + '& .Physics': { + backgroundColor: 'rgba(10, 150, 255, 0.49)', + }, + '& .Music': { + backgroundColor: 'rgba(224, 183, 60, 0.55)', + }, + '& .Dance': { + backgroundColor: 'rgba(200, 150, 255, 0.49)', + }, +}; + +export default function RowSpanningCalender() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx.preview b/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx.preview new file mode 100644 index 0000000000000..bba8d8d1ef972 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCalender.tsx.preview @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.js b/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.js new file mode 100644 index 0000000000000..4d0881c9c34ef --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.js @@ -0,0 +1,158 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid } from '@mui/x-data-grid'; + +const rows = [ + { + id: 0, + day: 'Monday', + time: '9:00 AM - 10:30 AM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Midterm exam', + }, + { + id: 1, + day: 'Monday', + time: '10:30 AM - 12:00 PM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Midterm exam', + }, + { + id: 2, + day: 'Tuesday', + time: '9:00 AM - 10:30 AM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Practical and lab work', + }, + { + id: 3, + day: 'Tuesday', + time: '10:30 AM - 12:00 PM', + course: 'Introduction to Biology', + instructor: 'Dr. Johnson', + room: 'Room 107', + notes: 'Lab session', + }, + { + id: 4, + day: 'Wednesday', + time: '9:00 AM - 10:30 AM', + course: 'Computer Science 101', + instructor: 'Dr. Lee', + room: 'Room 303', + notes: 'Class', + }, + { + id: 5, + day: 'Wednesday', + time: '10:30 AM - 12:00 PM', + course: 'Computer Science 101', + instructor: 'Dr. Lee', + room: 'Room 303', + notes: 'Lab session', + }, + { + id: 6, + day: 'Thursday', + time: '9:00 AM - 11:00 AM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Discussion', + }, + { + id: 7, + day: 'Thursday', + time: '11:00 AM - 12:30 PM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Discussion', + }, + { + id: 8, + day: 'Friday', + time: '9:00 AM - 11:00 AM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Submission', + }, + { + id: 9, + day: 'Friday', + time: '11:00 AM - 12:30 PM', + course: 'Literature & Composition', + instructor: 'Prof. Adams', + room: 'Lecture Hall 1', + notes: 'Reading Assignment', + }, +]; + +const columns = [ + { + field: 'day', + headerName: 'Day', + }, + { + field: 'time', + headerName: 'Time', + minWidth: 160, + }, + { + field: 'course', + headerName: 'Course', + minWidth: 140, + colSpan: 2, + valueGetter: (_, row) => `${row?.course} (${row?.instructor})`, + cellClassName: 'course-instructor--cell', + }, + { + field: 'instructor', + headerName: 'Instructor', + minWidth: 140, + hideable: false, + }, + { + field: 'room', + headerName: 'Room', + minWidth: 120, + }, + { + field: 'notes', + headerName: 'Notes', + minWidth: 180, + }, +]; + +export default function RowSpanningClassSchedule() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.tsx b/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.tsx new file mode 100644 index 0000000000000..44e3e28e7c032 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningClassSchedule.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; + +const rows = [ + { + id: 0, + day: 'Monday', + time: '9:00 AM - 10:30 AM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Midterm exam', + }, + { + id: 1, + day: 'Monday', + time: '10:30 AM - 12:00 PM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Midterm exam', + }, + { + id: 2, + day: 'Tuesday', + time: '9:00 AM - 10:30 AM', + course: 'Advanced Mathematics', + instructor: 'Dr. Smith', + room: 'Room 101', + notes: 'Practical and lab work', + }, + { + id: 3, + day: 'Tuesday', + time: '10:30 AM - 12:00 PM', + course: 'Introduction to Biology', + instructor: 'Dr. Johnson', + room: 'Room 107', + notes: 'Lab session', + }, + { + id: 4, + day: 'Wednesday', + time: '9:00 AM - 10:30 AM', + course: 'Computer Science 101', + instructor: 'Dr. Lee', + room: 'Room 303', + notes: 'Class', + }, + { + id: 5, + day: 'Wednesday', + time: '10:30 AM - 12:00 PM', + course: 'Computer Science 101', + instructor: 'Dr. Lee', + room: 'Room 303', + notes: 'Lab session', + }, + { + id: 6, + day: 'Thursday', + time: '9:00 AM - 11:00 AM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Discussion', + }, + + { + id: 7, + day: 'Thursday', + time: '11:00 AM - 12:30 PM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Discussion', + }, + + { + id: 8, + day: 'Friday', + time: '9:00 AM - 11:00 AM', + course: 'Physics II', + instructor: 'Dr. Carter', + room: 'Room 104', + notes: 'Project Submission', + }, + { + id: 9, + day: 'Friday', + time: '11:00 AM - 12:30 PM', + course: 'Literature & Composition', + instructor: 'Prof. Adams', + room: 'Lecture Hall 1', + notes: 'Reading Assignment', + }, +]; + +const columns: GridColDef[] = [ + { + field: 'day', + headerName: 'Day', + }, + { + field: 'time', + headerName: 'Time', + minWidth: 160, + }, + { + field: 'course', + headerName: 'Course', + minWidth: 140, + colSpan: 2, + valueGetter: (_, row) => `${row?.course} (${row?.instructor})`, + cellClassName: 'course-instructor--cell', + }, + { + field: 'instructor', + headerName: 'Instructor', + minWidth: 140, + hideable: false, + }, + { + field: 'room', + headerName: 'Room', + minWidth: 120, + }, + { + field: 'notes', + headerName: 'Notes', + minWidth: 180, + }, +]; + +export default function RowSpanningClassSchedule() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/row-spanning/RowSpanningCustom.js b/docs/data/data-grid/row-spanning/RowSpanningCustom.js new file mode 100644 index 0000000000000..695063fd8165a --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCustom.js @@ -0,0 +1,95 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid } from '@mui/x-data-grid'; + +export default function RowSpanningCustom() { + return ( + + + + ); +} + +const columns = [ + { + field: 'name', + headerName: 'Name', + width: 200, + editable: true, + }, + { + field: 'designation', + headerName: 'Designation', + width: 200, + editable: true, + }, + { + field: 'department', + headerName: 'Department', + width: 150, + editable: true, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 100, + valueFormatter: (value) => { + return `${value} yo`; + }, + rowSpanValueGetter: (value, row) => { + return row ? `${row.name}-${row.age}` : value; + }, + }, +]; + +const rows = [ + { + id: 1, + name: 'George Floyd', + designation: 'React Engineer', + department: 'Engineering', + age: 25, + }, + { + id: 2, + name: 'George Floyd', + designation: 'Technical Interviewer', + department: 'Human resource', + age: 25, + }, + { + id: 3, + name: 'Cynthia Duke', + designation: 'Technical Team Lead', + department: 'Engineering', + age: 25, + }, + { + id: 4, + name: 'Jordyn Black', + designation: 'React Engineer', + department: 'Engineering', + age: 31, + }, + { + id: 5, + name: 'Rene Glass', + designation: 'Ops Lead', + department: 'Operations', + age: 31, + }, +]; diff --git a/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx b/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx new file mode 100644 index 0000000000000..431a49aa7151e --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; + +export default function RowSpanningCustom() { + return ( + + + + ); +} + +const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + width: 200, + editable: true, + }, + { + field: 'designation', + headerName: 'Designation', + width: 200, + editable: true, + }, + { + field: 'department', + headerName: 'Department', + width: 150, + editable: true, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 100, + valueFormatter: (value) => { + return `${value} yo`; + }, + rowSpanValueGetter: (value, row) => { + return row ? `${row.name}-${row.age}` : value; + }, + }, +]; + +const rows = [ + { + id: 1, + name: 'George Floyd', + designation: 'React Engineer', + department: 'Engineering', + age: 25, + }, + { + id: 2, + name: 'George Floyd', + designation: 'Technical Interviewer', + department: 'Human resource', + age: 25, + }, + { + id: 3, + name: 'Cynthia Duke', + designation: 'Technical Team Lead', + department: 'Engineering', + age: 25, + }, + { + id: 4, + name: 'Jordyn Black', + designation: 'React Engineer', + department: 'Engineering', + age: 31, + }, + { + id: 5, + name: 'Rene Glass', + designation: 'Ops Lead', + department: 'Operations', + age: 31, + }, +]; diff --git a/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx.preview b/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx.preview new file mode 100644 index 0000000000000..37f9489e83ea0 --- /dev/null +++ b/docs/data/data-grid/row-spanning/RowSpanningCustom.tsx.preview @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/row-spanning/row-spanning.md b/docs/data/data-grid/row-spanning/row-spanning.md index 7640695d86d6c..ab59f7680148d 100644 --- a/docs/data/data-grid/row-spanning/row-spanning.md +++ b/docs/data/data-grid/row-spanning/row-spanning.md @@ -1,18 +1,53 @@ -# Data Grid - Row spanning 🚧 +# Data Grid - Row spanning -

Span cells across several columns.

+

Span cells across several rows.

+ +By default, each cell in a Data Grid takes up the height of one row. +The row spanning feature makes it possible for a cell to fill multiple rows in a single column. + +To enable, pass the `unstable_rowSpanning` prop to the Data Grid. +The Data Grid will automatically merge consecutive cells with repeating values in the same column, as shown in the demo below—switch off the toggle button to see the actual rows: + +{{"demo": "RowSpanning.js", "bg": "inline", "defaultCodeOpen": false}} + +:::info +In this demo, the `quantity` column has been deliberately excluded from the row spanning computation using the `colDef.rowSpanValueGetter` prop. + +See the [Customizing row-spanning cells](#customizing-row-spanning-cells) section for more details. +::: :::warning -This feature isn't implemented yet. It's coming. +Row spanning works by increasing the height of the spanned cell by a factor of `rowHeight`—it won't work properly with a variable or dynamic height. +::: + +## Customizing row-spanning cells + +You can customize how row spanning works using two props: -👍 Upvote [issue #207](https://github.com/mui/mui-x/issues/207) if you want to see it land faster. +- `colDef.rowSpanValueGetter`: Controls which values are used for row spanning +- `colDef.valueGetter`: Controls both the row spanning logic and the cell value -Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this component, or if you are facing a pain point with your current solution. +This lets you prevent unwanted row spanning when there are repeating values that shouldn't be merged. + +In the following example, `rowSpanValueGetter` is used to avoid merging `age` cells that don't belong to the same person. + +{{"demo": "RowSpanningCustom.js", "bg": "inline", "defaultCodeOpen": false}} + +## Usage with column spanning + +Row spanning can be used in conjunction with column spanning to create cells that span multiple rows and columns simultaneously, as shown in the demo below: + +{{"demo": "RowSpanningClassSchedule.js", "bg": "inline", "defaultCodeOpen": false}} + +:::warning +Row spanning works well with features like [sorting](/x/react-data-grid/sorting/) and [filtering](/x/react-data-grid/filtering/), but be sure to check that everything works as expected when using it with [column spanning](/x/react-data-grid/column-spanning/). ::: -Each cell takes up the width of one row. -Row spanning lets you change this default behavior, so cells can span multiple rows. -This is very close to the "row spanning" in an HTML ``. +## Demo + +The demo below recreates the calendar from the [column spanning documentation](/x/react-data-grid/column-spanning/#function-signature) using the row spanning feature: + +{{"demo": "RowSpanningCalender.js", "bg": "inline", "defaultCodeOpen": false}} ## API diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 68a022f3d6aa1..8761f4cb80c31 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -57,7 +57,7 @@ const pages: MuiPage[] = [ { pathname: '/x/react-data-grid/row-definition' }, { pathname: '/x/react-data-grid/row-updates' }, { pathname: '/x/react-data-grid/row-height' }, - { pathname: '/x/react-data-grid/row-spanning', planned: true }, + { pathname: '/x/react-data-grid/row-spanning', newFeature: true }, { pathname: '/x/react-data-grid/master-detail', plan: 'pro' }, { pathname: '/x/react-data-grid/row-ordering', plan: 'pro' }, { pathname: '/x/react-data-grid/row-pinning', plan: 'pro' }, diff --git a/docs/pages/x/api/data-grid/data-grid-premium.json b/docs/pages/x/api/data-grid/data-grid-premium.json index d3fdac4d0e2b8..45e11992cbcd2 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -628,7 +628,8 @@ "additionalInfo": { "sx": true } }, "throttleRowsMs": { "type": { "name": "number" }, "default": "0" }, - "treeData": { "type": { "name": "bool" }, "default": "false" } + "treeData": { "type": { "name": "bool" }, "default": "false" }, + "unstable_rowSpanning": { "type": { "name": "bool" }, "default": "false" } }, "name": "DataGridPremium", "imports": [ diff --git a/docs/pages/x/api/data-grid/data-grid-pro.json b/docs/pages/x/api/data-grid/data-grid-pro.json index 8cb47ee673117..7c256c8d9de69 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -559,7 +559,8 @@ "additionalInfo": { "sx": true } }, "throttleRowsMs": { "type": { "name": "number" }, "default": "0" }, - "treeData": { "type": { "name": "bool" }, "default": "false" } + "treeData": { "type": { "name": "bool" }, "default": "false" }, + "unstable_rowSpanning": { "type": { "name": "bool" }, "default": "false" } }, "name": "DataGridPro", "imports": [ diff --git a/docs/pages/x/api/data-grid/data-grid.json b/docs/pages/x/api/data-grid/data-grid.json index 49024a68e2dca..0c69e19f5e830 100644 --- a/docs/pages/x/api/data-grid/data-grid.json +++ b/docs/pages/x/api/data-grid/data-grid.json @@ -468,7 +468,8 @@ "description": "Array<func
| object
| bool>
| func
| object" }, "additionalInfo": { "sx": true } - } + }, + "unstable_rowSpanning": { "type": { "name": "bool" }, "default": "false" } }, "name": "DataGrid", "imports": [ diff --git a/docs/pages/x/api/data-grid/grid-actions-col-def.json b/docs/pages/x/api/data-grid/grid-actions-col-def.json index 26d5acce67f3b..eece855816ed8 100644 --- a/docs/pages/x/api/data-grid/grid-actions-col-def.json +++ b/docs/pages/x/api/data-grid/grid-actions-col-def.json @@ -89,6 +89,7 @@ "isProPlan": true }, "resizable": { "type": { "description": "boolean" }, "default": "true" }, + "rowSpanValueGetter": { "type": { "description": "GridValueGetter<R, V, F>" } }, "sortable": { "type": { "description": "boolean" }, "default": "true" }, "sortComparator": { "type": { "description": "GridComparatorFn<V>" } }, "sortingOrder": { "type": { "description": "readonly GridSortDirection[]" } }, diff --git a/docs/pages/x/api/data-grid/grid-col-def.json b/docs/pages/x/api/data-grid/grid-col-def.json index bfe97f8a97043..0546471b90ea7 100644 --- a/docs/pages/x/api/data-grid/grid-col-def.json +++ b/docs/pages/x/api/data-grid/grid-col-def.json @@ -82,6 +82,7 @@ "isProPlan": true }, "resizable": { "type": { "description": "boolean" }, "default": "true" }, + "rowSpanValueGetter": { "type": { "description": "GridValueGetter<R, V, F>" } }, "sortable": { "type": { "description": "boolean" }, "default": "true" }, "sortComparator": { "type": { "description": "GridComparatorFn<V>" } }, "sortingOrder": { "type": { "description": "readonly GridSortDirection[]" } }, diff --git a/docs/pages/x/api/data-grid/grid-single-select-col-def.json b/docs/pages/x/api/data-grid/grid-single-select-col-def.json index 669318bc48489..e07981f5de85a 100644 --- a/docs/pages/x/api/data-grid/grid-single-select-col-def.json +++ b/docs/pages/x/api/data-grid/grid-single-select-col-def.json @@ -89,6 +89,7 @@ "isProPlan": true }, "resizable": { "type": { "description": "boolean" }, "default": "true" }, + "rowSpanValueGetter": { "type": { "description": "GridValueGetter<R, V, F>" } }, "sortable": { "type": { "description": "boolean" }, "default": "true" }, "sortComparator": { "type": { "description": "GridComparatorFn<V>" } }, "sortingOrder": { "type": { "description": "readonly GridSortDirection[]" } }, diff --git a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json index 5db73ea059f6e..6ad9d6baa7aa7 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json @@ -649,6 +649,9 @@ }, "treeData": { "description": "If true, the rows will be gathered in a tree structure according to the getTreeDataPath prop." + }, + "unstable_rowSpanning": { + "description": "If true, the Data Grid will auto span the cells over the rows having the same value." } }, "classDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json index b763e3504c5db..dd8849adea650 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json @@ -587,6 +587,9 @@ }, "treeData": { "description": "If true, the rows will be gathered in a tree structure according to the getTreeDataPath prop." + }, + "unstable_rowSpanning": { + "description": "If true, the Data Grid will auto span the cells over the rows having the same value." } }, "classDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid/data-grid.json index c8df41f22856e..6acf12ef95099 100644 --- a/docs/translations/api-docs/data-grid/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid/data-grid.json @@ -476,6 +476,9 @@ "sortModel": { "description": "Set the sort model of the Data Grid." }, "sx": { "description": "The system prop that allows defining system overrides as well as additional CSS styles." + }, + "unstable_rowSpanning": { + "description": "If true, the Data Grid will auto span the cells over the rows having the same value." } }, "classDescriptions": { diff --git a/docs/translations/api-docs/data-grid/grid-actions-col-def.json b/docs/translations/api-docs/data-grid/grid-actions-col-def.json index 087bee3376cce..ec34f0c620938 100644 --- a/docs/translations/api-docs/data-grid/grid-actions-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-actions-col-def.json @@ -75,6 +75,9 @@ "description": "Allows to render a component in the column header filter cell." }, "resizable": { "description": "If true, the column is resizable." }, + "rowSpanValueGetter": { + "description": "Function that allows to provide a specific value to be used in row spanning." + }, "sortable": { "description": "If true, the column is sortable." }, "sortComparator": { "description": "A comparator function used to sort rows." }, "sortingOrder": { "description": "The order of the sorting sequence." }, diff --git a/docs/translations/api-docs/data-grid/grid-col-def.json b/docs/translations/api-docs/data-grid/grid-col-def.json index 15b648e0809e1..1e63902cd79e5 100644 --- a/docs/translations/api-docs/data-grid/grid-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-col-def.json @@ -73,6 +73,9 @@ "description": "Allows to render a component in the column header filter cell." }, "resizable": { "description": "If true, the column is resizable." }, + "rowSpanValueGetter": { + "description": "Function that allows to provide a specific value to be used in row spanning." + }, "sortable": { "description": "If true, the column is sortable." }, "sortComparator": { "description": "A comparator function used to sort rows." }, "sortingOrder": { "description": "The order of the sorting sequence." }, diff --git a/docs/translations/api-docs/data-grid/grid-single-select-col-def.json b/docs/translations/api-docs/data-grid/grid-single-select-col-def.json index fdd0720fd1a26..55fceed053c94 100644 --- a/docs/translations/api-docs/data-grid/grid-single-select-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-single-select-col-def.json @@ -78,6 +78,9 @@ "description": "Allows to render a component in the column header filter cell." }, "resizable": { "description": "If true, the column is resizable." }, + "rowSpanValueGetter": { + "description": "Function that allows to provide a specific value to be used in row spanning." + }, "sortable": { "description": "If true, the column is sortable." }, "sortComparator": { "description": "A comparator function used to sort rows." }, "sortingOrder": { "description": "The order of the sorting sequence." }, diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 31603ed8ddef6..96b2d6a126559 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -1078,6 +1078,11 @@ DataGridPremiumRaw.propTypes = { set: PropTypes.func.isRequired, }), unstable_onDataSourceError: PropTypes.func, + /** + * If `true`, the Data Grid will auto span the cells over the rows having the same value. + * @default false + */ + unstable_rowSpanning: PropTypes.bool, } as any; interface DataGridPremiumComponent { diff --git a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx index b61e63a1f9277..12899dd06ce2f 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx @@ -68,6 +68,8 @@ import { useGridDataSourceTreeDataPreProcessors, useGridDataSource, dataSourceStateInitializer, + useGridRowSpanning, + rowSpanningStateInitializer, } from '@mui/x-data-grid-pro/internals'; import { GridApiPremium, GridPrivateApiPremium } from '../models/gridApiPremium'; import { DataGridPremiumProcessedProps } from '../models/dataGridPremiumProps'; @@ -131,6 +133,7 @@ export const useDataGridPremiumComponent = ( useGridInitializeState(sortingStateInitializer, apiRef, props); useGridInitializeState(preferencePanelStateInitializer, apiRef, props); useGridInitializeState(filterStateInitializer, apiRef, props); + useGridInitializeState(rowSpanningStateInitializer, apiRef, props); useGridInitializeState(densityStateInitializer, apiRef, props); useGridInitializeState(columnReorderStateInitializer, apiRef, props); useGridInitializeState(columnResizeStateInitializer, apiRef, props); @@ -152,6 +155,7 @@ export const useDataGridPremiumComponent = ( useGridRowPinning(apiRef, props); useGridColumns(apiRef, props); useGridRows(apiRef, props); + useGridRowSpanning(apiRef, props); useGridParamsApi(apiRef); useGridDetailPanel(apiRef, props); useGridColumnSpanning(apiRef); diff --git a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 54c650ccc9138..908659a542846 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -977,4 +977,9 @@ DataGridProRaw.propTypes = { set: PropTypes.func.isRequired, }), unstable_onDataSourceError: PropTypes.func, + /** + * If `true`, the Data Grid will auto span the cells over the rows having the same value. + * @default false + */ + unstable_rowSpanning: PropTypes.bool, } as any; diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx index d902aa413bb60..6b8b06fc21bb5 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx @@ -47,6 +47,8 @@ import { useGridVirtualization, useGridColumnResize, columnResizeStateInitializer, + useGridRowSpanning, + rowSpanningStateInitializer, } from '@mui/x-data-grid/internals'; import { GridApiPro, GridPrivateApiPro } from '../models/gridApiPro'; import { DataGridProProcessedProps } from '../models/dataGridProProps'; @@ -120,6 +122,7 @@ export const useDataGridProComponent = ( useGridInitializeState(sortingStateInitializer, apiRef, props); useGridInitializeState(preferencePanelStateInitializer, apiRef, props); useGridInitializeState(filterStateInitializer, apiRef, props); + useGridInitializeState(rowSpanningStateInitializer, apiRef, props); useGridInitializeState(densityStateInitializer, apiRef, props); useGridInitializeState(columnReorderStateInitializer, apiRef, props); useGridInitializeState(columnResizeStateInitializer, apiRef, props); @@ -138,6 +141,7 @@ export const useDataGridProComponent = ( useGridRowPinning(apiRef, props); useGridColumns(apiRef, props); useGridRows(apiRef, props); + useGridRowSpanning(apiRef, props); useGridParamsApi(apiRef); useGridDetailPanel(apiRef, props); useGridColumnSpanning(apiRef); diff --git a/packages/x-data-grid/src/DataGrid/DataGrid.tsx b/packages/x-data-grid/src/DataGrid/DataGrid.tsx index b23f085214d34..ee7649620c992 100644 --- a/packages/x-data-grid/src/DataGrid/DataGrid.tsx +++ b/packages/x-data-grid/src/DataGrid/DataGrid.tsx @@ -803,4 +803,9 @@ DataGridRaw.propTypes = { PropTypes.func, PropTypes.object, ]), + /** + * If `true`, the Data Grid will auto span the cells over the rows having the same value. + * @default false + */ + unstable_rowSpanning: PropTypes.bool, } as any; diff --git a/packages/x-data-grid/src/DataGrid/useDataGridComponent.tsx b/packages/x-data-grid/src/DataGrid/useDataGridComponent.tsx index 42b9d2dde827e..85f9a09cb3eaa 100644 --- a/packages/x-data-grid/src/DataGrid/useDataGridComponent.tsx +++ b/packages/x-data-grid/src/DataGrid/useDataGridComponent.tsx @@ -53,6 +53,10 @@ import { columnResizeStateInitializer, useGridColumnResize, } from '../hooks/features/columnResize/useGridColumnResize'; +import { + rowSpanningStateInitializer, + useGridRowSpanning, +} from '../hooks/features/rows/useGridRowSpanning'; export const useDataGridComponent = ( inputApiRef: React.MutableRefObject | undefined, @@ -81,6 +85,7 @@ export const useDataGridComponent = ( useGridInitializeState(sortingStateInitializer, apiRef, props); useGridInitializeState(preferencePanelStateInitializer, apiRef, props); useGridInitializeState(filterStateInitializer, apiRef, props); + useGridInitializeState(rowSpanningStateInitializer, apiRef, props); useGridInitializeState(densityStateInitializer, apiRef, props); useGridInitializeState(columnResizeStateInitializer, apiRef, props); useGridInitializeState(paginationStateInitializer, apiRef, props); @@ -93,6 +98,7 @@ export const useDataGridComponent = ( useGridRowSelection(apiRef, props); useGridColumns(apiRef, props); useGridRows(apiRef, props); + useGridRowSpanning(apiRef, props); useGridParamsApi(apiRef); useGridColumnSpanning(apiRef); useGridColumnGrouping(apiRef, props); diff --git a/packages/x-data-grid/src/DataGrid/useDataGridProps.ts b/packages/x-data-grid/src/DataGrid/useDataGridProps.ts index fb7e047f38238..124df2ee71403 100644 --- a/packages/x-data-grid/src/DataGrid/useDataGridProps.ts +++ b/packages/x-data-grid/src/DataGrid/useDataGridProps.ts @@ -80,6 +80,7 @@ export const DATA_GRID_PROPS_DEFAULT_VALUES: DataGridPropsWithDefaultValues = { sortingMode: 'client', sortingOrder: ['asc' as const, 'desc' as const, null], throttleRowsMs: 0, + unstable_rowSpanning: false, }; const defaultSlots = DATA_GRID_DEFAULT_SLOTS_COMPONENTS; diff --git a/packages/x-data-grid/src/components/cell/GridCell.tsx b/packages/x-data-grid/src/components/cell/GridCell.tsx index 089932efa4d03..c742def4102e5 100644 --- a/packages/x-data-grid/src/components/cell/GridCell.tsx +++ b/packages/x-data-grid/src/components/cell/GridCell.tsx @@ -34,6 +34,10 @@ import { MissingRowIdError } from '../../hooks/features/rows/useGridParamsApi'; import type { DataGridProcessedProps } from '../../models/props/DataGridProps'; import { shouldCellShowLeftBorder, shouldCellShowRightBorder } from '../../utils/cellBorderUtils'; import { GridPinnedColumnPosition } from '../../hooks/features/columns/gridColumnsInterfaces'; +import { + gridRowSpanningHiddenCellsSelector, + gridRowSpanningSpannedCellsSelector, +} from '../../hooks/features/rows/gridRowSpanningSelectors'; export enum PinnedPosition { NONE, @@ -210,6 +214,9 @@ const GridCell = React.forwardRef(function GridCe }), ); + const hiddenCells = useGridSelector(apiRef, gridRowSpanningHiddenCellsSelector); + const spannedCells = useGridSelector(apiRef, gridRowSpanningSpannedCellsSelector); + const { cellMode, hasFocus, isEditable = false, value } = cellParams; const canManageOwnFocus = @@ -321,6 +328,9 @@ const GridCell = React.forwardRef(function GridCe [apiRef, field, rowId], ); + const isCellRowSpanned = hiddenCells[rowId]?.[field] ?? false; + const rowSpan = spannedCells[rowId]?.[field] ?? 1; + const style = React.useMemo(() => { if (isNotVisible) { return { @@ -349,8 +359,13 @@ const GridCell = React.forwardRef(function GridCe cellStyle[side] = pinnedOffset; } + if (rowSpan > 1) { + cellStyle.height = `calc(var(--height) * ${rowSpan})`; + cellStyle.zIndex = 5; + } + return cellStyle; - }, [width, isNotVisible, styleProp, pinnedOffset, pinnedPosition, isRtl]); + }, [width, isNotVisible, styleProp, pinnedOffset, pinnedPosition, isRtl, rowSpan]); React.useEffect(() => { if (!hasFocus || cellMode === GridCellModes.Edit) { @@ -373,6 +388,16 @@ const GridCell = React.forwardRef(function GridCe } }, [hasFocus, cellMode, apiRef]); + if (isCellRowSpanned) { + return ( +
+ ); + } + if (cellParams === EMPTY_CELL_PARAMS) { return null; } @@ -462,6 +487,7 @@ const GridCell = React.forwardRef(function GridCe data-colindex={colIndex} aria-colindex={colIndex + 1} aria-colspan={colSpan} + aria-rowspan={rowSpan} style={style} title={title} tabIndex={tabIndex} diff --git a/packages/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts b/packages/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts index d11ecf537ce0c..5ad04140d74b9 100644 --- a/packages/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts +++ b/packages/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts @@ -1,9 +1,12 @@ import * as React from 'react'; import { useRtl } from '@mui/system/RtlProvider'; import { GridEventListener } from '../../../models/events'; -import { GridApiCommunity, GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; +import { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; import { GridCellParams } from '../../../models/params/gridCellParams'; -import { gridVisibleColumnDefinitionsSelector } from '../columns/gridColumnsSelector'; +import { + gridVisibleColumnDefinitionsSelector, + gridVisibleColumnFieldsSelector, +} from '../columns/gridColumnsSelector'; import { useGridLogger } from '../../utils/useGridLogger'; import { useGridApiEventHandler } from '../../utils/useGridApiEventHandler'; import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; @@ -14,8 +17,7 @@ import { gridClasses } from '../../../constants/gridClasses'; import { GridCellModes } from '../../../models/gridEditRowModel'; import { isNavigationKey } from '../../../utils/keyboardUtils'; import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../../../constants/gridDetailPanelToggleField'; -import { GridRowEntry, GridRowId } from '../../../models'; -import { gridPinnedRowsSelector } from '../rows/gridRowsSelector'; +import { GridRowId } from '../../../models'; import { gridFocusColumnGroupHeaderSelector } from '../focus'; import { gridColumnGroupsHeaderMaxDepthSelector } from '../columnGrouping/gridColumnGroupsSelector'; import { @@ -24,61 +26,12 @@ import { } from '../headerFiltering/gridHeaderFilteringSelectors'; import { GridPipeProcessor, useGridRegisterPipeProcessor } from '../../core/pipeProcessing'; import { isEventTargetInPortal } from '../../../utils/domUtils'; - -function enrichPageRowsWithPinnedRows( - apiRef: React.MutableRefObject, - rows: GridRowEntry[], -) { - const pinnedRows = gridPinnedRowsSelector(apiRef) || {}; - - return [...(pinnedRows.top || []), ...rows, ...(pinnedRows.bottom || [])]; -} - -const getLeftColumnIndex = ({ - currentColIndex, - firstColIndex, - lastColIndex, - isRtl, -}: { - currentColIndex: number; - firstColIndex: number; - lastColIndex: number; - isRtl: boolean; -}) => { - if (isRtl) { - if (currentColIndex < lastColIndex) { - return currentColIndex + 1; - } - } else if (!isRtl) { - if (currentColIndex > firstColIndex) { - return currentColIndex - 1; - } - } - return null; -}; - -const getRightColumnIndex = ({ - currentColIndex, - firstColIndex, - lastColIndex, - isRtl, -}: { - currentColIndex: number; - firstColIndex: number; - lastColIndex: number; - isRtl: boolean; -}) => { - if (isRtl) { - if (currentColIndex > firstColIndex) { - return currentColIndex - 1; - } - } else if (!isRtl) { - if (currentColIndex < lastColIndex) { - return currentColIndex + 1; - } - } - return null; -}; +import { + enrichPageRowsWithPinnedRows, + getLeftColumnIndex, + getRightColumnIndex, + findNonRowSpannedCell, +} from './utils'; /** * @requires useGridSorting (method) - can be after @@ -114,12 +67,18 @@ export const useGridKeyboardNavigation = ( /** * @param {number} colIndex Index of the column to focus - * @param {number} rowIndex index of the row to focus + * @param {GridRowId} rowId index of the row to focus * @param {string} closestColumnToUse Which closest column cell to use when the cell is spanned by `colSpan`. + * @param {string} rowSpanScanDirection Which direction to search to find the next cell not hidden by `rowSpan`. * TODO replace with apiRef.current.moveFocusToRelativeCell() */ const goToCell = React.useCallback( - (colIndex: number, rowId: GridRowId, closestColumnToUse: 'left' | 'right' = 'left') => { + ( + colIndex: number, + rowId: GridRowId, + closestColumnToUse: 'left' | 'right' = 'left', + rowSpanScanDirection: 'up' | 'down' = 'up', + ) => { const visibleSortedRows = gridExpandedSortedRowEntriesSelector(apiRef); const nextCellColSpanInfo = apiRef.current.unstable_getCellColSpanInfo(rowId, colIndex); if (nextCellColSpanInfo && nextCellColSpanInfo.spannedByColSpan) { @@ -129,16 +88,19 @@ export const useGridKeyboardNavigation = ( colIndex = nextCellColSpanInfo.rightVisibleCellIndex; } } + const field = gridVisibleColumnFieldsSelector(apiRef)[colIndex]; + const nonRowSpannedRowId = findNonRowSpannedCell(apiRef, rowId, field, rowSpanScanDirection); // `scrollToIndexes` requires a rowIndex relative to all visible rows. // Those rows do not include pinned rows, but pinned rows do not need scroll anyway. - const rowIndexRelativeToAllRows = visibleSortedRows.findIndex((row) => row.id === rowId); + const rowIndexRelativeToAllRows = visibleSortedRows.findIndex( + (row) => row.id === nonRowSpannedRowId, + ); logger.debug(`Navigating to cell row ${rowIndexRelativeToAllRows}, col ${colIndex}`); apiRef.current.scrollToIndexes({ colIndex, rowIndex: rowIndexRelativeToAllRows, }); - const field = apiRef.current.getVisibleColumns()[colIndex].field; - apiRef.current.setCellFocus(rowId, field); + apiRef.current.setCellFocus(nonRowSpannedRowId, field); }, [apiRef, logger], ); @@ -551,7 +513,12 @@ export const useGridKeyboardNavigation = ( case 'ArrowDown': { // "Enter" is only triggered by the row / cell editing feature if (rowIndexBefore < lastRowIndexInPage) { - goToCell(colIndexBefore, getRowIdFromIndex(rowIndexBefore + 1)); + goToCell( + colIndexBefore, + getRowIdFromIndex(rowIndexBefore + 1), + isRtl ? 'right' : 'left', + 'down', + ); } break; } diff --git a/packages/x-data-grid/src/hooks/features/keyboardNavigation/utils.ts b/packages/x-data-grid/src/hooks/features/keyboardNavigation/utils.ts new file mode 100644 index 0000000000000..3db6b0302e3f7 --- /dev/null +++ b/packages/x-data-grid/src/hooks/features/keyboardNavigation/utils.ts @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { gridFilteredSortedRowIdsSelector } from '../filter/gridFilterSelector'; +import { GridColDef, GridRowEntry, GridRowId } from '../../../models'; +import { gridRowSpanningHiddenCellsSelector } from '../rows/gridRowSpanningSelectors'; +import { GridApiCommunity } from '../../../models/api/gridApiCommunity'; +import { gridPinnedRowsSelector } from '../rows/gridRowsSelector'; + +export function enrichPageRowsWithPinnedRows( + apiRef: React.MutableRefObject, + rows: GridRowEntry[], +) { + const pinnedRows = gridPinnedRowsSelector(apiRef) || {}; + + return [...(pinnedRows.top || []), ...rows, ...(pinnedRows.bottom || [])]; +} + +export const getLeftColumnIndex = ({ + currentColIndex, + firstColIndex, + lastColIndex, + isRtl, +}: { + currentColIndex: number; + firstColIndex: number; + lastColIndex: number; + isRtl: boolean; +}) => { + if (isRtl) { + if (currentColIndex < lastColIndex) { + return currentColIndex + 1; + } + } else if (!isRtl) { + if (currentColIndex > firstColIndex) { + return currentColIndex - 1; + } + } + return null; +}; + +export const getRightColumnIndex = ({ + currentColIndex, + firstColIndex, + lastColIndex, + isRtl, +}: { + currentColIndex: number; + firstColIndex: number; + lastColIndex: number; + isRtl: boolean; +}) => { + if (isRtl) { + if (currentColIndex > firstColIndex) { + return currentColIndex - 1; + } + } else if (!isRtl) { + if (currentColIndex < lastColIndex) { + return currentColIndex + 1; + } + } + return null; +}; + +export function findNonRowSpannedCell( + apiRef: React.MutableRefObject, + rowId: GridRowId, + field: GridColDef['field'], + rowSpanScanDirection: 'up' | 'down', +) { + const rowSpanHiddenCells = gridRowSpanningHiddenCellsSelector(apiRef); + if (!rowSpanHiddenCells[rowId]?.[field]) { + return rowId; + } + const filteredSortedRowIds = gridFilteredSortedRowIdsSelector(apiRef); + // find closest non row spanned cell in the given `rowSpanScanDirection` + let nextRowIndex = + filteredSortedRowIds.indexOf(rowId) + (rowSpanScanDirection === 'down' ? 1 : -1); + while (nextRowIndex >= 0 && nextRowIndex < filteredSortedRowIds.length) { + const nextRowId = filteredSortedRowIds[nextRowIndex]; + if (!rowSpanHiddenCells[nextRowId]?.[field]) { + return nextRowId; + } + nextRowIndex += rowSpanScanDirection === 'down' ? 1 : -1; + } + return rowId; +} diff --git a/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningSelectors.ts b/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningSelectors.ts new file mode 100644 index 0000000000000..e9da213ee77ab --- /dev/null +++ b/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningSelectors.ts @@ -0,0 +1,19 @@ +import { createSelector } from '../../../utils/createSelector'; +import { GridStateCommunity } from '../../../models/gridStateCommunity'; + +const gridRowSpanningStateSelector = (state: GridStateCommunity) => state.rowSpanning; + +export const gridRowSpanningHiddenCellsSelector = createSelector( + gridRowSpanningStateSelector, + (rowSpanning) => rowSpanning.hiddenCells, +); + +export const gridRowSpanningSpannedCellsSelector = createSelector( + gridRowSpanningStateSelector, + (rowSpanning) => rowSpanning.spannedCells, +); + +export const gridRowSpanningHiddenCellsOriginMapSelector = createSelector( + gridRowSpanningStateSelector, + (rowSpanning) => rowSpanning.hiddenCellOriginMap, +); diff --git a/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningUtils.ts b/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningUtils.ts new file mode 100644 index 0000000000000..6720ed4bd3374 --- /dev/null +++ b/packages/x-data-grid/src/hooks/features/rows/gridRowSpanningUtils.ts @@ -0,0 +1,64 @@ +import * as React from 'react'; +import type { GridRenderContext } from '../../../models'; +import type { GridValidRowModel } from '../../../models/gridRows'; +import type { GridColDef } from '../../../models/colDef'; +import type { GridApiCommunity } from '../../../models/api/gridApiCommunity'; +import type { RowRange } from './useGridRowSpanning'; + +export function getUnprocessedRange(testRange: RowRange, processedRange: RowRange) { + if ( + testRange.firstRowIndex >= processedRange.firstRowIndex && + testRange.lastRowIndex <= processedRange.lastRowIndex + ) { + return null; + } + // Overflowing at the end + // Example: testRange={ firstRowIndex: 10, lastRowIndex: 20 }, processedRange={ firstRowIndex: 0, lastRowIndex: 15 } + // Unprocessed Range={ firstRowIndex: 16, lastRowIndex: 20 } + if ( + testRange.firstRowIndex >= processedRange.firstRowIndex && + testRange.lastRowIndex > processedRange.lastRowIndex + ) { + return { firstRowIndex: processedRange.lastRowIndex, lastRowIndex: testRange.lastRowIndex }; + } + // Overflowing at the beginning + // Example: testRange={ firstRowIndex: 0, lastRowIndex: 20 }, processedRange={ firstRowIndex: 16, lastRowIndex: 30 } + // Unprocessed Range={ firstRowIndex: 0, lastRowIndex: 15 } + if ( + testRange.firstRowIndex < processedRange.firstRowIndex && + testRange.lastRowIndex <= processedRange.lastRowIndex + ) { + return { + firstRowIndex: testRange.firstRowIndex, + lastRowIndex: processedRange.firstRowIndex - 1, + }; + } + // TODO: Should return two ranges handle overflowing at both ends ? + return testRange; +} + +export function isRowContextInitialized(renderContext: GridRenderContext) { + return renderContext.firstRowIndex !== 0 || renderContext.lastRowIndex !== 0; +} + +export function isRowRangeUpdated(range1: RowRange, range2: RowRange) { + return ( + range1.firstRowIndex !== range2.firstRowIndex || range1.lastRowIndex !== range2.lastRowIndex + ); +} + +export const getCellValue = ( + row: GridValidRowModel, + colDef: GridColDef, + apiRef: React.MutableRefObject, +) => { + if (!row) { + return null; + } + let cellValue = row[colDef.field]; + const valueGetter = colDef.rowSpanValueGetter ?? colDef.valueGetter; + if (valueGetter) { + cellValue = valueGetter(cellValue as never, row, colDef, apiRef); + } + return cellValue; +}; diff --git a/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts b/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts new file mode 100644 index 0000000000000..0fd6b3a1c492f --- /dev/null +++ b/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts @@ -0,0 +1,350 @@ +import * as React from 'react'; +import useLazyRef from '@mui/utils/useLazyRef'; +import { gridVisibleColumnDefinitionsSelector } from '../columns/gridColumnsSelector'; +import { useGridVisibleRows } from '../../utils/useGridVisibleRows'; +import { gridRenderContextSelector } from '../virtualization/gridVirtualizationSelectors'; +import { useGridSelector } from '../../utils/useGridSelector'; +import type { GridColDef } from '../../../models/colDef'; +import type { GridRowId, GridValidRowModel, GridRowEntry } from '../../../models/gridRows'; +import type { DataGridProcessedProps } from '../../../models/props/DataGridProps'; +import type { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; +import type { GridStateInitializer } from '../../utils/useGridInitializeState'; +import { + getUnprocessedRange, + isRowRangeUpdated, + isRowContextInitialized, + getCellValue, +} from './gridRowSpanningUtils'; + +export interface GridRowSpanningState { + spannedCells: Record>; + hiddenCells: Record>; + /** + * For each hidden cell, it contains the row index corresponding to the cell that is + * the origin of the hidden cell. i.e. the cell which is spanned. + * Used by the virtualization to properly keep the spanned cells in view. + */ + hiddenCellOriginMap: Record>; +} + +export type RowRange = { firstRowIndex: number; lastRowIndex: number }; + +const EMPTY_STATE = { spannedCells: {}, hiddenCells: {}, hiddenCellOriginMap: {} }; +const EMPTY_RANGE: RowRange = { firstRowIndex: 0, lastRowIndex: 0 }; +const skippedFields = new Set(['__check__', '__reorder__', '__detail_panel_toggle__']); +/** + * Default number of rows to process during state initialization to avoid flickering. + * Number `20` is arbitrarily chosen to be large enough to cover most of the cases without + * compromising performance. + */ +const DEFAULT_ROWS_TO_PROCESS = 20; + +const computeRowSpanningState = ( + apiRef: React.MutableRefObject, + colDefs: GridColDef[], + visibleRows: GridRowEntry[], + range: RowRange, + rangeToProcess: RowRange, + resetState: boolean, + processedRange: RowRange, +) => { + const spannedCells = resetState ? {} : { ...apiRef.current.state.rowSpanning.spannedCells }; + const hiddenCells = resetState ? {} : { ...apiRef.current.state.rowSpanning.hiddenCells }; + const hiddenCellOriginMap = resetState + ? {} + : { ...apiRef.current.state.rowSpanning.hiddenCellOriginMap }; + + if (resetState) { + processedRange = EMPTY_RANGE; + } + + colDefs.forEach((colDef) => { + if (skippedFields.has(colDef.field)) { + return; + } + + for ( + let index = rangeToProcess.firstRowIndex; + index <= rangeToProcess.lastRowIndex; + index += 1 + ) { + const row = visibleRows[index]; + + if (hiddenCells[row.id]?.[colDef.field]) { + continue; + } + const cellValue = getCellValue(row.model, colDef, apiRef); + + if (cellValue == null) { + continue; + } + + let spannedRowId = row.id; + let spannedRowIndex = index; + let rowSpan = 0; + + // For first index, also scan in the previous rows to handle the reset state case e.g by sorting + const backwardsHiddenCells: number[] = []; + if (index === rangeToProcess.firstRowIndex) { + let prevIndex = index - 1; + const prevRowEntry = visibleRows[prevIndex]; + while ( + prevIndex >= range.firstRowIndex && + getCellValue(prevRowEntry.model, colDef, apiRef) === cellValue + ) { + const currentRow = visibleRows[prevIndex + 1]; + if (hiddenCells[currentRow.id]) { + hiddenCells[currentRow.id][colDef.field] = true; + } else { + hiddenCells[currentRow.id] = { [colDef.field]: true }; + } + backwardsHiddenCells.push(index); + rowSpan += 1; + spannedRowId = prevRowEntry.id; + spannedRowIndex = prevIndex; + prevIndex -= 1; + } + } + + backwardsHiddenCells.forEach((hiddenCellIndex) => { + if (hiddenCellOriginMap[hiddenCellIndex]) { + hiddenCellOriginMap[hiddenCellIndex][colDef.field] = spannedRowIndex; + } else { + hiddenCellOriginMap[hiddenCellIndex] = { [colDef.field]: spannedRowIndex }; + } + }); + + // Scan the next rows + let relativeIndex = index + 1; + while ( + relativeIndex <= range.lastRowIndex && + visibleRows[relativeIndex] && + getCellValue(visibleRows[relativeIndex].model, colDef, apiRef) === cellValue + ) { + const currentRow = visibleRows[relativeIndex]; + if (hiddenCells[currentRow.id]) { + hiddenCells[currentRow.id][colDef.field] = true; + } else { + hiddenCells[currentRow.id] = { [colDef.field]: true }; + } + if (hiddenCellOriginMap[relativeIndex]) { + hiddenCellOriginMap[relativeIndex][colDef.field] = spannedRowIndex; + } else { + hiddenCellOriginMap[relativeIndex] = { [colDef.field]: spannedRowIndex }; + } + relativeIndex += 1; + rowSpan += 1; + } + + if (rowSpan > 0) { + if (spannedCells[spannedRowId]) { + spannedCells[spannedRowId][colDef.field] = rowSpan + 1; + } else { + spannedCells[spannedRowId] = { [colDef.field]: rowSpan + 1 }; + } + } + } + processedRange = { + firstRowIndex: Math.min(processedRange.firstRowIndex, rangeToProcess.firstRowIndex), + lastRowIndex: Math.max(processedRange.lastRowIndex, rangeToProcess.lastRowIndex), + }; + }); + return { spannedCells, hiddenCells, hiddenCellOriginMap, processedRange }; +}; + +/** + * @requires columnsStateInitializer (method) - should be initialized before + * @requires rowsStateInitializer (method) - should be initialized before + * @requires filterStateInitializer (method) - should be initialized before + */ +export const rowSpanningStateInitializer: GridStateInitializer = (state, props, apiRef) => { + if (props.unstable_rowSpanning) { + const rowIds = state.rows!.dataRowIds || []; + const orderedFields = state.columns!.orderedFields || []; + const dataRowIdToModelLookup = state.rows!.dataRowIdToModelLookup; + const columnsLookup = state.columns!.lookup; + const isFilteringPending = + Boolean(state.filter!.filterModel!.items!.length) || + Boolean(state.filter!.filterModel!.quickFilterValues?.length); + + if ( + !rowIds.length || + !orderedFields.length || + !dataRowIdToModelLookup || + !columnsLookup || + isFilteringPending + ) { + return { + ...state, + rowSpanning: EMPTY_STATE, + }; + } + const rangeToProcess = { + firstRowIndex: 0, + lastRowIndex: Math.min(DEFAULT_ROWS_TO_PROCESS - 1, Math.max(rowIds.length - 1, 0)), + }; + const rows = rowIds.map((id) => ({ + id, + model: dataRowIdToModelLookup[id!], + })) as GridRowEntry[]; + const colDefs = orderedFields.map((field) => columnsLookup[field!]) as GridColDef[]; + const { spannedCells, hiddenCells, hiddenCellOriginMap } = computeRowSpanningState( + apiRef, + colDefs, + rows, + rangeToProcess, + rangeToProcess, + true, + EMPTY_RANGE, + ); + + return { + ...state, + rowSpanning: { + spannedCells, + hiddenCells, + hiddenCellOriginMap, + }, + }; + } + return { + ...state, + rowSpanning: EMPTY_STATE, + }; +}; + +export const useGridRowSpanning = ( + apiRef: React.MutableRefObject, + props: Pick, +): void => { + const { range, rows: visibleRows } = useGridVisibleRows(apiRef, props); + const renderContext = useGridSelector(apiRef, gridRenderContextSelector); + const colDefs = useGridSelector(apiRef, gridVisibleColumnDefinitionsSelector); + const processedRange = useLazyRef(() => { + return Object.keys(apiRef.current.state.rowSpanning.spannedCells).length > 0 + ? { + firstRowIndex: 0, + lastRowIndex: Math.min( + DEFAULT_ROWS_TO_PROCESS - 1, + Math.max(apiRef.current.state.rows.dataRowIds.length - 1, 0), + ), + } + : EMPTY_RANGE; + }); + const lastRange = React.useRef(EMPTY_RANGE); + + const updateRowSpanningState = React.useCallback( + // A reset needs to occur when: + // - The `unstable_rowSpanning` prop is updated (feature flag) + // - The filtering is applied + // - The sorting is applied + // - The `paginationModel` is updated + // - The rows are updated + (resetState: boolean = true) => { + if (!props.unstable_rowSpanning) { + if (apiRef.current.state.rowSpanning !== EMPTY_STATE) { + apiRef.current.setState((state) => ({ ...state, rowSpanning: EMPTY_STATE })); + } + return; + } + + if (range === null || !isRowContextInitialized(renderContext)) { + return; + } + + if (resetState) { + processedRange.current = EMPTY_RANGE; + } + + const rangeToProcess = getUnprocessedRange( + { + firstRowIndex: renderContext.firstRowIndex, + lastRowIndex: renderContext.lastRowIndex - 1, + }, + processedRange.current, + ); + + if (rangeToProcess === null) { + return; + } + + const { + spannedCells, + hiddenCells, + hiddenCellOriginMap, + processedRange: newProcessedRange, + } = computeRowSpanningState( + apiRef, + colDefs, + visibleRows, + range, + rangeToProcess, + resetState, + processedRange.current, + ); + + processedRange.current = newProcessedRange; + + const newSpannedCellsCount = Object.keys(spannedCells).length; + const newHiddenCellsCount = Object.keys(hiddenCells).length; + const currentSpannedCellsCount = Object.keys( + apiRef.current.state.rowSpanning.spannedCells, + ).length; + const currentHiddenCellsCount = Object.keys( + apiRef.current.state.rowSpanning.hiddenCells, + ).length; + + const shouldUpdateState = + resetState || + newSpannedCellsCount !== currentSpannedCellsCount || + newHiddenCellsCount !== currentHiddenCellsCount; + + if (!shouldUpdateState) { + return; + } + + apiRef.current.setState((state) => { + return { + ...state, + rowSpanning: { + spannedCells, + hiddenCells, + hiddenCellOriginMap, + }, + }; + }); + }, + [ + apiRef, + props.unstable_rowSpanning, + range, + renderContext, + visibleRows, + colDefs, + processedRange, + ], + ); + + const prevRenderContext = React.useRef(renderContext); + const isFirstRender = React.useRef(true); + const shouldResetState = React.useRef(false); + React.useEffect(() => { + const firstRender = isFirstRender.current; + if (isFirstRender.current) { + isFirstRender.current = false; + } + if (range && lastRange.current && isRowRangeUpdated(range, lastRange.current)) { + lastRange.current = range; + shouldResetState.current = true; + } + if (!firstRender && prevRenderContext.current !== renderContext) { + if (isRowRangeUpdated(prevRenderContext.current, renderContext)) { + updateRowSpanningState(shouldResetState.current); + shouldResetState.current = false; + } + prevRenderContext.current = renderContext; + return; + } + updateRowSpanningState(); + }, [updateRowSpanningState, renderContext, range, lastRange]); +}; diff --git a/packages/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx b/packages/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx index 53a8b9cd6c56c..cf6cb4401c697 100644 --- a/packages/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx +++ b/packages/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx @@ -45,6 +45,7 @@ import { gridVirtualizationColumnEnabledSelector, } from './gridVirtualizationSelectors'; import { EMPTY_RENDER_CONTEXT } from './useGridVirtualization'; +import { gridRowSpanningHiddenCellsOriginMapSelector } from '../rows/gridRowSpanningSelectors'; const MINIMUM_COLUMN_WIDTH = 50; @@ -627,6 +628,7 @@ type RenderContextInputs = { range: ReturnType['range']; pinnedColumns: ReturnType; visibleColumns: ReturnType; + hiddenCellsOriginMap: ReturnType; }; function inputsSelector( @@ -638,6 +640,7 @@ function inputsSelector( const dimensions = gridDimensionsSelector(apiRef.current.state); const currentPage = getVisibleRows(apiRef, rootProps); const visibleColumns = gridVisibleColumnDefinitionsSelector(apiRef); + const hiddenCellsOriginMap = gridRowSpanningHiddenCellsOriginMapSelector(apiRef); const lastRowId = apiRef.current.state.rows.dataRowIds.at(-1); const lastColumn = visibleColumns.at(-1); return { @@ -659,6 +662,7 @@ function inputsSelector( range: currentPage.range, pinnedColumns: gridVisiblePinnedColumnDefinitionsSelector(apiRef), visibleColumns, + hiddenCellsOriginMap, }; } @@ -680,7 +684,7 @@ function computeRenderContext( if (inputs.enabledForRows) { // Clamp the value because the search may return an index out of bounds. // In the last index, this is not needed because Array.slice doesn't include it. - const firstRowIndex = Math.min( + let firstRowIndex = Math.min( getNearestIndexToRender(inputs, top, { atStart: true, lastPosition: @@ -689,6 +693,14 @@ function computeRenderContext( inputs.rowsMeta.positions.length - 1, ); + // If any of the cells in the `firstRowIndex` is hidden due to an extended row span, + // Make sure the row from where the rowSpan is originated is visible. + const rowSpanHiddenCellOrigin = inputs.hiddenCellsOriginMap[firstRowIndex]; + if (rowSpanHiddenCellOrigin) { + const minSpannedRowIndex = Math.min(...Object.values(rowSpanHiddenCellOrigin)); + firstRowIndex = Math.min(firstRowIndex, minSpannedRowIndex); + } + const lastRowIndex = inputs.autoHeight ? firstRowIndex + inputs.rows.length : getNearestIndexToRender(inputs, top + inputs.viewportInnerHeight); diff --git a/packages/x-data-grid/src/internals/index.ts b/packages/x-data-grid/src/internals/index.ts index 864b5b5890138..6d0b8e5b91b60 100644 --- a/packages/x-data-grid/src/internals/index.ts +++ b/packages/x-data-grid/src/internals/index.ts @@ -78,6 +78,10 @@ export { export { useGridEditing, editingStateInitializer } from '../hooks/features/editing/useGridEditing'; export { gridEditRowsStateSelector } from '../hooks/features/editing/gridEditingSelectors'; export { useGridRows, rowsStateInitializer } from '../hooks/features/rows/useGridRows'; +export { + useGridRowSpanning, + rowSpanningStateInitializer, +} from '../hooks/features/rows/useGridRowSpanning'; export { useGridAriaAttributes } from '../hooks/utils/useGridAriaAttributes'; export { useGridRowAriaAttributes } from '../hooks/features/rows/useGridRowAriaAttributes'; export { useGridRowsPreProcessors } from '../hooks/features/rows/useGridRowsPreProcessors'; diff --git a/packages/x-data-grid/src/models/colDef/gridColDef.ts b/packages/x-data-grid/src/models/colDef/gridColDef.ts index 4030443ff315d..039fb6589f5ee 100644 --- a/packages/x-data-grid/src/models/colDef/gridColDef.ts +++ b/packages/x-data-grid/src/models/colDef/gridColDef.ts @@ -184,6 +184,10 @@ export interface GridBaseColDef; + /** + * Function that allows to provide a specific value to be used in row spanning. + */ + rowSpanValueGetter?: GridValueGetter; /** * Function that allows to customize how the entered value is stored in the row. * It only works with cell/row editing. diff --git a/packages/x-data-grid/src/models/gridStateCommunity.ts b/packages/x-data-grid/src/models/gridStateCommunity.ts index ee737b4b8a51d..311d28a285968 100644 --- a/packages/x-data-grid/src/models/gridStateCommunity.ts +++ b/packages/x-data-grid/src/models/gridStateCommunity.ts @@ -25,6 +25,7 @@ import { GridHeaderFilteringState } from './gridHeaderFilteringModel'; import type { GridRowSelectionModel } from './gridRowSelectionModel'; import type { GridVisibleRowsLookupState } from '../hooks/features/filter/gridFilterState'; import type { GridColumnResizeState } from '../hooks/features/columnResize'; +import type { GridRowSpanningState } from '../hooks/features/rows/useGridRowSpanning'; /** * The state of `DataGrid`. @@ -51,6 +52,7 @@ export interface GridStateCommunity { density: GridDensityState; virtualization: GridVirtualizationState; columnResize: GridColumnResizeState; + rowSpanning: GridRowSpanningState; } /** diff --git a/packages/x-data-grid/src/models/props/DataGridProps.ts b/packages/x-data-grid/src/models/props/DataGridProps.ts index 8b57c7b8915b7..cc713cbed84a4 100644 --- a/packages/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/x-data-grid/src/models/props/DataGridProps.ts @@ -383,6 +383,11 @@ export interface DataGridPropsWithDefaultValues - Row spanning', () => { + const { render } = createRenderer(); + + let apiRef: React.MutableRefObject; + const baselineProps: DataGridProps = { + unstable_rowSpanning: true, + columns: [ + { + field: 'code', + headerName: 'Item Code', + width: 85, + cellClassName: ({ row }) => (row.summaryRow ? 'bold' : ''), + }, + { + field: 'description', + headerName: 'Description', + width: 170, + }, + { + field: 'quantity', + headerName: 'Quantity', + width: 80, + // Do not span the values + rowSpanValueGetter: () => null, + }, + { + field: 'unitPrice', + headerName: 'Unit Price', + type: 'number', + valueFormatter: (value) => (value ? `$${value}.00` : ''), + }, + { + field: 'totalPrice', + headerName: 'Total Price', + type: 'number', + valueGetter: (value, row) => value ?? row?.unitPrice, + valueFormatter: (value) => `$${value}.00`, + }, + ], + rows: [ + { + id: 1, + code: 'A101', + description: 'Wireless Mouse', + quantity: 2, + unitPrice: 50, + totalPrice: 100, + }, + { + id: 2, + code: 'A102', + description: 'Mechanical Keyboard', + quantity: 1, + unitPrice: 75, + }, + { + id: 3, + code: 'A103', + description: 'USB Dock Station', + quantity: 1, + unitPrice: 400, + }, + { + id: 4, + code: 'A104', + description: 'Laptop', + quantity: 1, + unitPrice: 1800, + totalPrice: 2050, + }, + { + id: 5, + code: 'A104', + description: '- 16GB RAM Upgrade', + quantity: 1, + unitPrice: 100, + totalPrice: 2050, + }, + { + id: 6, + code: 'A104', + description: '- 512GB SSD Upgrade', + quantity: 1, + unitPrice: 150, + totalPrice: 2050, + }, + { + id: 7, + code: 'TOTAL', + totalPrice: 2625, + summaryRow: true, + }, + ], + }; + + function TestDataGrid(props: Partial) { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + } + + const rowHeight = 52; + + it('should span the repeating row values', function test() { + if (isJSDOM) { + this.skip(); + } + render(); + const rowsWithSpannedCells = Object.keys(apiRef.current.state.rowSpanning.spannedCells); + expect(rowsWithSpannedCells.length).to.equal(1); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows('4'); + expect(rowIndex).to.equal(3); + const spanValue = apiRef.current.state.rowSpanning.spannedCells['4']; + expect(spanValue).to.deep.equal({ code: 3, totalPrice: 3 }); + const spannedCell = getCell(rowIndex, 0); + expect(spannedCell).to.have.style('height', `${rowHeight * spanValue.code}px`); + }); + + describe('sorting', () => { + it('should work with sorting when initializing sorting', function test() { + if (isJSDOM) { + this.skip(); + } + render( + , + ); + const rowsWithSpannedCells = Object.keys(apiRef.current.state.rowSpanning.spannedCells); + expect(rowsWithSpannedCells.length).to.equal(1); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows('4'); + expect(rowIndex).to.equal(1); + const spanValue = apiRef.current.state.rowSpanning.spannedCells['4']; + expect(spanValue).to.deep.equal({ code: 3, totalPrice: 3 }); + const spannedCell = getCell(rowIndex, 0); + expect(spannedCell).to.have.style('height', `${rowHeight * spanValue.code}px`); + }); + + it('should work with sorting when controlling sorting', function test() { + if (isJSDOM) { + this.skip(); + } + render(); + const rowsWithSpannedCells = Object.keys(apiRef.current.state.rowSpanning.spannedCells); + expect(rowsWithSpannedCells.length).to.equal(1); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows('4'); + expect(rowIndex).to.equal(1); + const spanValue = apiRef.current.state.rowSpanning.spannedCells['4']; + expect(spanValue).to.deep.equal({ code: 3, totalPrice: 3 }); + const spannedCell = getCell(rowIndex, 0); + expect(spannedCell).to.have.style('height', `${rowHeight * spanValue.code}px`); + }); + }); + + describe('filtering', () => { + it('should work with filtering when initializing filter', function test() { + if (isJSDOM) { + this.skip(); + } + render( + , + ); + const rowsWithSpannedCells = Object.keys(apiRef.current.state.rowSpanning.spannedCells); + expect(rowsWithSpannedCells.length).to.equal(1); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows('5'); + expect(rowIndex).to.equal(0); + const spanValue = apiRef.current.state.rowSpanning.spannedCells['5']; + expect(spanValue).to.deep.equal({ code: 2, totalPrice: 2 }); + const spannedCell = getCell(rowIndex, 0); + expect(spannedCell).to.have.style('height', `${rowHeight * spanValue.code}px`); + }); + + it('should work with filtering when controlling filter', function test() { + if (isJSDOM) { + this.skip(); + } + render( + , + ); + const rowsWithSpannedCells = Object.keys(apiRef.current.state.rowSpanning.spannedCells); + expect(rowsWithSpannedCells.length).to.equal(1); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows('5'); + expect(rowIndex).to.equal(0); + const spanValue = apiRef.current.state.rowSpanning.spannedCells['5']; + expect(spanValue).to.deep.equal({ code: 2, totalPrice: 2 }); + const spannedCell = getCell(rowIndex, 0); + expect(spannedCell).to.have.style('height', `${rowHeight * spanValue.code}px`); + }); + }); + + describe('pagination', () => { + it('should only compute the row spanning state for current page', async function test() { + if (isJSDOM) { + this.skip(); + } + render( + , + ); + expect(Object.keys(apiRef.current.state.rowSpanning.spannedCells).length).to.equal(0); + apiRef.current.setPage(1); + await waitFor(() => + expect(Object.keys(apiRef.current.state.rowSpanning.spannedCells).length).to.equal(1), + ); + expect(Object.keys(apiRef.current.state.rowSpanning.hiddenCells).length).to.equal(1); + }); + }); + + describe('keyboard navigation', () => { + it('should respect the spanned cells when navigating using keyboard', () => { + render(); + // Set focus to the cell with value `- 16GB RAM Upgrade` + act(() => apiRef.current.setCellFocus(5, 'description')); + expect(getActiveCell()).to.equal('4-1'); + const cell41 = getCell(4, 1); + fireEvent.keyDown(cell41, { key: 'ArrowLeft' }); + expect(getActiveCell()).to.equal('3-0'); + const cell30 = getCell(3, 0); + fireEvent.keyDown(cell30, { key: 'ArrowRight' }); + expect(getActiveCell()).to.equal('3-1'); + }); + }); + + // TODO: Add tests for row reordering +});