diff --git a/codecov.yml b/codecov.yml index 4a05503c5b9e..b4af65812615 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,15 +10,15 @@ coverage: adapters: target: 100% paths: - - 'packages/x-date-pickers/src/AdapterDateFns/AdapterDateFns.ts' - - 'packages/x-date-pickers/src/AdapterDateFnsV3/AdapterDateFnsV3.ts' - - 'packages/x-date-pickers/src/AdapterDateFnsJalali/AdapterDateFnsJalali.ts' - - 'packages/x-date-pickers/src/AdapterDateFnsJalaliV3/AdapterDateFnsJalaliV3.ts' - - 'packages/x-date-pickers/src/AdapterDayjs/AdapterDayjs.ts' - - 'packages/x-date-pickers/src/AdapterLuxon/AdapterLuxon.ts' - - 'packages/x-date-pickers/src/AdapterMoment/AdapterMoment.ts' - - 'packages/x-date-pickers/src/AdapterMomentHijri/AdapterMomentHijri.ts' - - 'packages/x-date-pickers/src/AdapterMomentJalaali/AdapterMomentJalaali.ts' + - packages/x-date-pickers/src/AdapterDateFns/AdapterDateFns.ts + - packages/x-date-pickers/src/AdapterDateFnsV3/AdapterDateFnsV3.ts + - packages/x-date-pickers/src/AdapterDateFnsJalali/AdapterDateFnsJalali.ts + - packages/x-date-pickers/src/AdapterDateFnsJalaliV3/AdapterDateFnsJalaliV3.ts + - packages/x-date-pickers/src/AdapterDayjs/AdapterDayjs.ts + - packages/x-date-pickers/src/AdapterLuxon/AdapterLuxon.ts + - packages/x-date-pickers/src/AdapterMoment/AdapterMoment.ts + - packages/x-date-pickers/src/AdapterMomentHijri/AdapterMomentHijri.ts + - packages/x-date-pickers/src/AdapterMomentJalaali/AdapterMomentJalaali.ts patch: off comment: false diff --git a/docs/data/charts/tooltip/CustomAxisTooltip.js b/docs/data/charts/tooltip/CustomAxisTooltip.js index e3e36c4e65b5..c8b63c7f6cec 100644 --- a/docs/data/charts/tooltip/CustomAxisTooltip.js +++ b/docs/data/charts/tooltip/CustomAxisTooltip.js @@ -3,22 +3,80 @@ import NoSsr from '@mui/material/NoSsr'; import Popper from '@mui/material/Popper'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { useAxisTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useAxisTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { generateVirtualElement } from './generateVirtualElement'; +function usePointer() { + const svgRef = useSvgRef(); + const popperRef = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function CustomAxisTooltip() { const tooltipData = useAxisTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -29,7 +87,8 @@ export function CustomAxisTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/CustomAxisTooltip.tsx b/docs/data/charts/tooltip/CustomAxisTooltip.tsx index e3e36c4e65b5..841d740c49ba 100644 --- a/docs/data/charts/tooltip/CustomAxisTooltip.tsx +++ b/docs/data/charts/tooltip/CustomAxisTooltip.tsx @@ -1,24 +1,88 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; -import Popper from '@mui/material/Popper'; +import Popper, { PopperProps } from '@mui/material/Popper'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { useAxisTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useAxisTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { generateVirtualElement } from './generateVirtualElement'; +type PointerState = { + isActive: boolean; + isMousePointer: boolean; + pointerHeight: number; +}; + +function usePointer(): PointerState & Pick { + const svgRef = useSvgRef(); + const popperRef: PopperProps['popperRef'] = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event: PointerEvent) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function CustomAxisTooltip() { const tooltipData = useAxisTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -29,7 +93,8 @@ export function CustomAxisTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/CustomItemTooltip.js b/docs/data/charts/tooltip/CustomItemTooltip.js index fcaf28469db1..c41324f0d3df 100644 --- a/docs/data/charts/tooltip/CustomItemTooltip.js +++ b/docs/data/charts/tooltip/CustomItemTooltip.js @@ -4,22 +4,79 @@ import Popper from '@mui/material/Popper'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { generateVirtualElement } from './generateVirtualElement'; +function usePointer() { + const svgRef = useSvgRef(); + const popperRef = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function CustomItemTooltip() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } - // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -30,7 +87,8 @@ export function CustomItemTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/CustomItemTooltip.tsx b/docs/data/charts/tooltip/CustomItemTooltip.tsx index fcaf28469db1..926b4fd3179d 100644 --- a/docs/data/charts/tooltip/CustomItemTooltip.tsx +++ b/docs/data/charts/tooltip/CustomItemTooltip.tsx @@ -1,25 +1,88 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; -import Popper from '@mui/material/Popper'; +import Popper, { PopperProps } from '@mui/material/Popper'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { generateVirtualElement } from './generateVirtualElement'; +type PointerState = { + isActive: boolean; + isMousePointer: boolean; + pointerHeight: number; +}; + +function usePointer(): PointerState & Pick { + const svgRef = useSvgRef(); + const popperRef: PopperProps['popperRef'] = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event: PointerEvent) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function CustomItemTooltip() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } - // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -30,7 +93,8 @@ export function CustomItemTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/ItemTooltip.js b/docs/data/charts/tooltip/ItemTooltip.js index 68fbab1d77ae..cb2a90370e5e 100644 --- a/docs/data/charts/tooltip/ItemTooltip.js +++ b/docs/data/charts/tooltip/ItemTooltip.js @@ -1,23 +1,80 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; import { generateVirtualElement } from './generateVirtualElement'; +function usePointer() { + const svgRef = useSvgRef(); + const popperRef = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function ItemTooltip() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } - // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -28,7 +85,8 @@ export function ItemTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/ItemTooltip.tsx b/docs/data/charts/tooltip/ItemTooltip.tsx index 68fbab1d77ae..5069ad32df3d 100644 --- a/docs/data/charts/tooltip/ItemTooltip.tsx +++ b/docs/data/charts/tooltip/ItemTooltip.tsx @@ -1,23 +1,86 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; -import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import Popper, { PopperProps } from '@mui/material/Popper'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; +import { useSvgRef } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; import { generateVirtualElement } from './generateVirtualElement'; +type PointerState = { + isActive: boolean; + isMousePointer: boolean; + pointerHeight: number; +}; + +function usePointer(): PointerState & Pick { + const svgRef = useSvgRef(); + const popperRef: PopperProps['popperRef'] = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + const handleMove = (event: PointerEvent) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + + return { ...pointer, popperRef, anchorEl: virtualElement.current }; +} + export function ItemTooltip() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); // Track the mouse position on chart. + const { isActive, isMousePointer, pointerHeight, popperRef, anchorEl } = + usePointer(); - if (!tooltipData || !mousePosition) { + if (!tooltipData || !isActive) { // No data to display return null; } - // The pointer type can be used to have different behavior based on pointer type. - const isMousePointer = mousePosition?.pointerType === 'mouse'; // Adapt the tooltip offset to the size of the pointer. - const yOffset = isMousePointer ? 0 : 40 - mousePosition.height; + const yOffset = isMousePointer ? 0 : 40 - pointerHeight; return ( @@ -28,7 +91,8 @@ export function ItemTooltip() { }} open placement={isMousePointer ? 'top-end' : 'top'} - anchorEl={generateVirtualElement(mousePosition)} + anchorEl={anchorEl} + popperRef={popperRef} modifiers={[ { name: 'offset', diff --git a/docs/data/charts/tooltip/ItemTooltipFixedY.js b/docs/data/charts/tooltip/ItemTooltipFixedY.js index 4e19cd1bc9f6..11bfb8fe8668 100644 --- a/docs/data/charts/tooltip/ItemTooltipFixedY.js +++ b/docs/data/charts/tooltip/ItemTooltipFixedY.js @@ -1,28 +1,92 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; import { useDrawingArea, useSvgRef } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; import { generateVirtualElement } from './generateVirtualElement'; +function usePointer() { + const svgRef = useSvgRef(); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + }; + }, [svgRef]); + + return pointer; +} + export function ItemTooltipFixedY() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); + const { isActive } = usePointer(); + + const popperRef = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); const svgRef = useSvgRef(); // Get the ref of the component. const drawingArea = useDrawingArea(); // Get the dimensions of the chart inside the . - if (!tooltipData || !mousePosition) { + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleMove = (event) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + // Add the y-coordinate of the to the to margin between the and the drawing area + y: svgRef.current.getBoundingClientRect().top + drawingArea.top, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef, drawingArea.top]); + + if (!tooltipData || !isActive) { // No data to display return null; } - const tooltipPosition = { - ...mousePosition, - // Add the y-coordinate of the to the to margin between the and the drawing area - y: svgRef.current.getBoundingClientRect().top + drawingArea.top, - }; - return ( diff --git a/docs/data/charts/tooltip/ItemTooltipFixedY.tsx b/docs/data/charts/tooltip/ItemTooltipFixedY.tsx index 3059c260afca..b6feefe896f4 100644 --- a/docs/data/charts/tooltip/ItemTooltipFixedY.tsx +++ b/docs/data/charts/tooltip/ItemTooltipFixedY.tsx @@ -1,28 +1,98 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; -import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import Popper, { PopperProps } from '@mui/material/Popper'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; import { useDrawingArea, useSvgRef } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; -import { generateVirtualElement, MousePosition } from './generateVirtualElement'; +import { generateVirtualElement } from './generateVirtualElement'; + +type PointerState = { + isActive: boolean; + isMousePointer: boolean; + pointerHeight: number; +}; + +function usePointer(): PointerState { + const svgRef = useSvgRef(); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + }; + }, [svgRef]); + + return pointer; +} export function ItemTooltipFixedY() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); + const { isActive } = usePointer(); + + const popperRef: PopperProps['popperRef'] = React.useRef(null); + const virtualElement = React.useRef(generateVirtualElement({ x: 0, y: 0 })); const svgRef = useSvgRef(); // Get the ref of the component. const drawingArea = useDrawingArea(); // Get the dimensions of the chart inside the . - if (!tooltipData || !mousePosition) { + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleMove = (event: PointerEvent) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + // Add the y-coordinate of the to the to margin between the and the drawing area + y: svgRef.current.getBoundingClientRect().top + drawingArea.top, + }); + popperRef.current?.update(); + }; + + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef, drawingArea.top]); + + if (!tooltipData || !isActive) { // No data to display return null; } - const tooltipPosition: MousePosition = { - ...mousePosition, - // Add the y-coordinate of the to the to margin between the and the drawing area - y: svgRef.current.getBoundingClientRect().top + drawingArea.top, - }; - return ( diff --git a/docs/data/charts/tooltip/ItemTooltipTopElement.js b/docs/data/charts/tooltip/ItemTooltipTopElement.js index 7d86e69b81e4..a3b52483984e 100644 --- a/docs/data/charts/tooltip/ItemTooltipTopElement.js +++ b/docs/data/charts/tooltip/ItemTooltipTopElement.js @@ -2,14 +2,59 @@ import * as React from 'react'; import NoSsr from '@mui/material/NoSsr'; import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; import { useSvgRef, useXAxis, useXScale, useYScale } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; import { generateVirtualElement } from './generateVirtualElement'; +function usePointer() { + const svgRef = useSvgRef(); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + }; + }, [svgRef]); + + return pointer; +} + export function ItemTooltipTopElement() { const tooltipData = useItemTooltip(); - const mousePosition = useMouseTracker(); + const { isActive } = usePointer(); // Get xAxis config to access its data array. const xAxis = useXAxis(); // Get the scale which map values to SVG coordinates. @@ -21,7 +66,7 @@ export function ItemTooltipTopElement() { // Get the ref of the component. const svgRef = useSvgRef(); - if (!tooltipData || !mousePosition || !xAxis.data) { + if (!tooltipData || !isActive || !xAxis.data) { // No data to display return null; } @@ -41,7 +86,6 @@ export function ItemTooltipTopElement() { const svgXPosition = xScale(xValue) ?? 0; const tooltipPosition = { - ...mousePosition, // Add half of `yScale.step()` to be in the middle of the band. x: svgRef.current.getBoundingClientRect().left + svgXPosition + xScale.step() / 2, diff --git a/docs/data/charts/tooltip/ItemTooltipTopElement.tsx b/docs/data/charts/tooltip/ItemTooltipTopElement.tsx index d69ab33e96da..a84a45cdc39b 100644 --- a/docs/data/charts/tooltip/ItemTooltipTopElement.tsx +++ b/docs/data/charts/tooltip/ItemTooltipTopElement.tsx @@ -2,14 +2,65 @@ import * as React from 'react'; import { ScaleBand } from '@mui/x-charts-vendor/d3-scale'; import NoSsr from '@mui/material/NoSsr'; import Popper from '@mui/material/Popper'; -import { useItemTooltip, useMouseTracker } from '@mui/x-charts/ChartsTooltip'; +import { useItemTooltip } from '@mui/x-charts/ChartsTooltip'; import { useSvgRef, useXAxis, useXScale, useYScale } from '@mui/x-charts/hooks'; import { CustomItemTooltipContent } from './CustomItemTooltipContent'; -import { generateVirtualElement, MousePosition } from './generateVirtualElement'; +import { generateVirtualElement } from './generateVirtualElement'; + +type PointerState = { + isActive: boolean; + isMousePointer: boolean; + pointerHeight: number; +}; + +function usePointer(): PointerState { + const svgRef = useSvgRef(); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointer, setPointer] = React.useState({ + isActive: false, + isMousePointer: false, + pointerHeight: 0, + }); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointer((prev) => ({ + ...prev, + isActive: false, + })); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointer({ + isActive: true, + isMousePointer: event.pointerType === 'mouse', + pointerHeight: event.height, + }); + }; + + element.addEventListener('pointerenter', handleEnter); + element.addEventListener('pointerup', handleOut); + + return () => { + element.removeEventListener('pointerenter', handleEnter); + element.removeEventListener('pointerup', handleOut); + }; + }, [svgRef]); + + return pointer; +} export function ItemTooltipTopElement() { const tooltipData = useItemTooltip<'bar'>(); - const mousePosition = useMouseTracker(); + const { isActive } = usePointer(); // Get xAxis config to access its data array. const xAxis = useXAxis(); // Get the scale which map values to SVG coordinates. @@ -21,7 +72,7 @@ export function ItemTooltipTopElement() { // Get the ref of the component. const svgRef = useSvgRef(); - if (!tooltipData || !mousePosition || !xAxis.data) { + if (!tooltipData || !isActive || !xAxis.data) { // No data to display return null; } @@ -40,8 +91,7 @@ export function ItemTooltipTopElement() { const svgYPosition = yScale(tooltipData.value) ?? 0; const svgXPosition = xScale(xValue) ?? 0; - const tooltipPosition: MousePosition = { - ...mousePosition, + const tooltipPosition = { // Add half of `yScale.step()` to be in the middle of the band. x: svgRef.current.getBoundingClientRect().left + diff --git a/docs/data/charts/tooltip/tooltip.md b/docs/data/charts/tooltip/tooltip.md index 474de7b274f0..f53171fca6e5 100644 --- a/docs/data/charts/tooltip/tooltip.md +++ b/docs/data/charts/tooltip/tooltip.md @@ -139,7 +139,7 @@ It contains: - `color`: The color used to display the item. This includes the impact of [color map](/x/react-charts/styling/#values-color). - `label`, `value`, `formattedValue`: Values computed to simplify the tooltip creation. -To follow the mouse position, you can use the `useMouseTracker()`, or track events on the SVG thanks to `useSvgRef()`. +To follow the mouse position, you can track pointer events on the SVG thanks to `useSvgRef`. {{"demo": "CustomTooltipContent.js"}} @@ -153,7 +153,7 @@ It contains: - `color`: The color used to display the item. This includes the impact of [color map](/x/react-charts/styling/#values-color). - `label`, `value`, `formattedValue`: Values computed to simplify the tooltip creation. -To follow the mouse position, you can use the `useMouseTracker()`, or track events on the SVG thanks to `useSvgRef()`. +To follow the mouse position, you can track pointer events on the SVG thanks to `useSvgRef`. {{"demo": "CustomAxisTooltipContent.js"}} diff --git a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.js b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.js index e1272f2776ba..ea3b72c58ed1 100644 --- a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.js +++ b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.js @@ -9,7 +9,7 @@ export default function CustomSlotPropsCallback() { ({ - color: ownerState.open ? 'secondary' : 'primary', + color: ownerState.isPickerOpen ? 'secondary' : 'primary', }), }} /> diff --git a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx index e1272f2776ba..ea3b72c58ed1 100644 --- a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx +++ b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx @@ -9,7 +9,7 @@ export default function CustomSlotPropsCallback() { ({ - color: ownerState.open ? 'secondary' : 'primary', + color: ownerState.isPickerOpen ? 'secondary' : 'primary', }), }} /> diff --git a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx.preview b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx.preview index 100188718fae..70bc3e35f25e 100644 --- a/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx.preview +++ b/docs/data/common-concepts/custom-components/CustomSlotPropsCallback.tsx.preview @@ -1,7 +1,7 @@ ({ - color: ownerState.open ? 'secondary' : 'primary', + color: ownerState.isPickerOpen ? 'secondary' : 'primary', }), }} /> \ No newline at end of file diff --git a/docs/data/data-grid/row-grouping/row-grouping.md b/docs/data/data-grid/row-grouping/row-grouping.md index b70899fb38b4..13f56a2ebac8 100644 --- a/docs/data/data-grid/row-grouping/row-grouping.md +++ b/docs/data/data-grid/row-grouping/row-grouping.md @@ -11,6 +11,10 @@ In the following example, movies are grouped based on their production `company` {{"demo": "RowGroupingBasicExample.js", "bg": "inline", "defaultCodeOpen": false}} +:::info +If you are looking for row grouping on the server-side, see [server-side row grouping](/x/react-data-grid/server-side-data/row-grouping/). +::: + ## Grouping criteria ### Initialize the row grouping @@ -252,6 +256,10 @@ Use the `setRowChildrenExpansion` method on `apiRef` to programmatically set the {{"demo": "RowGroupingSetChildrenExpansion.js", "bg": "inline", "defaultCodeOpen": false}} +:::warning +The `apiRef.current.setRowChildrenExpansion` method is not compatible with the [server-side tree data](/x/react-data-grid/server-side-data/tree-data/) and [server-side row grouping](/x/react-data-grid/server-side-data/row-grouping/). Use `apiRef.current.unstable_dataSource.fetchRows` instead. +::: + ### Customize grouping cell indent To change the default cell indent, you can use the `--DataGrid-cellOffsetMultiplier` CSS variable: @@ -280,10 +288,6 @@ If you are rendering leaves with the `leafField` property of `groupingColDef`, t You can force the filtering to be applied on another grouping criteria with the `mainGroupingCriteria` property of `groupingColDef` -:::warning -This feature is not yet compatible with `sortingMode = "server"` and `filteringMode = "server"`. -::: - {{"demo": "RowGroupingFilteringSingleGroupingColDef.js", "bg": "inline", "defaultCodeOpen": false}} ### Multiple grouping columns @@ -376,6 +380,10 @@ const rows = apiRef.current.getRowGroupChildren({ {{"demo": "RowGroupingGetRowGroupChildren.js", "bg": "inline", "defaultCodeOpen": false}} +:::warning +The `apiRef.current.getRowGroupChildren` method is not compatible with the [server-side row grouping](/x/react-data-grid/server-side-data/row-grouping/) since all the rows might not be available to get at a given instance. +::: + ## Row group panel 🚧 :::warning diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.js b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.js new file mode 100644 index 000000000000..fc75932d136b --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.js @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingDataGrid() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns } = useMockServer({ + rowGrouping: true, + }); + + const dataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx new file mode 100644 index 000000000000..91c7f66a6d99 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { + DataGridPremium, + GridDataSource, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingDataGrid() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns } = useMockServer({ + rowGrouping: true, + }); + + const dataSource: GridDataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx.preview b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx.preview new file mode 100644 index 000000000000..920c80a41342 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingDataGrid.tsx.preview @@ -0,0 +1,16 @@ + + +
+ +
\ No newline at end of file diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.js b/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.js new file mode 100644 index 000000000000..793347a26301 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.js @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Snackbar from '@mui/material/Snackbar'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten } from '@mui/material/styles'; + +export default function ServerSideRowGroupingErrorHandling() { + const apiRef = useGridApiRef(); + const [rootError, setRootError] = React.useState(); + const [childrenError, setChildrenError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, columns } = useMockServer( + { + rowGrouping: true, + }, + {}, + shouldRequestsFail, + ); + + const dataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+
+ + setShouldRequestsFail(event.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ { + if (!params.groupKeys || params.groupKeys.length === 0) { + setRootError(error.message); + } else { + setChildrenError( + `${error.message} (Requested level: ${params.groupKeys.join(' > ')})`, + ); + } + }} + unstable_dataSourceCache={null} + apiRef={apiRef} + initialState={initialState} + /> + {rootError && } + setChildrenError('')} + message={childrenError} + /> +
+
+ ); +} + +function getBorderColor(theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }) { + if (!error) { + return null; + } + return {error}; +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.tsx b/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.tsx new file mode 100644 index 000000000000..621b74b052f4 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingErrorHandling.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { + DataGridPremium, + GridDataSource, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Snackbar from '@mui/material/Snackbar'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten, Theme } from '@mui/material/styles'; + +export default function ServerSideRowGroupingErrorHandling() { + const apiRef = useGridApiRef(); + const [rootError, setRootError] = React.useState(); + const [childrenError, setChildrenError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, columns } = useMockServer( + { + rowGrouping: true, + }, + {}, + shouldRequestsFail, + ); + + const dataSource: GridDataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+
+ + setShouldRequestsFail(event.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ { + if (!params.groupKeys || params.groupKeys.length === 0) { + setRootError(error.message); + } else { + setChildrenError( + `${error.message} (Requested level: ${params.groupKeys.join(' > ')})`, + ); + } + }} + unstable_dataSourceCache={null} + apiRef={apiRef} + initialState={initialState} + /> + {rootError && } + setChildrenError('')} + message={childrenError} + /> +
+
+ ); +} + +function getBorderColor(theme: Theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }: { error: string }) { + if (!error) { + return null; + } + return {error}; +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.js b/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.js new file mode 100644 index 000000000000..7db1ea09c9c3 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.js @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, + GridToolbar, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingFullDataGrid() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, loadNewData } = useMockServer({ + rowGrouping: true, + rowLength: 1000, + dataSet: 'Commodity', + maxColumns: 20, + }); + + const dataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['commodity', 'status'], + }, + columns: { + columnVisibilityModel: { + id: false, + }, + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.tsx b/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.tsx new file mode 100644 index 000000000000..4545cf49f9e7 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingFullDataGrid.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { + DataGridPremium, + GridDataSource, + useGridApiRef, + useKeepGroupedColumnsHidden, + GridToolbar, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingFullDataGrid() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, loadNewData } = useMockServer({ + rowGrouping: true, + rowLength: 1000, + dataSet: 'Commodity', + maxColumns: 20, + }); + + const dataSource: GridDataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['commodity', 'status'], + }, + columns: { + columnVisibilityModel: { + id: false, + }, + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.js b/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.js new file mode 100644 index 000000000000..bdb42747fc07 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.js @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingGroupExpansion() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns } = useMockServer({ + rowGrouping: true, + }); + + const dataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company'], + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.tsx b/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.tsx new file mode 100644 index 000000000000..64aa58a45d8e --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideRowGroupingGroupExpansion.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { + DataGridPremium, + GridDataSource, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import Button from '@mui/material/Button'; + +export default function ServerSideRowGroupingGroupExpansion() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns } = useMockServer({ + rowGrouping: true, + }); + + const dataSource: GridDataSource = React.useMemo(() => { + return { + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: JSON.stringify(params.paginationModel), + filterModel: JSON.stringify(params.filterModel), + sortModel: JSON.stringify(params.sortModel), + groupKeys: JSON.stringify(params.groupKeys), + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + getGroupKey: (row) => row.group, + getChildrenCount: (row) => row.descendantCount, + }; + }, [fetchRows]); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company'], + }, + }, + }); + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/row-grouping.md b/docs/data/data-grid/server-side-data/row-grouping.md index 537ef8ad0d54..25b6044f3f43 100644 --- a/docs/data/data-grid/server-side-data/row-grouping.md +++ b/docs/data/data-grid/server-side-data/row-grouping.md @@ -2,14 +2,90 @@ title: React Server-side row grouping --- -# Data Grid - Server-side row grouping [](/x/introduction/licensing/#pro-plan 'Pro plan')🚧 +# Data Grid - Server-side row grouping [](/x/introduction/licensing/#pro-plan 'Pro plan')

Lazy-loaded row grouping with server-side data source.

-:::warning -This feature isn't implemented yet. It's coming. +To dynamically load row grouping data from the server, including lazy-loading of children, create a data source and pass the `unstable_dataSource` prop to the Data Grid, as mentioned in the [overview](/x/react-data-grid/server-side-data/) section. + +:::info +If you are looking for row grouping on the client-side, see [client-side row grouping](/x/react-data-grid/row-grouping/). +::: + +Similar to the [tree data](/x/react-data-grid/server-side-data/tree-data/), you need to pass some additional properties to enable the data source row grouping feature: + +- `getGroupKey()`: Returns the group key for the row. +- `getChildrenCount()`: Returns the number of children for the row. If the children count is not available for some reason, but there are some children, returns `-1`. + +```tsx +const customDataSource: GridDataSource = { + getRows: async (params) => { + // Fetch the data from the server + }, + getGroupKey: (row) => { + // Return the group key for the row, e.g. `name` + return row.name; + }, + getChildrenCount: (row) => { + // Return the number of children for the row + return row.childrenCount; + }, +}; +``` -👍 Upvote [issue #10859](https://github.com/mui/mui-x/issues/10859) if you want to see it land faster. +In addition to `groupKeys`, the `getRows()` callback receives a `groupFields` parameter. This corresponds to the current `rowGroupingModel`. Use `groupFields` on the server to group the data for each `getRows()` call. -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. +```tsx +const getRows: async (params) => { + const urlParams = new URLSearchParams({ + // Example: JSON.stringify(['20th Century Fox', 'James Cameron']) + groupKeys: JSON.stringify(params.groupKeys), + // Example: JSON.stringify(['company', 'director']) + groupFields: JSON.stringify(params.groupFields), + }); + const getRowsResponse = await fetchRows( + // Server should group the data based on `groupFields` and + // extract the rows for the nested level based on `groupKeys` + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; +} +``` + +{{"demo": "ServerSideRowGroupingDataGrid.js", "bg": "inline"}} + +:::warning +For complex data, consider using `colDef.groupingValueGetter` to extract the grouping value. This value is passed in the `groupKeys` parameter when `getRows` is called. + +Ensure your backend can interpret the `groupKeys` parameter generated by `colDef.groupingValueGetter` to retrieve grouping values for child rows. ::: + +## Error handling + +If an error occurs during a `getRows` call, the Data Grid displays an error message in the row group cell. `unstable_onDataSourceError` is also triggered with the error and the fetch params. + +This example shows error handling with toast notifications and default error messages in grouping cells. Caching is disabled for simplicity. + +{{"demo": "ServerSideRowGroupingErrorHandling.js", "bg": "inline"}} + +## Group expansion + +The group expansion works similar to the [data source tree data](/x/react-data-grid/server-side-data/tree-data/#group-expansion). +The following demo uses `defaultGroupingExpansionDepth='-1'` to expand all the groups. + +{{"demo": "ServerSideRowGroupingGroupExpansion.js", "bg": "inline"}} + +## Demo + +In the following demo, use the auto generated data based on the `Commodities` dataset to simulate the server-side row grouping. + +{{"demo": "ServerSideRowGroupingFullDataGrid.js", "bg": "inline"}} + +## API + +- [DataGrid](/x/api/data-grid/data-grid/) +- [DataGridPro](/x/api/data-grid/data-grid-pro/) +- [DataGridPremium](/x/api/data-grid/data-grid-premium/) diff --git a/docs/data/data-grid/server-side-data/tree-data.md b/docs/data/data-grid/server-side-data/tree-data.md index 8e77fe0542bf..d5c725ac456e 100644 --- a/docs/data/data-grid/server-side-data/tree-data.md +++ b/docs/data/data-grid/server-side-data/tree-data.md @@ -8,8 +8,14 @@ title: React Server-side tree data To dynamically load tree data from the server, including lazy-loading of children, you must create a data source and pass the `unstable_dataSource` prop to the Data Grid, as detailed in the [overview section](/x/react-data-grid/server-side-data/). -The data source also requires some additional props to handle tree data, namely `getGroupKey` and `getChildrenCount`. -If the children count is not available for some reason, but there are some children, `getChildrenCount` should return `-1`. +:::info +If you are looking for tree data on the client-side, see [client-side tree data](/x/react-data-grid/tree-data/). +::: + +The data source also requires some additional props to handle tree data: + +- `getGroupKey()`: Returns the group key for the row. +- `getChildrenCount()`: Returns the number of children for the row. If the children count is not available for some reason, but there are some children, returns `-1`. ```tsx const customDataSource: GridDataSource = { @@ -27,6 +33,26 @@ const customDataSource: GridDataSource = { }; ``` +Like the other parameters such as `filterModel`, `sortModel`, and `paginationModel`, the `getRows()` callback receives a `groupKeys` parameter that corresponds to the keys provided for each nested level in `getGroupKey()`. +Use `groupKeys` on the server to extract the rows for a given nested level. + +```tsx +const getRows: async (params) => { + const urlParams = new URLSearchParams({ + // Example: JSON.stringify(['Billy Houston', 'Lora Dean']) + groupKeys: JSON.stringify(params.groupKeys), + }); + const getRowsResponse = await fetchRows( + // Server should extract the rows for the nested level based on `groupKeys` + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; +} +``` + The following tree data example supports filtering, sorting, and pagination on the server. It also caches the data by default. diff --git a/docs/data/migration/migration-pickers-v7/migration-pickers-v7.md b/docs/data/migration/migration-pickers-v7/migration-pickers-v7.md index 06190e91328c..18a1f57af80f 100644 --- a/docs/data/migration/migration-pickers-v7/migration-pickers-v7.md +++ b/docs/data/migration/migration-pickers-v7/migration-pickers-v7.md @@ -258,3 +258,59 @@ const theme = createTheme({ }, }); ``` + +## Removed types + +The following types are no longer exported by `@mui/x-date-pickers` and/or `@mui/x-date-pickers-pro`. +If you were using them, you need to replace them with the following code: + +- `UseDateFieldComponentProps` + + ```ts + import { UseDateFieldProps } from '@mui/x-date-pickers/DateField'; + import { PickerValidDate } from '@mui/x-date-pickers/models'; + + type UseDateFieldComponentProps< + TDate extends PickerValidDate, + TEnableAccessibleFieldDOMStructure extends boolean, + TChildProps extends {}, + > = Omit< + TChildProps, + keyof UseDateFieldProps + > & + UseDateFieldProps; + ``` + +- `UseTimeFieldComponentProps` + + ```ts + import { UseTimeFieldProps } from '@mui/x-date-pickers/TimeField'; + import { PickerValidDate } from '@mui/x-date-pickers/models'; + + type UseTimeFieldComponentProps< + TDate extends PickerValidDate, + TEnableAccessibleFieldDOMStructure extends boolean, + TChildProps extends {}, + > = Omit< + TChildProps, + keyof UseTimeFieldProps + > & + UseTimeFieldProps; + ``` + +- `UseDateTimeFieldComponentProps` + + ```ts + import { UseDateTimeFieldProps } from '@mui/x-date-pickers/DateTimeField'; + import { PickerValidDate } from '@mui/x-date-pickers/models'; + + type UseDateTimeFieldComponentProps< + TDate extends PickerValidDate, + TEnableAccessibleFieldDOMStructure extends boolean, + TChildProps extends {}, + > = Omit< + TChildProps, + keyof UseDateTimeFieldProps + > & + UseDateTimeFieldProps; + ``` diff --git a/docs/data/pages.ts b/docs/data/pages.ts index bae512de2940..361b46837c94 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -120,8 +120,9 @@ const pages: MuiPage[] = [ pathname: '/x/react-data-grid/server-side-data-group', title: 'Server-side data', plan: 'pro', + newFeature: true, children: [ - { pathname: '/x/react-data-grid/server-side-data', title: 'Overview' }, + { pathname: '/x/react-data-grid/server-side-data', title: 'Overview', plan: 'pro' }, { pathname: '/x/react-data-grid/server-side-data/tree-data', plan: 'pro' }, { pathname: '/x/react-data-grid/server-side-data/lazy-loading', @@ -135,8 +136,7 @@ const pages: MuiPage[] = [ }, { pathname: '/x/react-data-grid/server-side-data/row-grouping', - plan: 'pro', - planned: true, + plan: 'premium', }, { pathname: '/x/react-data-grid/server-side-data/aggregation', diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx index 4566eddb96d6..51c770bbb827 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx @@ -11,11 +11,13 @@ import { InteractionContext, ItemInteractionData, } from '../context/InteractionProvider'; +import { useSvgRef } from '../hooks/useSvgRef'; import { generateVirtualElement, - useMouseTracker, getTooltipHasData, TriggerOptions, + usePointerType, + VirtualElement, } from './utils'; import { ChartSeriesType } from '../models/seriesType/config'; import { ChartsItemContentProps, ChartsItemTooltipContent } from './ChartsItemTooltipContent'; @@ -133,14 +135,22 @@ function ChartsTooltip(inProps: ChartsTooltipProps }); const { trigger = 'axis', itemContent, axisContent, slots, slotProps } = props; - const mousePosition = useMouseTracker(); + const svgRef = useSvgRef(); + const pointerType = usePointerType(); + + const popperRef: PopperProps['popperRef'] = React.useRef(null); + + const virtualElement = React.useRef(null); + if (virtualElement.current === null) { + virtualElement.current = generateVirtualElement(null); + } const { item, axis } = React.useContext(InteractionContext); const displayedData = trigger === 'item' ? item : axis; const tooltipHasData = getTooltipHasData(trigger, displayedData); - const popperOpen = mousePosition !== null && tooltipHasData; + const popperOpen = pointerType !== null && tooltipHasData; const classes = useUtilityClasses({ classes: props.classes }); @@ -150,14 +160,14 @@ function ChartsTooltip(inProps: ChartsTooltipProps externalSlotProps: slotProps?.popper, additionalProps: { open: popperOpen, - placement: - mousePosition?.pointerType === 'mouse' ? ('right-start' as const) : ('top' as const), - anchorEl: generateVirtualElement(mousePosition), + placement: pointerType?.pointerType === 'mouse' ? ('right-start' as const) : ('top' as const), + popperRef, + anchorEl: virtualElement.current, modifiers: [ { name: 'offset', options: { - offset: [0, mousePosition?.pointerType === 'touch' ? 40 - mousePosition.height : 0], + offset: [0, pointerType?.pointerType === 'touch' ? 40 - pointerType.height : 0], }, }, ], @@ -165,6 +175,26 @@ function ChartsTooltip(inProps: ChartsTooltipProps ownerState: {}, }); + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleMove = (event: PointerEvent) => { + virtualElement.current = generateVirtualElement({ + x: event.clientX, + y: event.clientY, + }); + popperRef.current?.update(); + }; + element.addEventListener('pointermove', handleMove); + + return () => { + element.removeEventListener('pointermove', handleMove); + }; + }, [svgRef]); + if (trigger === 'none') { return null; } diff --git a/packages/x-charts/src/ChartsTooltip/contentDisplayed.test.tsx b/packages/x-charts/src/ChartsTooltip/contentDisplayed.test.tsx index 6b4634241e3d..bde48ebc9f35 100644 --- a/packages/x-charts/src/ChartsTooltip/contentDisplayed.test.tsx +++ b/packages/x-charts/src/ChartsTooltip/contentDisplayed.test.tsx @@ -54,6 +54,7 @@ describe('ChartsTooltip', () => { ); const svg = document.querySelector('svg')!; + fireEvent.pointerEnter(svg); // Trigger the tooltip firePointerEvent(svg, 'pointermove', { clientX: 198, clientY: 60, @@ -120,6 +121,7 @@ describe('ChartsTooltip', () => { ); const svg = document.querySelector('svg')!; + fireEvent.pointerEnter(svg); // Trigger the tooltip firePointerEvent(svg, 'pointermove', { clientX: 150, clientY: 60, @@ -191,6 +193,7 @@ describe('ChartsTooltip', () => { fireEvent.pointerEnter(rectangles[0]); + fireEvent.pointerEnter(svg); // Trigger the tooltip firePointerEvent(svg, 'pointermove', { clientX: 150, clientY: 60, @@ -235,6 +238,7 @@ describe('ChartsTooltip', () => { fireEvent.pointerEnter(rectangles[0]); + fireEvent.pointerEnter(svg); // Trigger the tooltip firePointerEvent(svg, 'pointermove', { clientX: 150, clientY: 60, diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index 89faff2a7b17..24e2e2220391 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -10,8 +10,28 @@ type MousePosition = { height: number; }; -export function generateVirtualElement(mousePosition: MousePosition | null) { - if (mousePosition === null) { +export type VirtualElement = { + getBoundingClientRect: () => { + width: number; + height: number; + x: number; + y: number; + top: number; + right: number; + bottom: number; + left: number; + toJSON: () => string; + }; +}; +/** + * Generate a virtual element for the tooltip. + * Default to (0, 0) is the argument is not provided, or null. + * @param mousePosition { x: number, y: number} + */ +export function generateVirtualElement( + mousePosition?: Pick | null, +): VirtualElement { + if (!mousePosition) { return { getBoundingClientRect: () => ({ width: 0, @@ -47,6 +67,9 @@ export function generateVirtualElement(mousePosition: MousePosition | null) { export type UseMouseTrackerReturnValue = null | MousePosition; +/** + * @deprecated We recommend using vanilla JS to let popper track mouse position. + */ export function useMouseTracker(): UseMouseTrackerReturnValue { const svgRef = useSvgRef(); @@ -59,6 +82,8 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { return () => {}; } + const controller = new AbortController(); + const handleOut = (event: PointerEvent) => { if (event.pointerType !== 'mouse') { setMousePosition(null); @@ -74,18 +99,57 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { }); }; - element.addEventListener('pointerdown', handleMove); - element.addEventListener('pointermove', handleMove); + element.addEventListener('pointerdown', handleMove, { signal: controller.signal }); + element.addEventListener('pointermove', handleMove, { signal: controller.signal }); + element.addEventListener('pointerup', handleOut, { signal: controller.signal }); + + return () => { + // Calling `.abort()` removes ALL event listeners + // For more info, see https://kettanaito.com/blog/dont-sleep-on-abort-controller + controller.abort(); + }; + }, [svgRef]); + + return mousePosition; +} + +type PointerType = Pick; + +export function usePointerType(): null | PointerType { + const svgRef = useSvgRef(); + + // Use a ref to avoid rerendering on every mousemove event. + const [pointerType, setPointerType] = React.useState(null); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null) { + return () => {}; + } + + const handleOut = (event: PointerEvent) => { + if (event.pointerType !== 'mouse') { + setPointerType(null); + } + }; + + const handleEnter = (event: PointerEvent) => { + setPointerType({ + height: event.height, + pointerType: event.pointerType as PointerType['pointerType'], + }); + }; + + element.addEventListener('pointerenter', handleEnter); element.addEventListener('pointerup', handleOut); return () => { - element.removeEventListener('pointerdown', handleMove); - element.removeEventListener('pointermove', handleMove); + element.removeEventListener('pointerenter', handleEnter); element.removeEventListener('pointerup', handleOut); }; }, [svgRef]); - return mousePosition; + return pointerType; } export type TriggerOptions = 'item' | 'axis' | 'none'; diff --git a/packages/x-data-grid-generator/src/hooks/index.ts b/packages/x-data-grid-generator/src/hooks/index.ts index 84dd7368aea4..223d6170c94b 100644 --- a/packages/x-data-grid-generator/src/hooks/index.ts +++ b/packages/x-data-grid-generator/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useDemoData'; export * from './useBasicDemoData'; -export * from './useMovieData'; +export { useMovieData } from './useMovieData'; +export type { Movie } from './useMovieData'; export * from './useQuery'; export * from './useMockServer'; export { loadServerRows } from './serverUtils'; diff --git a/packages/x-data-grid-generator/src/hooks/serverUtils.ts b/packages/x-data-grid-generator/src/hooks/serverUtils.ts index 095893126643..80651b133af5 100644 --- a/packages/x-data-grid-generator/src/hooks/serverUtils.ts +++ b/packages/x-data-grid-generator/src/hooks/serverUtils.ts @@ -10,7 +10,6 @@ import { GridValidRowModel, } from '@mui/x-data-grid-pro'; import { GridStateColDef } from '@mui/x-data-grid-pro/internals'; -import { UseDemoDataOptions } from './useDemoData'; import { randomInt } from '../services/random-generator'; export interface FakeServerResponse { @@ -53,14 +52,9 @@ export interface ServerSideQueryOptions { sortModel?: GridSortModel; firstRowToRender?: number; lastRowToRender?: number; + groupFields?: string[]; } -export const DEFAULT_DATASET_OPTIONS: UseDemoDataOptions = { - dataSet: 'Commodity', - rowLength: 100, - maxColumns: 6, -}; - declare const DISABLE_CHANCE_RANDOM: any; export const disableDelay = typeof DISABLE_CHANCE_RANDOM !== 'undefined' && DISABLE_CHANCE_RANDOM; @@ -323,7 +317,7 @@ export const loadServerRows = ( }); }; -interface ProcessTreeDataRowsResponse { +interface NestedDataRowsResponse { rows: GridRowModel[]; rootRowCount: number; } @@ -333,6 +327,7 @@ const findTreeDataRowChildren = ( parentPath: string[], pathKey: string = 'path', depth: number = 1, // the depth of the children to find relative to parentDepth, `-1` to find all + rowQualifier?: (row: GridRowModel) => boolean, ) => { const parentDepth = parentPath.length; const children = []; @@ -346,7 +341,9 @@ const findTreeDataRowChildren = ( ((depth < 0 && rowPath.length > parentDepth) || rowPath.length === parentDepth + depth) && parentPath.every((value, index) => value === rowPath[index]) ) { - children.push(row); + if (!rowQualifier || rowQualifier(row)) { + children.push(row); + } } } return children; @@ -427,14 +424,14 @@ const getTreeDataFilteredRows: GetTreeDataFilteredRows = ( }; /** - * Simulates server data loading + * Simulates server data for tree-data feature */ export const processTreeDataRows = ( rows: GridRowModel[], queryOptions: ServerSideQueryOptions, serverOptions: ServerOptions, columnsWithDefaultColDef: GridColDef[], -): Promise => { +): Promise => { const { minDelay = 100, maxDelay = 300 } = serverOptions; const pathKey = 'path'; // TODO: Support filtering and cursor based pagination @@ -490,3 +487,124 @@ export const processTreeDataRows = ( }, delay); // simulate network latency }); }; + +/** + * Simulates server data for row grouping feature + */ +export const processRowGroupingRows = ( + rows: GridValidRowModel[], + queryOptions: ServerSideQueryOptions, + serverOptions: ServerOptions, + columnsWithDefaultColDef: GridColDef[], +): Promise => { + const { minDelay = 100, maxDelay = 300 } = serverOptions; + const pathKey = 'path'; + + if (maxDelay < minDelay) { + throw new Error('serverOptions.minDelay is larger than serverOptions.maxDelay '); + } + + if (queryOptions.groupKeys == null) { + throw new Error('serverOptions.groupKeys must be defined to compute row grouping data'); + } + + if (queryOptions.groupFields == null) { + throw new Error('serverOptions.groupFields must be defined to compute row grouping data'); + } + + const delay = randomInt(minDelay, maxDelay); + + const pathsToAutogenerate = new Set(); + let rowsWithPaths = rows; + const rowsWithMissingGroups: GridValidRowModel[] = []; + + // add paths and generate parent rows based on `groupFields` + const groupFields = queryOptions.groupFields; + if (groupFields.length > 0) { + rowsWithPaths = rows.reduce((acc, row) => { + const partialPath = groupFields.map((field) => String(row[field])); + for (let index = 0; index < partialPath.length; index += 1) { + const value = partialPath[index]; + if (value === undefined) { + if (index === 0) { + rowsWithMissingGroups.push({ ...row, group: false }); + } + return acc; + } + const parentPath = partialPath.slice(0, index + 1); + const strigifiedPath = parentPath.join(','); + if (!pathsToAutogenerate.has(strigifiedPath)) { + pathsToAutogenerate.add(strigifiedPath); + } + } + acc.push({ ...row, path: [...partialPath, ''] }); + return acc; + }, []); + } else { + rowsWithPaths = rows.map((row) => ({ ...row, path: [''] })); + } + + const autogeneratedRows = Array.from(pathsToAutogenerate).map((path) => { + const pathArray = path.split(','); + return { + id: `auto-generated-parent-${pathArray.join('-')}`, + path: pathArray.slice(0, pathArray.length), + group: pathArray.slice(-1)[0], + }; + }); + + // apply plain filtering + const filteredRows = getTreeDataFilteredRows( + [...autogeneratedRows, ...rowsWithPaths, ...rowsWithMissingGroups], + queryOptions.filterModel, + columnsWithDefaultColDef, + ) as GridValidRowModel[]; + + // get root row count + const rootRows = findTreeDataRowChildren(filteredRows, []); + const rootRowCount = rootRows.length; + + let filteredRowsWithMissingGroups: GridValidRowModel[] = []; + let childRows = rootRows; + if (queryOptions.groupKeys.length === 0) { + filteredRowsWithMissingGroups = filteredRows.filter(({ group }) => group === false); + } else { + childRows = findTreeDataRowChildren(filteredRows, queryOptions.groupKeys); + } + + let childRowsWithDescendantCounts = childRows.map((row) => { + const descendants = findTreeDataRowChildren( + filteredRows, + row[pathKey], + pathKey, + -1, + ({ id }) => typeof id !== 'string' || !id.startsWith('auto-generated-parent-'), + ); + const descendantCount = descendants.length; + return { ...row, descendantCount } as GridRowModel; + }); + + if (queryOptions.sortModel) { + const rowComparator = getRowComparator(queryOptions.sortModel, columnsWithDefaultColDef); + const sortedMissingGroups = [...filteredRowsWithMissingGroups].sort(rowComparator); + const sortedChildRows = [...childRowsWithDescendantCounts].sort(rowComparator); + childRowsWithDescendantCounts = [...sortedMissingGroups, ...sortedChildRows]; + } + + if (queryOptions.paginationModel && queryOptions.groupKeys.length === 0) { + // Only paginate root rows, grid should refetch root rows when `paginationModel` updates + const { pageSize, page } = queryOptions.paginationModel; + if (pageSize < childRowsWithDescendantCounts.length) { + childRowsWithDescendantCounts = childRowsWithDescendantCounts.slice( + page * pageSize, + (page + 1) * pageSize, + ); + } + } + + return new Promise((resolve) => { + setTimeout(() => { + resolve({ rows: childRowsWithDescendantCounts, rootRowCount }); + }, delay); // simulate network latency + }); +}; diff --git a/packages/x-data-grid-generator/src/hooks/useMockServer.ts b/packages/x-data-grid-generator/src/hooks/useMockServer.ts index 83c43fd30c05..e9c168f0ca08 100644 --- a/packages/x-data-grid-generator/src/hooks/useMockServer.ts +++ b/packages/x-data-grid-generator/src/hooks/useMockServer.ts @@ -9,23 +9,24 @@ import { GridInitialState, GridColumnVisibilityModel, } from '@mui/x-data-grid-pro'; -import { - UseDemoDataOptions, - getColumnsFromOptions, - extrapolateSeed, - deepFreeze, -} from './useDemoData'; +import { extrapolateSeed, deepFreeze } from './useDemoData'; +import { getCommodityColumns } from '../columns/commodities.columns'; +import { getEmployeeColumns } from '../columns/employees.columns'; import { GridColDefGenerator } from '../services/gridColDefGenerator'; import { getRealGridData, GridDemoData } from '../services/real-data-service'; -import { addTreeDataOptionsToDemoData } from '../services/tree-data-generator'; +import { + addTreeDataOptionsToDemoData, + AddPathToDemoDataOptions, +} from '../services/tree-data-generator'; import { loadServerRows, processTreeDataRows, - DEFAULT_DATASET_OPTIONS, + processRowGroupingRows, DEFAULT_SERVER_OPTIONS, } from './serverUtils'; import type { ServerOptions } from './serverUtils'; import { randomInt } from '../services'; +import { getMovieRows, getMovieColumns } from './useMovieData'; const dataCache = new LRUCache({ max: 10, @@ -43,6 +44,66 @@ type UseMockServerResponse = { loadNewData: () => void; }; +type DataSet = 'Commodity' | 'Employee' | 'Movies'; + +interface UseMockServerOptions { + dataSet: DataSet; + /** + * Has no effect when DataSet='Movies' + */ + rowLength: number; + maxColumns?: number; + visibleFields?: string[]; + editable?: boolean; + treeData?: AddPathToDemoDataOptions; + rowGrouping?: boolean; +} + +interface GridMockServerData { + rows: GridRowModel[]; + columns: GridColDefGenerator[] | GridColDef[]; + initialState?: GridInitialState; +} + +interface ColumnsOptions + extends Pick {} + +const GET_DEFAULT_DATASET_OPTIONS: (isRowGrouping: boolean) => UseMockServerOptions = ( + isRowGrouping, +) => ({ + dataSet: isRowGrouping ? 'Movies' : 'Commodity', + rowLength: isRowGrouping ? getMovieRows().length : 100, + maxColumns: 6, +}); + +const getColumnsFromOptions = (options: ColumnsOptions): GridColDefGenerator[] | GridColDef[] => { + let columns; + + switch (options.dataSet) { + case 'Commodity': + columns = getCommodityColumns(options.editable); + break; + case 'Employee': + columns = getEmployeeColumns(); + break; + case 'Movies': + columns = getMovieColumns(); + break; + default: + throw new Error('Unknown dataset'); + } + + if (options.visibleFields) { + columns = columns.map((col) => + options.visibleFields?.includes(col.field) ? col : { ...col, hide: true }, + ); + } + if (options.maxColumns) { + columns = columns.slice(0, options.maxColumns); + } + return columns; +}; + function decodeParams(url: string): GridGetRowsParams { const params = new URL(url).searchParams; const decodedParams = {} as any; @@ -76,12 +137,18 @@ const getInitialState = (columns: GridColDefGenerator[], groupingField?: string) const defaultColDef = getGridDefaultColumnTypes(); +function sendEmptyResponse() { + return new Promise((resolve) => { + resolve({ rows: [], rowCount: 0 }); + }); +} + export const useMockServer = ( - dataSetOptions?: Partial, + dataSetOptions?: Partial, serverOptions?: ServerOptions & { verbose?: boolean }, shouldRequestsFail?: boolean, ): UseMockServerResponse => { - const [data, setData] = React.useState(); + const [data, setData] = React.useState(); const [index, setIndex] = React.useState(0); const shouldRequestsFailRef = React.useRef(shouldRequestsFail ?? false); @@ -91,7 +158,11 @@ export const useMockServer = ( } }, [shouldRequestsFail]); - const options = { ...DEFAULT_DATASET_OPTIONS, ...dataSetOptions }; + const isRowGrouping = dataSetOptions?.rowGrouping ?? false; + + const options = { ...GET_DEFAULT_DATASET_OPTIONS(isRowGrouping), ...dataSetOptions }; + + const isTreeData = options.treeData?.groupingField != null; const columns = React.useMemo(() => { return getColumnsFromOptions({ @@ -116,8 +187,6 @@ export const useMockServer = ( [columns], ); - const isTreeData = options.treeData?.groupingField != null; - const getGroupKey = React.useMemo(() => { if (isTreeData) { return (row: GridRowModel): string => row[options.treeData!.groupingField!]; @@ -144,6 +213,13 @@ export const useMockServer = ( return undefined; } + if (options.dataSet === 'Movies') { + const rowsData = { rows: getMovieRows(), columns }; + setData(rowsData); + dataCache.set(cacheKey, rowsData); + return undefined; + } + let active = true; (async () => { @@ -193,10 +269,8 @@ export const useMockServer = ( const fetchRows = React.useCallback( async (requestUrl: string): Promise => { - if (!data || !requestUrl) { - return new Promise((resolve) => { - resolve({ rows: [], rowCount: 0 }); - }); + if (!requestUrl || !data?.rows) { + return sendEmptyResponse(); } const params = decodeParams(requestUrl); const verbose = serverOptions?.verbose ?? true; @@ -224,9 +298,21 @@ export const useMockServer = ( }); } - if (isTreeData /* || TODO: `isRowGrouping` */) { + if (isTreeData) { const { rows, rootRowCount } = await processTreeDataRows( - data.rows, + data?.rows ?? [], + params, + serverOptionsWithDefault, + columnsWithDefaultColDef, + ); + + getRowsResponse = { + rows: rows.slice().map((row) => ({ ...row, path: undefined })), + rowCount: rootRowCount, + }; + } else if (isRowGrouping) { + const { rows, rootRowCount } = await processRowGroupingRows( + data?.rows ?? [], params, serverOptionsWithDefault, columnsWithDefaultColDef, @@ -237,9 +323,8 @@ export const useMockServer = ( rowCount: rootRowCount, }; } else { - // plain data const { returnedRows, nextCursor, totalRowCount } = await loadServerRows( - data.rows, + data?.rows ?? [], { ...params, ...params.paginationModel }, serverOptionsWithDefault, columnsWithDefaultColDef, @@ -262,12 +347,13 @@ export const useMockServer = ( serverOptions?.useCursorPagination, isTreeData, columnsWithDefaultColDef, + isRowGrouping, ], ); return { columns: columnsWithDefaultColDef, - initialState, + initialState: options.dataSet === 'Movies' ? {} : initialState, getGroupKey, getChildrenCount, fetchRows, diff --git a/packages/x-data-grid-generator/src/hooks/useMovieData.ts b/packages/x-data-grid-generator/src/hooks/useMovieData.ts index 7820a7471fde..b9b196716839 100644 --- a/packages/x-data-grid-generator/src/hooks/useMovieData.ts +++ b/packages/x-data-grid-generator/src/hooks/useMovieData.ts @@ -546,6 +546,9 @@ const ROWS: GridRowModel[] = [ }, ]; +export const getMovieColumns = (): GridColDef[] => COLUMNS; +export const getMovieRows = (): GridRowModel[] => ROWS; + export const useMovieData = () => { return { rows: ROWS, diff --git a/packages/x-data-grid-generator/src/hooks/useQuery.ts b/packages/x-data-grid-generator/src/hooks/useQuery.ts index 62ae140bcdcd..5387a7e1f485 100644 --- a/packages/x-data-grid-generator/src/hooks/useQuery.ts +++ b/packages/x-data-grid-generator/src/hooks/useQuery.ts @@ -7,9 +7,15 @@ import { getColumnsFromOptions, getInitialState, } from './useDemoData'; -import { DEFAULT_DATASET_OPTIONS, DEFAULT_SERVER_OPTIONS, loadServerRows } from './serverUtils'; +import { DEFAULT_SERVER_OPTIONS, loadServerRows } from './serverUtils'; import type { ServerOptions, QueryOptions, PageInfo } from './serverUtils'; +const DEFAULT_DATASET_OPTIONS: UseDemoDataOptions = { + dataSet: 'Commodity', + rowLength: 100, + maxColumns: 6, +}; + export const createFakeServer = ( dataSetOptions?: Partial, serverOptions?: ServerOptions, diff --git a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx index 8106dec99163..46c76914dda7 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx @@ -86,6 +86,7 @@ import { rowGroupingStateInitializer, } from '../hooks/features/rowGrouping/useGridRowGrouping'; import { useGridRowGroupingPreProcessors } from '../hooks/features/rowGrouping/useGridRowGroupingPreProcessors'; +import { useGridDataSourceRowGroupingPreProcessors } from '../hooks/features/rowGrouping/useGridDataSourceRowGroupingPreProcessors'; import { useGridExcelExport } from '../hooks/features/export/useGridExcelExport'; import { cellSelectionStateInitializer, @@ -105,6 +106,7 @@ export const useDataGridPremiumComponent = ( useGridRowSelectionPreProcessors(apiRef, props); useGridRowReorderPreProcessors(apiRef, props); useGridRowGroupingPreProcessors(apiRef, props); + useGridDataSourceRowGroupingPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); useGridDataSourceTreeDataPreProcessors(apiRef, props); useGridLazyLoaderPreProcessors(apiRef, props); diff --git a/packages/x-data-grid-premium/src/components/GridDataSourceGroupingCriteriaCell.tsx b/packages/x-data-grid-premium/src/components/GridDataSourceGroupingCriteriaCell.tsx new file mode 100644 index 000000000000..0f7f159ee84c --- /dev/null +++ b/packages/x-data-grid-premium/src/components/GridDataSourceGroupingCriteriaCell.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { unstable_composeClasses as composeClasses } from '@mui/utils'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { useGridPrivateApiContext } from '@mui/x-data-grid-pro/internals'; +import { + useGridSelector, + getDataGridUtilityClass, + GridRenderCellParams, + GridGroupNode, +} from '@mui/x-data-grid-pro'; +import { useGridApiContext } from '../hooks/utils/useGridApiContext'; +import { useGridRootProps } from '../hooks/utils/useGridRootProps'; +import { DataGridPremiumProcessedProps } from '../models/dataGridPremiumProps'; +import { GridPrivateApiPremium } from '../models/gridApiPremium'; +import { GridStatePremium } from '../models/gridStatePremium'; + +type OwnerState = DataGridPremiumProcessedProps; + +const useUtilityClasses = (ownerState: OwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['groupingCriteriaCell'], + toggle: ['groupingCriteriaCellToggle'], + loadingContainer: ['groupingCriteriaCellLoadingContainer'], + }; + + return composeClasses(slots, getDataGridUtilityClass, classes); +}; + +interface GridGroupingCriteriaCellProps extends GridRenderCellParams { + hideDescendantCount?: boolean; +} + +interface GridGroupingCriteriaCellIconProps + extends Pick { + descendantCount: number; +} + +function GridGroupingCriteriaCellIcon(props: GridGroupingCriteriaCellIconProps) { + const apiRef = useGridPrivateApiContext() as React.MutableRefObject; + const rootProps = useGridRootProps(); + const classes = useUtilityClasses(rootProps); + const { rowNode, id, field, descendantCount } = props; + + const loadingSelector = (state: GridStatePremium) => state.dataSource.loading[id] ?? false; + const errorSelector = (state: GridStatePremium) => state.dataSource.errors[id]; + const isDataLoading = useGridSelector(apiRef, loadingSelector); + const error = useGridSelector(apiRef, errorSelector); + + const handleClick = (event: React.MouseEvent) => { + if (!rowNode.childrenExpanded) { + // always fetch/get from cache the children when the node is expanded + apiRef.current.unstable_dataSource.fetchRows(id); + } else { + apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded); + } + apiRef.current.setCellFocus(id, field); + event.stopPropagation(); + }; + + const Icon = rowNode.childrenExpanded + ? rootProps.slots.groupingCriteriaCollapseIcon + : rootProps.slots.groupingCriteriaExpandIcon; + + if (isDataLoading) { + return ( +
+ +
+ ); + } + + return descendantCount > 0 ? ( + + + + + + + + ) : null; +} + +export function GridDataSourceGroupingCriteriaCell(props: GridGroupingCriteriaCellProps) { + const { id, field, rowNode, hideDescendantCount, formattedValue } = props; + + const rootProps = useGridRootProps(); + const apiRef = useGridApiContext(); + const rowSelector = (state: GridStatePremium) => state.rows.dataRowIdToModelLookup[id]; + const row = useGridSelector(apiRef, rowSelector); + const classes = useUtilityClasses(rootProps); + + let descendantCount = 0; + if (row) { + descendantCount = Math.max(rootProps.unstable_dataSource?.getChildrenCount?.(row) ?? 0, 0); + } + + let cellContent: React.ReactNode; + + const colDef = apiRef.current.getColumn(rowNode.groupingField!); + if (typeof colDef?.renderCell === 'function') { + cellContent = colDef.renderCell(props); + } else if (typeof formattedValue !== 'undefined') { + cellContent = {formattedValue}; + } else { + cellContent = {rowNode.groupingKey}; + } + + return ( + + `calc(var(--DataGrid-cellOffsetMultiplier) * ${theme.spacing(rowNode.depth)})`, + }} + > +
+ +
+ {cellContent} + {!hideDescendantCount && descendantCount > 0 ? ( + ({descendantCount}) + ) : null} +
+ ); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/createGroupingColDef.tsx b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/createGroupingColDef.tsx index caa1a42af8f5..194c62efa521 100644 --- a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/createGroupingColDef.tsx +++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/createGroupingColDef.tsx @@ -12,10 +12,12 @@ import { GridColumnRawLookup, isSingleSelectColDef } from '@mui/x-data-grid-pro/ import { GridApiPremium } from '../../../models/gridApiPremium'; import { GridGroupingColumnFooterCell } from '../../../components/GridGroupingColumnFooterCell'; import { GridGroupingCriteriaCell } from '../../../components/GridGroupingCriteriaCell'; +import { GridDataSourceGroupingCriteriaCell } from '../../../components/GridDataSourceGroupingCriteriaCell'; import { GridGroupingColumnLeafCell } from '../../../components/GridGroupingColumnLeafCell'; import { getRowGroupingFieldFromGroupingCriteria, GRID_ROW_GROUPING_SINGLE_GROUPING_FIELD, + RowGroupingStrategy, } from './gridRowGroupingUtils'; import { gridRowGroupingSanitizedModelSelector } from './gridRowGroupingSelector'; @@ -25,11 +27,24 @@ const GROUPING_COL_DEF_DEFAULT_PROPERTIES: Omit = { disableReorder: true, }; -const GROUPING_COL_DEF_FORCED_PROPERTIES: Pick = { +const GROUPING_COL_DEF_FORCED_PROPERTIES_DEFAULT: Pick< + GridColDef, + 'type' | 'editable' | 'groupable' +> = { editable: false, groupable: false, }; +const GROUPING_COL_DEF_FORCED_PROPERTIES_DATA_SOURCE: Pick< + GridColDef, + 'type' | 'editable' | 'groupable' | 'filterable' | 'sortable' | 'aggregable' +> = { + ...GROUPING_COL_DEF_FORCED_PROPERTIES_DEFAULT, + // TODO: Support these features on the grouping column(s) + filterable: false, + sortable: false, +}; + /** * When sorting two cells with different grouping criteria, we consider that the cell with the grouping criteria coming first in the model should be displayed below. * This can occur when some rows don't have all the fields. In which case we want the rows with the missing field to be displayed above. @@ -122,6 +137,7 @@ interface CreateGroupingColDefMonoCriteriaParams { * This value comes `prop.groupingColDef`. */ colDefOverride: GridGroupingColDefOverride | null | undefined; + strategy?: RowGroupingStrategy; } /** @@ -132,11 +148,17 @@ export const createGroupingColDefForOneGroupingCriteria = ({ groupedByColDef, groupingCriteria, colDefOverride, + strategy = RowGroupingStrategy.Default, }: CreateGroupingColDefMonoCriteriaParams): GridColDef => { const { leafField, mainGroupingCriteria, hideDescendantCount, ...colDefOverrideProperties } = colDefOverride ?? {}; const leafColDef = leafField ? columnsLookup[leafField] : null; + const CriteriaCell = + strategy === RowGroupingStrategy.Default + ? GridGroupingCriteriaCell + : GridDataSourceGroupingCriteriaCell; + // The properties that do not depend on the presence of a `leafColDef` and that can be overridden by `colDefOverride` const commonProperties: Partial = { width: Math.max( @@ -170,7 +192,7 @@ export const createGroupingColDefForOneGroupingCriteria = ({ // Render current grouping criteria groups if (params.rowNode.groupingField === groupingCriteria) { return ( - )} hideDescendantCount={hideDescendantCount} /> @@ -222,7 +244,7 @@ export const createGroupingColDefForOneGroupingCriteria = ({ // The properties that can't be overridden with `colDefOverride` const forcedProperties: Pick = { field: getRowGroupingFieldFromGroupingCriteria(groupingCriteria), - ...GROUPING_COL_DEF_FORCED_PROPERTIES, + ...GROUPING_COL_DEF_FORCED_PROPERTIES_DEFAULT, }; return { @@ -246,6 +268,7 @@ interface CreateGroupingColDefSeveralCriteriaParams { * This value comes `prop.groupingColDef`. */ colDefOverride: GridGroupingColDefOverride | null | undefined; + strategy?: RowGroupingStrategy; } /** @@ -256,11 +279,17 @@ export const createGroupingColDefForAllGroupingCriteria = ({ columnsLookup, rowGroupingModel, colDefOverride, + strategy = RowGroupingStrategy.Default, }: CreateGroupingColDefSeveralCriteriaParams): GridColDef => { const { leafField, mainGroupingCriteria, hideDescendantCount, ...colDefOverrideProperties } = colDefOverride ?? {}; const leafColDef = leafField ? columnsLookup[leafField] : null; + const CriteriaCell = + strategy === RowGroupingStrategy.Default + ? GridGroupingCriteriaCell + : GridDataSourceGroupingCriteriaCell; + // The properties that do not depend on the presence of a `leafColDef` and that can be overridden by `colDefOverride` const commonProperties: Partial = { headerName: apiRef.current.getLocaleText('groupingColumnHeaderName'), @@ -296,7 +325,7 @@ export const createGroupingColDefForAllGroupingCriteria = ({ // Render the groups return ( - )} hideDescendantCount={hideDescendantCount} /> @@ -344,7 +373,9 @@ export const createGroupingColDefForAllGroupingCriteria = ({ // The properties that can't be overridden with `colDefOverride` const forcedProperties: Pick = { field: GRID_ROW_GROUPING_SINGLE_GROUPING_FIELD, - ...GROUPING_COL_DEF_FORCED_PROPERTIES, + ...(strategy === RowGroupingStrategy.Default + ? GROUPING_COL_DEF_FORCED_PROPERTIES_DEFAULT + : GROUPING_COL_DEF_FORCED_PROPERTIES_DATA_SOURCE), }; return { diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts index 1cdec6a7c663..9d1e3ddcafae 100644 --- a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts +++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts @@ -9,6 +9,7 @@ import { GridRowModel, GridColDef, GridKeyValue, + GridDataSource, } from '@mui/x-data-grid-pro'; import { passFilterLogic, @@ -28,7 +29,10 @@ import { GridPrivateApiPremium } from '../../../models/gridApiPremium'; export const GRID_ROW_GROUPING_SINGLE_GROUPING_FIELD = '__row_group_by_columns_group__'; -export const ROW_GROUPING_STRATEGY = 'grouping-columns'; +export enum RowGroupingStrategy { + Default = 'grouping-columns', + DataSource = 'grouping-columns-data-source', +} export const getRowGroupingFieldFromGroupingCriteria = (groupingCriteria: string | null) => { if (groupingCriteria === null) { @@ -178,10 +182,11 @@ export const filterRowTreeFromGroupingColumns = ( export const getColDefOverrides = ( groupingColDefProp: DataGridPremiumProcessedProps['groupingColDef'], fields: string[], + strategy?: RowGroupingStrategy, ) => { if (typeof groupingColDefProp === 'function') { return groupingColDefProp({ - groupingName: ROW_GROUPING_STRATEGY, + groupingName: strategy ?? RowGroupingStrategy.Default, fields, }); } @@ -199,6 +204,7 @@ export const mergeStateWithRowGroupingModel = export const setStrategyAvailability = ( privateApiRef: React.MutableRefObject, disableRowGrouping: boolean, + dataSource?: GridDataSource, ) => { let isAvailable: () => boolean; if (disableRowGrouping) { @@ -210,7 +216,9 @@ export const setStrategyAvailability = ( }; } - privateApiRef.current.setStrategyAvailability('rowTree', ROW_GROUPING_STRATEGY, isAvailable); + const strategy = dataSource ? RowGroupingStrategy.DataSource : RowGroupingStrategy.Default; + + privateApiRef.current.setStrategyAvailability('rowTree', strategy, isAvailable); }; export const getCellGroupingCriteria = ({ diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridDataSourceRowGroupingPreProcessors.ts b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridDataSourceRowGroupingPreProcessors.ts new file mode 100644 index 000000000000..07ad4690d059 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridDataSourceRowGroupingPreProcessors.ts @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { GridRowId, gridRowTreeSelector, gridColumnLookupSelector } from '@mui/x-data-grid-pro'; +import { + GridStrategyProcessor, + useGridRegisterStrategyProcessor, + createRowTree, + updateRowTree, + getVisibleRowsLookup, + skipSorting, + skipFiltering, + GridRowsPartialUpdates, +} from '@mui/x-data-grid-pro/internals'; +import { DataGridPremiumProcessedProps } from '../../../models/dataGridPremiumProps'; +import { getGroupingRules, RowGroupingStrategy } from './gridRowGroupingUtils'; +import { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { gridRowGroupingSanitizedModelSelector } from './gridRowGroupingSelector'; + +export const useGridDataSourceRowGroupingPreProcessors = ( + apiRef: React.MutableRefObject, + props: Pick< + DataGridPremiumProcessedProps, + | 'disableRowGrouping' + | 'groupingColDef' + | 'rowGroupingColumnMode' + | 'defaultGroupingExpansionDepth' + | 'isGroupExpandedByDefault' + | 'unstable_dataSource' + >, +) => { + const createRowTreeForRowGrouping = React.useCallback>( + (params) => { + const getGroupKey = props.unstable_dataSource?.getGroupKey; + if (!getGroupKey) { + throw new Error('MUI X: No `getGroupKey` method provided with the dataSource.'); + } + + const getChildrenCount = props.unstable_dataSource?.getChildrenCount; + if (!getChildrenCount) { + throw new Error('MUI X: No `getChildrenCount` method provided with the dataSource.'); + } + + const sanitizedRowGroupingModel = gridRowGroupingSanitizedModelSelector(apiRef); + const columnsLookup = gridColumnLookupSelector(apiRef); + const groupingRules = getGroupingRules({ + sanitizedRowGroupingModel, + columnsLookup, + }); + apiRef.current.caches.rowGrouping.rulesOnLastRowTreeCreation = groupingRules; + + const getRowTreeBuilderNode = (rowId: GridRowId) => { + const parentPath = (params.updates as GridRowsPartialUpdates).groupKeys ?? []; + const row = params.dataRowIdToModelLookup[rowId]; + const groupingRule = groupingRules[parentPath.length]; + const groupingValueGetter = groupingRule?.groupingValueGetter; + const leafKey = + groupingValueGetter?.( + row[groupingRule.field] as never, + row, + columnsLookup[groupingRule.field], + apiRef, + ) ?? getGroupKey(params.dataRowIdToModelLookup[rowId]); + return { + id: rowId, + path: [...parentPath, leafKey ?? rowId.toString()].map((key, i) => ({ + key, + field: groupingRules[i]?.field ?? null, + })), + serverChildrenCount: getChildrenCount(params.dataRowIdToModelLookup[rowId]) ?? 0, + }; + }; + + if (params.updates.type === 'full') { + return createRowTree({ + previousTree: params.previousTree, + nodes: params.updates.rows.map(getRowTreeBuilderNode), + defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, + isGroupExpandedByDefault: props.isGroupExpandedByDefault, + groupingName: RowGroupingStrategy.DataSource, + }); + } + + return updateRowTree({ + nodes: { + inserted: (params.updates as GridRowsPartialUpdates).actions.insert.map( + getRowTreeBuilderNode, + ), + modified: (params.updates as GridRowsPartialUpdates).actions.modify.map( + getRowTreeBuilderNode, + ), + removed: (params.updates as GridRowsPartialUpdates).actions.remove, + }, + previousTree: params.previousTree!, + previousGroupsToFetch: params.previousGroupsToFetch, + previousTreeDepth: params.previousTreeDepths!, + defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, + isGroupExpandedByDefault: props.isGroupExpandedByDefault, + groupingName: RowGroupingStrategy.DataSource, + }); + }, + [ + apiRef, + props.unstable_dataSource, + props.defaultGroupingExpansionDepth, + props.isGroupExpandedByDefault, + ], + ); + + const filterRows = React.useCallback>(() => { + const rowTree = gridRowTreeSelector(apiRef); + + return skipFiltering(rowTree); + }, [apiRef]); + + const sortRows = React.useCallback>(() => { + const rowTree = gridRowTreeSelector(apiRef); + + return skipSorting(rowTree); + }, [apiRef]); + + useGridRegisterStrategyProcessor( + apiRef, + RowGroupingStrategy.DataSource, + 'rowTreeCreation', + createRowTreeForRowGrouping, + ); + useGridRegisterStrategyProcessor(apiRef, RowGroupingStrategy.DataSource, 'filtering', filterRows); + useGridRegisterStrategyProcessor(apiRef, RowGroupingStrategy.DataSource, 'sorting', sortRows); + useGridRegisterStrategyProcessor( + apiRef, + RowGroupingStrategy.DataSource, + 'visibleRowsLookupCreation', + getVisibleRowsLookup, + ); +}; diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGrouping.tsx b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGrouping.tsx index b779e23a2064..fe562ab900c3 100644 --- a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGrouping.tsx +++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGrouping.tsx @@ -19,7 +19,7 @@ import { import { DataGridPremiumProcessedProps } from '../../../models/dataGridPremiumProps'; import { getRowGroupingFieldFromGroupingCriteria, - ROW_GROUPING_STRATEGY, + RowGroupingStrategy, isGroupingColumn, mergeStateWithRowGroupingModel, setStrategyAvailability, @@ -63,6 +63,7 @@ export const useGridRowGrouping = ( | 'disableRowGrouping' | 'slotProps' | 'slots' + | 'unstable_dataSource' >, ) => { apiRef.current.registerControlState({ @@ -73,7 +74,7 @@ export const useGridRowGrouping = ( changeEvent: 'rowGroupingModelChange', }); - /** + /* * API METHODS */ const setRowGroupingModel = React.useCallback( @@ -165,6 +166,16 @@ export const useGridRowGrouping = ( [props.disableRowGrouping], ); + const addGetRowsParams = React.useCallback>( + (params) => { + return { + ...params, + groupFields: gridRowGroupingModelSelector(apiRef), + }; + }, + [apiRef], + ); + const stateExportPreProcessing = React.useCallback>( (prevState, context) => { const rowGroupingModelToExport = gridRowGroupingModelSelector(apiRef); @@ -209,10 +220,11 @@ export const useGridRowGrouping = ( ); useGridRegisterPipeProcessor(apiRef, 'columnMenu', addColumnMenuButtons); + useGridRegisterPipeProcessor(apiRef, 'getRowsParams', addGetRowsParams); useGridRegisterPipeProcessor(apiRef, 'exportState', stateExportPreProcessing); useGridRegisterPipeProcessor(apiRef, 'restoreState', stateRestorePreProcessing); - /** + /* * EVENTS */ const handleCellKeyDown = React.useCallback>( @@ -233,10 +245,15 @@ export const useGridRowGrouping = ( return; } + if (props.unstable_dataSource && !params.rowNode.childrenExpanded) { + apiRef.current.unstable_dataSource.fetchRows(params.id); + return; + } + apiRef.current.setRowChildrenExpansion(params.id, !params.rowNode.childrenExpanded); } }, - [apiRef, props.rowGroupingColumnMode], + [apiRef, props.rowGroupingColumnMode, props.unstable_dataSource], ); const checkGroupingColumnsModelDiff = React.useCallback< @@ -258,7 +275,7 @@ export const useGridRowGrouping = ( // Refresh the row tree creation strategy processing // TODO: Add a clean way to re-run a strategy processing without publishing a private event - if (apiRef.current.getActiveStrategy('rowTree') === ROW_GROUPING_STRATEGY) { + if (apiRef.current.getActiveStrategy('rowTree') === RowGroupingStrategy.Default) { apiRef.current.publishEvent('activeStrategyProcessorChange', 'rowTreeCreation'); } } @@ -267,8 +284,11 @@ export const useGridRowGrouping = ( useGridApiEventHandler(apiRef, 'cellKeyDown', handleCellKeyDown); useGridApiEventHandler(apiRef, 'columnsChange', checkGroupingColumnsModelDiff); useGridApiEventHandler(apiRef, 'rowGroupingModelChange', checkGroupingColumnsModelDiff); + useGridApiEventHandler(apiRef, 'rowGroupingModelChange', () => + apiRef.current.unstable_dataSource.fetchRows(), + ); - /** + /* * EFFECTS */ React.useEffect(() => { diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGroupingPreProcessors.ts b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGroupingPreProcessors.ts index 2fa403ec72d5..ac43f038695a 100644 --- a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGroupingPreProcessors.ts +++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/useGridRowGroupingPreProcessors.ts @@ -31,7 +31,7 @@ import { import { filterRowTreeFromGroupingColumns, getColDefOverrides, - ROW_GROUPING_STRATEGY, + RowGroupingStrategy, isGroupingColumn, setStrategyAvailability, getCellGroupingCriteria, @@ -48,6 +48,7 @@ export const useGridRowGroupingPreProcessors = ( | 'rowGroupingColumnMode' | 'defaultGroupingExpansionDepth' | 'isGroupExpandedByDefault' + | 'unstable_dataSource' >, ) => { const getGroupingColDefs = React.useCallback( @@ -56,6 +57,10 @@ export const useGridRowGroupingPreProcessors = ( return []; } + const strategy = props.unstable_dataSource + ? RowGroupingStrategy.DataSource + : RowGroupingStrategy.Default; + const groupingColDefProp = props.groupingColDef; // We can't use `gridGroupingRowsSanitizedModelSelector` here because the new columns are not in the state yet @@ -73,8 +78,9 @@ export const useGridRowGroupingPreProcessors = ( createGroupingColDefForAllGroupingCriteria({ apiRef, rowGroupingModel, - colDefOverride: getColDefOverrides(groupingColDefProp, rowGroupingModel), + colDefOverride: getColDefOverrides(groupingColDefProp, rowGroupingModel, strategy), columnsLookup: columnsState.lookup, + strategy, }), ]; } @@ -86,6 +92,7 @@ export const useGridRowGroupingPreProcessors = ( colDefOverride: getColDefOverrides(groupingColDefProp, [groupingCriteria]), groupedByColDef: columnsState.lookup[groupingCriteria], columnsLookup: columnsState.lookup, + strategy, }), ); } @@ -95,7 +102,13 @@ export const useGridRowGroupingPreProcessors = ( } } }, - [apiRef, props.groupingColDef, props.rowGroupingColumnMode, props.disableRowGrouping], + [ + apiRef, + props.groupingColDef, + props.rowGroupingColumnMode, + props.disableRowGrouping, + props.unstable_dataSource, + ], ); const updateGroupingColumn = React.useCallback>( @@ -177,7 +190,7 @@ export const useGridRowGroupingPreProcessors = ( nodes: params.updates.rows.map(getRowTreeBuilderNode), defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: ROW_GROUPING_STRATEGY, + groupingName: RowGroupingStrategy.Default, }); } @@ -191,7 +204,7 @@ export const useGridRowGroupingPreProcessors = ( previousTreeDepth: params.previousTreeDepths!, defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: ROW_GROUPING_STRATEGY, + groupingName: RowGroupingStrategy.Default, }); }, [apiRef, props.defaultGroupingExpansionDepth, props.isGroupExpandedByDefault], @@ -228,35 +241,29 @@ export const useGridRowGroupingPreProcessors = ( useGridRegisterPipeProcessor(apiRef, 'hydrateColumns', updateGroupingColumn); useGridRegisterStrategyProcessor( apiRef, - ROW_GROUPING_STRATEGY, + RowGroupingStrategy.Default, 'rowTreeCreation', createRowTreeForRowGrouping, ); - useGridRegisterStrategyProcessor(apiRef, ROW_GROUPING_STRATEGY, 'filtering', filterRows); - useGridRegisterStrategyProcessor(apiRef, ROW_GROUPING_STRATEGY, 'sorting', sortRows); + useGridRegisterStrategyProcessor(apiRef, RowGroupingStrategy.Default, 'filtering', filterRows); + useGridRegisterStrategyProcessor(apiRef, RowGroupingStrategy.Default, 'sorting', sortRows); useGridRegisterStrategyProcessor( apiRef, - ROW_GROUPING_STRATEGY, + RowGroupingStrategy.Default, 'visibleRowsLookupCreation', getVisibleRowsLookup, ); - /** - * 1ST RENDER - */ useFirstRender(() => { - setStrategyAvailability(apiRef, props.disableRowGrouping); + setStrategyAvailability(apiRef, props.disableRowGrouping, props.unstable_dataSource); }); - /** - * EFFECTS - */ const isFirstRender = React.useRef(true); React.useEffect(() => { if (!isFirstRender.current) { - setStrategyAvailability(apiRef, props.disableRowGrouping); + setStrategyAvailability(apiRef, props.disableRowGrouping, props.unstable_dataSource); } else { isFirstRender.current = false; } - }, [apiRef, props.disableRowGrouping]); + }, [apiRef, props.disableRowGrouping, props.unstable_dataSource]); }; diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts index dde8cad3d39f..5645235abf01 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts @@ -15,6 +15,7 @@ function getKey(params: GridGetRowsParams) { params.filterModel, params.sortModel, params.groupKeys, + params.groupFields, ]); } diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts index 6b6aa5a9404d..09e2d34c9099 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts @@ -21,8 +21,6 @@ export const gridGetRowsParamsSelector = createSelector( (filterModel, sortModel, paginationModel) => { return { groupKeys: [], - // TODO: Implement with `rowGrouping` - groupFields: [], paginationModel, sortModel, filterModel, diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts index 4e365d10d7ee..31f3d209a695 100644 --- a/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts @@ -85,7 +85,10 @@ export const useGridDataSource = ( apiRef.current.resetDataSourceState(); } - const fetchParams = gridGetRowsParamsSelector(apiRef); + const fetchParams = { + ...gridGetRowsParamsSelector(apiRef), + ...apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}), + }; const cachedData = apiRef.current.unstable_dataSource.cache.get(fetchParams); @@ -122,7 +125,8 @@ export const useGridDataSource = ( const fetchRowChildren = React.useCallback( async (id) => { - if (!props.treeData) { + const pipedParams = apiRef.current.unstable_applyPipeProcessors('getRowsParams', {}); + if (!props.treeData && (pipedParams.groupFields?.length ?? 0) === 0) { nestedDataManager.clearPendingRequest(id); return; } @@ -138,7 +142,11 @@ export const useGridDataSource = ( return; } - const fetchParams = { ...gridGetRowsParamsSelector(apiRef), groupKeys: rowNode.path }; + const fetchParams = { + ...gridGetRowsParamsSelector(apiRef), + ...pipedParams, + groupKeys: rowNode.path, + }; const cachedData = apiRef.current.unstable_dataSource.cache.get(fetchParams); diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx index c09bc45b8ded..0c6b841f939d 100644 --- a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx @@ -34,8 +34,7 @@ import { } from '../../../utils/tree/models'; import { updateRowTree } from '../../../utils/tree/updateRowTree'; import { getVisibleRowsLookup } from '../../../utils/tree/utils'; - -const DATA_SOURCE_TREE_DATA_STRATEGY = 'dataSourceTreeData'; +import { TreeDataStrategy } from '../treeData/gridTreeDataUtils'; export const useGridDataSourceTreeDataPreProcessors = ( privateApiRef: React.MutableRefObject, @@ -53,7 +52,7 @@ export const useGridDataSourceTreeDataPreProcessors = ( const setStrategyAvailability = React.useCallback(() => { privateApiRef.current.setStrategyAvailability( 'rowTree', - DATA_SOURCE_TREE_DATA_STRATEGY, + TreeDataStrategy.DataSource, props.treeData && props.unstable_dataSource ? () => true : () => false, ); }, [privateApiRef, props.treeData, props.unstable_dataSource]); @@ -64,7 +63,7 @@ export const useGridDataSourceTreeDataPreProcessors = ( let colDefOverride: GridGroupingColDefOverride | null | undefined; if (typeof groupingColDefProp === 'function') { const params: GridGroupingColDefOverrideParams = { - groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.DataSource, fields: [], }; @@ -171,7 +170,7 @@ export const useGridDataSourceTreeDataPreProcessors = ( nodes: params.updates.rows.map(getRowTreeBuilderNode), defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.DataSource, onDuplicatePath, }); } @@ -187,7 +186,7 @@ export const useGridDataSourceTreeDataPreProcessors = ( previousTreeDepth: params.previousTreeDepths!, defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.DataSource, }); }, [ @@ -212,25 +211,20 @@ export const useGridDataSourceTreeDataPreProcessors = ( useGridRegisterPipeProcessor(privateApiRef, 'hydrateColumns', updateGroupingColumn); useGridRegisterStrategyProcessor( privateApiRef, - DATA_SOURCE_TREE_DATA_STRATEGY, + TreeDataStrategy.DataSource, 'rowTreeCreation', createRowTreeForTreeData, ); useGridRegisterStrategyProcessor( privateApiRef, - DATA_SOURCE_TREE_DATA_STRATEGY, + TreeDataStrategy.DataSource, 'filtering', filterRows, ); + useGridRegisterStrategyProcessor(privateApiRef, TreeDataStrategy.DataSource, 'sorting', sortRows); useGridRegisterStrategyProcessor( privateApiRef, - DATA_SOURCE_TREE_DATA_STRATEGY, - 'sorting', - sortRows, - ); - useGridRegisterStrategyProcessor( - privateApiRef, - DATA_SOURCE_TREE_DATA_STRATEGY, + TreeDataStrategy.DataSource, 'visibleRowsLookupCreation', getVisibleRowsLookup, ); diff --git a/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts b/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts index ee8224e89b08..6161b317a427 100644 --- a/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts +++ b/packages/x-data-grid-pro/src/hooks/features/treeData/gridTreeDataUtils.ts @@ -20,7 +20,10 @@ interface FilterRowTreeFromTreeDataParams { apiRef: React.MutableRefObject; } -export const TREE_DATA_STRATEGY = 'tree-data'; +export enum TreeDataStrategy { + Default = 'tree-data', + DataSource = 'tree-data-source', +} /** * A node is visible if one of the following criteria is met: diff --git a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx index 74c0fcfcda38..6a5753c9eb60 100644 --- a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx +++ b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx @@ -19,7 +19,7 @@ import { GRID_TREE_DATA_GROUPING_COL_DEF_FORCED_PROPERTIES, } from './gridTreeDataGroupColDef'; import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; -import { filterRowTreeFromTreeData, TREE_DATA_STRATEGY } from './gridTreeDataUtils'; +import { filterRowTreeFromTreeData, TreeDataStrategy } from './gridTreeDataUtils'; import { GridPrivateApiPro } from '../../../models/gridApiPro'; import { GridGroupingColDefOverride, @@ -52,7 +52,7 @@ export const useGridTreeDataPreProcessors = ( const setStrategyAvailability = React.useCallback(() => { privateApiRef.current.setStrategyAvailability( 'rowTree', - TREE_DATA_STRATEGY, + TreeDataStrategy.Default, props.treeData && !props.unstable_dataSource ? () => true : () => false, ); }, [privateApiRef, props.treeData, props.unstable_dataSource]); @@ -63,7 +63,7 @@ export const useGridTreeDataPreProcessors = ( let colDefOverride: GridGroupingColDefOverride | null | undefined; if (typeof groupingColDefProp === 'function') { const params: GridGroupingColDefOverrideParams = { - groupingName: TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.Default, fields: [], }; @@ -158,7 +158,7 @@ export const useGridTreeDataPreProcessors = ( nodes: params.updates.rows.map(getRowTreeBuilderNode), defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.Default, onDuplicatePath, }); } @@ -173,7 +173,7 @@ export const useGridTreeDataPreProcessors = ( previousTreeDepth: params.previousTreeDepths!, defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, isGroupExpandedByDefault: props.isGroupExpandedByDefault, - groupingName: TREE_DATA_STRATEGY, + groupingName: TreeDataStrategy.Default, }); }, [props.getTreeDataPath, props.defaultGroupingExpansionDepth, props.isGroupExpandedByDefault], @@ -211,15 +211,20 @@ export const useGridTreeDataPreProcessors = ( useGridRegisterPipeProcessor(privateApiRef, 'hydrateColumns', updateGroupingColumn); useGridRegisterStrategyProcessor( privateApiRef, - TREE_DATA_STRATEGY, + TreeDataStrategy.Default, 'rowTreeCreation', createRowTreeForTreeData, ); - useGridRegisterStrategyProcessor(privateApiRef, TREE_DATA_STRATEGY, 'filtering', filterRows); - useGridRegisterStrategyProcessor(privateApiRef, TREE_DATA_STRATEGY, 'sorting', sortRows); useGridRegisterStrategyProcessor( privateApiRef, - TREE_DATA_STRATEGY, + TreeDataStrategy.Default, + 'filtering', + filterRows, + ); + useGridRegisterStrategyProcessor(privateApiRef, TreeDataStrategy.Default, 'sorting', sortRows); + useGridRegisterStrategyProcessor( + privateApiRef, + TreeDataStrategy.Default, 'visibleRowsLookupCreation', getVisibleRowsLookup, ); diff --git a/packages/x-data-grid-pro/src/internals/index.ts b/packages/x-data-grid-pro/src/internals/index.ts index c0de9e27064b..ed619bb0cc9e 100644 --- a/packages/x-data-grid-pro/src/internals/index.ts +++ b/packages/x-data-grid-pro/src/internals/index.ts @@ -33,7 +33,6 @@ export { useGridRowReorder } from '../hooks/features/rowReorder/useGridRowReorde export { useGridRowReorderPreProcessors } from '../hooks/features/rowReorder/useGridRowReorderPreProcessors'; export { useGridTreeData } from '../hooks/features/treeData/useGridTreeData'; export { useGridTreeDataPreProcessors } from '../hooks/features/treeData/useGridTreeDataPreProcessors'; -export { TREE_DATA_STRATEGY } from '../hooks/features/treeData/gridTreeDataUtils'; export { useGridRowPinning, rowPinningStateInitializer, @@ -61,4 +60,6 @@ export { sortRowTree } from '../utils/tree/sortRowTree'; export { insertNodeInTree, removeNodeFromTree, getVisibleRowsLookup } from '../utils/tree/utils'; export type { RowTreeBuilderGroupingCriterion } from '../utils/tree/models'; +export { skipSorting, skipFiltering } from '../hooks/features/serverSideTreeData/utils'; + export * from './propValidation'; diff --git a/packages/x-data-grid/src/components/cell/GridBooleanCell.tsx b/packages/x-data-grid/src/components/cell/GridBooleanCell.tsx index 543e199229c2..69b7b58032a6 100644 --- a/packages/x-data-grid/src/components/cell/GridBooleanCell.tsx +++ b/packages/x-data-grid/src/components/cell/GridBooleanCell.tsx @@ -2,13 +2,15 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { SvgIconProps } from '@mui/material/SvgIcon'; import composeClasses from '@mui/utils/composeClasses'; +import { useGridSelector } from '../../hooks/utils/useGridSelector'; +import { gridRowMaximumTreeDepthSelector } from '../../hooks/features/rows/gridRowsSelector'; import { getDataGridUtilityClass } from '../../constants/gridClasses'; -import { GridRenderCellParams } from '../../models/params/gridCellParams'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; -import { DataGridProcessedProps } from '../../models/props/DataGridProps'; -import { GridColDef } from '../../models/colDef/gridColDef'; import { isAutogeneratedRowNode } from '../../hooks/features/rows/gridRowsUtils'; +import type { DataGridProcessedProps } from '../../models/props/DataGridProps'; +import type { GridColDef } from '../../models/colDef/gridColDef'; +import type { GridRenderCellParams } from '../../models/params/gridCellParams'; type OwnerState = { classes: DataGridProcessedProps['classes'] }; @@ -49,11 +51,20 @@ function GridBooleanCellRaw(props: GridBooleanCellProps) { const ownerState = { classes: rootProps.classes }; const classes = useUtilityClasses(ownerState); + const maxDepth = useGridSelector(apiRef, gridRowMaximumTreeDepthSelector); + const isServerSideRowGroupingRow = + // @ts-expect-error - Access tree data prop + maxDepth > 0 && rowNode.type === 'group' && rootProps.treeData === false; + const Icon = React.useMemo( () => (value ? rootProps.slots.booleanCellTrueIcon : rootProps.slots.booleanCellFalseIcon), [rootProps.slots.booleanCellFalseIcon, rootProps.slots.booleanCellTrueIcon, value], ); + if (isServerSideRowGroupingRow && value === undefined) { + return null; + } + return ( ('MuiDataGrid', [ 'treeDataGroupingCellLoadingContainer', 'groupingCriteriaCell', 'groupingCriteriaCellToggle', + 'groupingCriteriaCellLoadingContainer', 'pinnedRows', 'pinnedRows--top', 'pinnedRows--bottom', diff --git a/packages/x-data-grid/src/hooks/core/pipeProcessing/gridPipeProcessingApi.ts b/packages/x-data-grid/src/hooks/core/pipeProcessing/gridPipeProcessingApi.ts index 7cd516a68c06..d47a8bbe98b0 100644 --- a/packages/x-data-grid/src/hooks/core/pipeProcessing/gridPipeProcessingApi.ts +++ b/packages/x-data-grid/src/hooks/core/pipeProcessing/gridPipeProcessingApi.ts @@ -20,6 +20,7 @@ import { import { GridRowEntry, GridRowId } from '../../../models/gridRows'; import { GridHydrateRowsValue } from '../../features/rows/gridRowsInterfaces'; import { GridPreferencePanelsValue } from '../../features/preferencesPanel'; +import { GridGetRowsParams } from '../../../models/gridDataSource'; import { HeightEntry } from '../../features/rows/gridRowsMetaInterfaces'; export type GridPipeProcessorGroup = keyof GridPipeProcessingLookup; @@ -30,6 +31,7 @@ export interface GridPipeProcessingLookup { context: GridColDef; }; exportState: { value: GridInitialStateCommunity; context: GridExportStateParams }; + getRowsParams: { value: Partial }; hydrateColumns: { value: GridHydrateColumnsValue; }; diff --git a/packages/x-date-pickers-pro/src/DateRangeCalendar/timezone.DateRangeCalendar.test.tsx b/packages/x-date-pickers-pro/src/DateRangeCalendar/timezone.DateRangeCalendar.test.tsx new file mode 100644 index 000000000000..2d345ab1cf08 --- /dev/null +++ b/packages/x-date-pickers-pro/src/DateRangeCalendar/timezone.DateRangeCalendar.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { screen, fireEvent } from '@mui/internal-test-utils'; +import { describeAdapters } from 'test/utils/pickers'; +import { DateRangeCalendar } from './DateRangeCalendar'; + +describe(' - Timezone', () => { + describeAdapters('Timezone prop', DateRangeCalendar, ({ adapter, render }) => { + if (!adapter.isTimezoneCompatible) { + return; + } + + it('should correctly render month days when timezone changes', () => { + function DateCalendarWithControlledTimezone() { + const [timezone, setTimezone] = React.useState('Europe/Paris'); + return ( + + + + + ); + } + render(); + + expect( + screen.getAllByRole('gridcell', { + name: (_, element) => element.nodeName === 'BUTTON', + }).length, + ).to.equal(30); + + fireEvent.click(screen.getByRole('button', { name: 'Switch timezone' })); + + // the amount of rendered days should remain the same after changing timezone + expect( + screen.getAllByRole('gridcell', { + name: (_, element) => element.nodeName === 'BUTTON', + }).length, + ).to.equal(30); + }); + }); +}); diff --git a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateRangeField.ts b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateRangeField.ts index 707f04219a1d..696f44fd0b37 100644 --- a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateRangeField.ts +++ b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateRangeField.ts @@ -1,8 +1,5 @@ import useEventCallback from '@mui/utils/useEventCallback'; -import { - unstable_useDateField as useDateField, - UseDateFieldComponentProps, -} from '@mui/x-date-pickers/DateField'; +import { unstable_useDateField as useDateField } from '@mui/x-date-pickers/DateField'; import { FieldChangeHandler, FieldChangeHandlerContext, @@ -100,10 +97,7 @@ export const useMultiInputDateRangeField = < unstableEndFieldRef, }); - const startFieldProps: UseDateFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const startFieldProps = { error: !!validationError[0], ...startTextFieldProps, ...selectedSectionsResponse.start, @@ -120,10 +114,7 @@ export const useMultiInputDateRangeField = < autoFocus, // Do not add on end field. }; - const endFieldProps: UseDateFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const endFieldProps = { error: !!validationError[1], ...endTextFieldProps, ...selectedSectionsResponse.end, diff --git a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateTimeRangeField.ts b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateTimeRangeField.ts index 59285f9d5025..aededb24e64f 100644 --- a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateTimeRangeField.ts +++ b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputDateTimeRangeField.ts @@ -1,8 +1,5 @@ import useEventCallback from '@mui/utils/useEventCallback'; -import { - unstable_useDateTimeField as useDateTimeField, - UseDateTimeFieldComponentProps, -} from '@mui/x-date-pickers/DateTimeField'; +import { unstable_useDateTimeField as useDateTimeField } from '@mui/x-date-pickers/DateTimeField'; import { FieldChangeHandler, FieldChangeHandlerContext, @@ -100,10 +97,7 @@ export const useMultiInputDateTimeRangeField = < unstableEndFieldRef, }); - const startFieldProps: UseDateTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const startFieldProps = { error: !!validationError[0], ...startTextFieldProps, ...selectedSectionsResponse.start, @@ -120,10 +114,7 @@ export const useMultiInputDateTimeRangeField = < autoFocus, // Do not add on end field. }; - const endFieldProps: UseDateTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const endFieldProps = { error: !!validationError[1], ...endTextFieldProps, ...selectedSectionsResponse.end, diff --git a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputTimeRangeField.ts b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputTimeRangeField.ts index ee5cb2dd835d..50cb64ba4ac5 100644 --- a/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputTimeRangeField.ts +++ b/packages/x-date-pickers-pro/src/internals/hooks/useMultiInputRangeField/useMultiInputTimeRangeField.ts @@ -1,8 +1,5 @@ import useEventCallback from '@mui/utils/useEventCallback'; -import { - unstable_useTimeField as useTimeField, - UseTimeFieldComponentProps, -} from '@mui/x-date-pickers/TimeField'; +import { unstable_useTimeField as useTimeField } from '@mui/x-date-pickers/TimeField'; import { FieldChangeHandler, FieldChangeHandlerContext, @@ -100,10 +97,7 @@ export const useMultiInputTimeRangeField = < unstableEndFieldRef, }); - const startFieldProps: UseTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const startFieldProps = { error: !!validationError[0], ...startTextFieldProps, ...selectedSectionsResponse.start, @@ -120,10 +114,7 @@ export const useMultiInputTimeRangeField = < autoFocus, // Do not add on end field. }; - const endFieldProps: UseTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - typeof sharedProps - > = { + const endFieldProps = { error: !!validationError[1], ...endTextFieldProps, ...selectedSectionsResponse.end, diff --git a/packages/x-date-pickers/src/DateCalendar/DayCalendar.tsx b/packages/x-date-pickers/src/DateCalendar/DayCalendar.tsx index 54ac9f891bb7..74d15f61c9ad 100644 --- a/packages/x-date-pickers/src/DateCalendar/DayCalendar.tsx +++ b/packages/x-date-pickers/src/DateCalendar/DayCalendar.tsx @@ -538,9 +538,8 @@ export function DayCalendar(inProps: DayCalendarProps) { ]); const weeksToDisplay = React.useMemo(() => { - const currentMonthWithTimezone = utils.setTimezone(currentMonth, timezone); - const toDisplay = utils.getWeekArray(currentMonthWithTimezone); - let nextMonth = utils.addMonths(currentMonthWithTimezone, 1); + const toDisplay = utils.getWeekArray(currentMonth); + let nextMonth = utils.addMonths(currentMonth, 1); while (fixedWeekNumber && toDisplay.length < fixedWeekNumber) { const additionalWeeks = utils.getWeekArray(nextMonth); const hasCommonWeek = utils.isSameDay( @@ -557,7 +556,7 @@ export function DayCalendar(inProps: DayCalendarProps) { nextMonth = utils.addMonths(nextMonth, 1); } return toDisplay; - }, [currentMonth, fixedWeekNumber, utils, timezone]); + }, [currentMonth, fixedWeekNumber, utils]); return ( diff --git a/packages/x-date-pickers/src/DateCalendar/tests/timezone.DateCalendar.test.tsx b/packages/x-date-pickers/src/DateCalendar/tests/timezone.DateCalendar.test.tsx index 0f00aeee3039..a8624667be44 100644 --- a/packages/x-date-pickers/src/DateCalendar/tests/timezone.DateCalendar.test.tsx +++ b/packages/x-date-pickers/src/DateCalendar/tests/timezone.DateCalendar.test.tsx @@ -29,6 +29,49 @@ describe(' - Timezone', () => { expect(actualDate).toEqualDateTime(expectedDate); }); + it('should use "default" timezone for onChange when provided', () => { + const onChange = spy(); + const value = adapter.date('2022-04-25T15:30'); + + render(); + + fireEvent.click(screen.getByRole('gridcell', { name: '25' })); + const expectedDate = adapter.setDate(value, 25); + + // Check the `onChange` value (uses timezone prop) + const actualDate = onChange.lastCall.firstArg; + expect(adapter.getTimezone(actualDate)).to.equal(adapter.lib === 'dayjs' ? 'UTC' : 'system'); + expect(actualDate).toEqualDateTime(expectedDate); + }); + + it('should correctly render month days when timezone changes', () => { + function DateCalendarWithControlledTimezone() { + const [timezone, setTimezone] = React.useState('Europe/Paris'); + return ( + + + + + ); + } + render(); + + expect( + screen.getAllByRole('gridcell', { + name: (_, element) => element.nodeName === 'BUTTON', + }).length, + ).to.equal(30); + + fireEvent.click(screen.getByRole('button', { name: 'Switch timezone' })); + + // the amount of rendered days should remain the same after changing timezone + expect( + screen.getAllByRole('gridcell', { + name: (_, element) => element.nodeName === 'BUTTON', + }).length, + ).to.equal(30); + }); + TIMEZONE_TO_TEST.forEach((timezone) => { describe(`Timezone: ${timezone}`, () => { it('should use timezone prop for onChange when no value is provided', () => { diff --git a/packages/x-date-pickers/src/DateCalendar/useCalendarState.tsx b/packages/x-date-pickers/src/DateCalendar/useCalendarState.tsx index d209b0da16cd..0fca874dbcb0 100644 --- a/packages/x-date-pickers/src/DateCalendar/useCalendarState.tsx +++ b/packages/x-date-pickers/src/DateCalendar/useCalendarState.tsx @@ -39,6 +39,7 @@ export const createCalendarStateReducer = action: | ReducerAction<'finishMonthSwitchingAnimation'> | ReducerAction<'changeMonth', ChangeMonthPayload> + | ReducerAction<'changeMonthTimezone', { newTimezone: string }> | ReducerAction<'changeFocusedDay', ChangeFocusedDayPayload>, ): CalendarState => { switch (action.type) { @@ -50,6 +51,21 @@ export const createCalendarStateReducer = isMonthSwitchingAnimating: !reduceAnimations, }; + case 'changeMonthTimezone': { + const newTimezone = action.newTimezone; + if (utils.getTimezone(state.currentMonth) === newTimezone) { + return state; + } + let newCurrentMonth = utils.setTimezone(state.currentMonth, newTimezone); + if (utils.getMonth(newCurrentMonth) !== utils.getMonth(state.currentMonth)) { + newCurrentMonth = utils.setMonth(newCurrentMonth, utils.getMonth(state.currentMonth)); + } + return { + ...state, + currentMonth: newCurrentMonth, + }; + } + case 'finishMonthSwitchingAnimation': return { ...state, @@ -139,7 +155,9 @@ export const useCalendarState = (params: UseCalendarStateParams) => { granularity: SECTION_TYPE_GRANULARITY.day, }); }, - [], // eslint-disable-line react-hooks/exhaustive-deps + // We want the `referenceDate` to update on prop and `timezone` change (https://github.com/mui/mui-x/issues/10804) + // eslint-disable-next-line react-hooks/exhaustive-deps + [referenceDateProp, timezone], ); const [calendarState, dispatch] = React.useReducer(reducerFn, { @@ -149,6 +167,15 @@ export const useCalendarState = (params: UseCalendarStateParams) => { slideDirection: 'left', }); + // Ensure that `calendarState.currentMonth` timezone is updated when `referenceDate` (or timezone changes) + // https://github.com/mui/mui-x/issues/10804 + React.useEffect(() => { + dispatch({ + type: 'changeMonthTimezone', + newTimezone: utils.getTimezone(referenceDate), + }); + }, [referenceDate, utils]); + const handleChangeMonth = React.useCallback( (payload: ChangeMonthPayload) => { dispatch({ diff --git a/packages/x-date-pickers/src/DateField/DateField.types.ts b/packages/x-date-pickers/src/DateField/DateField.types.ts index ce42c93a2240..793313f76496 100644 --- a/packages/x-date-pickers/src/DateField/DateField.types.ts +++ b/packages/x-date-pickers/src/DateField/DateField.types.ts @@ -37,28 +37,25 @@ export interface UseDateFieldProps = Omit> & - UseDateFieldProps; - export type DateFieldProps = - UseDateFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - BuiltInFieldTextFieldProps - > & { - /** - * Overridable component slots. - * @default {} - */ - slots?: DateFieldSlots; - /** - * The props used for each component slot. - * @default {} - */ - slotProps?: DateFieldSlotProps; - }; + // The hook props + UseDateFieldProps & + // The TextField props + Omit< + BuiltInFieldTextFieldProps, + keyof UseDateFieldProps + > & { + /** + * Overridable component slots. + * @default {} + */ + slots?: DateFieldSlots; + /** + * The props used for each component slot. + * @default {} + */ + slotProps?: DateFieldSlotProps; + }; export type DateFieldOwnerState = DateFieldProps; diff --git a/packages/x-date-pickers/src/DateField/index.ts b/packages/x-date-pickers/src/DateField/index.ts index cd6119e98a25..69c1964f1330 100644 --- a/packages/x-date-pickers/src/DateField/index.ts +++ b/packages/x-date-pickers/src/DateField/index.ts @@ -1,7 +1,3 @@ export { DateField } from './DateField'; export { useDateField as unstable_useDateField } from './useDateField'; -export type { - UseDateFieldProps, - UseDateFieldComponentProps, - DateFieldProps, -} from './DateField.types'; +export type { UseDateFieldProps, DateFieldProps } from './DateField.types'; diff --git a/packages/x-date-pickers/src/DateTimeField/DateTimeField.types.ts b/packages/x-date-pickers/src/DateTimeField/DateTimeField.types.ts index a4baac79c538..3d7cce8e280d 100644 --- a/packages/x-date-pickers/src/DateTimeField/DateTimeField.types.ts +++ b/packages/x-date-pickers/src/DateTimeField/DateTimeField.types.ts @@ -49,28 +49,25 @@ export interface UseDateTimeFieldProps = Omit> & - UseDateTimeFieldProps; - export type DateTimeFieldProps = - UseDateTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - BuiltInFieldTextFieldProps - > & { - /** - * Overridable component slots. - * @default {} - */ - slots?: DateTimeFieldSlots; - /** - * The props used for each component slot. - * @default {} - */ - slotProps?: DateTimeFieldSlotProps; - }; + // The hook props + UseDateTimeFieldProps & + // The TextField props + Omit< + BuiltInFieldTextFieldProps, + keyof UseDateTimeFieldProps + > & { + /** + * Overridable component slots. + * @default {} + */ + slots?: DateTimeFieldSlots; + /** + * The props used for each component slot. + * @default {} + */ + slotProps?: DateTimeFieldSlotProps; + }; export type DateTimeFieldOwnerState = DateTimeFieldProps; diff --git a/packages/x-date-pickers/src/DateTimeField/index.ts b/packages/x-date-pickers/src/DateTimeField/index.ts index 95952dde9474..7f8882b9d04a 100644 --- a/packages/x-date-pickers/src/DateTimeField/index.ts +++ b/packages/x-date-pickers/src/DateTimeField/index.ts @@ -1,7 +1,3 @@ export { DateTimeField } from './DateTimeField'; export { useDateTimeField as unstable_useDateTimeField } from './useDateTimeField'; -export type { - UseDateTimeFieldProps, - UseDateTimeFieldComponentProps, - DateTimeFieldProps, -} from './DateTimeField.types'; +export type { UseDateTimeFieldProps, DateTimeFieldProps } from './DateTimeField.types'; diff --git a/packages/x-date-pickers/src/TimeField/TimeField.types.ts b/packages/x-date-pickers/src/TimeField/TimeField.types.ts index 23f6e45e3cae..d1150af825bd 100644 --- a/packages/x-date-pickers/src/TimeField/TimeField.types.ts +++ b/packages/x-date-pickers/src/TimeField/TimeField.types.ts @@ -36,28 +36,25 @@ export interface UseTimeFieldProps = Omit> & - UseTimeFieldProps; - export type TimeFieldProps = - UseTimeFieldComponentProps< - TEnableAccessibleFieldDOMStructure, - BuiltInFieldTextFieldProps - > & { - /** - * Overridable component slots. - * @default {} - */ - slots?: TimeFieldSlots; - /** - * The props used for each component slot. - * @default {} - */ - slotProps?: TimeFieldSlotProps; - }; + // The hook props + UseTimeFieldProps & + // The TextField props + Omit< + BuiltInFieldTextFieldProps, + keyof UseTimeFieldProps + > & { + /** + * Overridable component slots. + * @default {} + */ + slots?: TimeFieldSlots; + /** + * The props used for each component slot. + * @default {} + */ + slotProps?: TimeFieldSlotProps; + }; export type TimeFieldOwnerState = TimeFieldProps; diff --git a/packages/x-date-pickers/src/TimeField/index.ts b/packages/x-date-pickers/src/TimeField/index.ts index f335f0f8fd76..b721d815213c 100644 --- a/packages/x-date-pickers/src/TimeField/index.ts +++ b/packages/x-date-pickers/src/TimeField/index.ts @@ -1,7 +1,3 @@ export { TimeField } from './TimeField'; export { useTimeField as unstable_useTimeField } from './useTimeField'; -export type { - UseTimeFieldProps, - UseTimeFieldComponentProps, - TimeFieldProps, -} from './TimeField.types'; +export type { UseTimeFieldProps, TimeFieldProps } from './TimeField.types'; diff --git a/packages/x-date-pickers/src/internals/hooks/useDesktopPicker/useDesktopPicker.types.ts b/packages/x-date-pickers/src/internals/hooks/useDesktopPicker/useDesktopPicker.types.ts index 6daea3ab49d4..1f16bc644e24 100644 --- a/packages/x-date-pickers/src/internals/hooks/useDesktopPicker/useDesktopPicker.types.ts +++ b/packages/x-date-pickers/src/internals/hooks/useDesktopPicker/useDesktopPicker.types.ts @@ -91,11 +91,7 @@ export interface ExportedUseDesktopPickerSlotProps< {}, UseDesktopPickerProps >; - openPickerIcon?: SlotComponentPropsFromProps< - Record, - {}, - PickerOwnerState - >; + openPickerIcon?: SlotComponentPropsFromProps, {}, PickerOwnerState>; } export interface DesktopOnlyPickerProps diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.ts index a92c4c4ff9e2..de808fac5e36 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.ts @@ -68,7 +68,7 @@ export const usePicker = < propsFromPickerViews: pickerViewsResponse.layoutProps, }); - const pickerOwnerState = usePickerOwnerState({ props, pickerValueResponse }); + const pickerOwnerState = usePickerOwnerState({ props, pickerValueResponse, valueManager }); return { // Picker value diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.types.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.types.ts index f1481eef5db5..3d1f44dfc263 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.types.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.types.ts @@ -62,5 +62,5 @@ export interface UsePickerResponse< > extends Omit, 'viewProps' | 'layoutProps'>, Omit, 'layoutProps'>, UsePickerLayoutPropsResponse { - ownerState: PickerOwnerState; + ownerState: PickerOwnerState; } diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerOwnerState.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerOwnerState.ts index 0e52cbbcbd9d..32da73815c61 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerOwnerState.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerOwnerState.ts @@ -1,25 +1,40 @@ import * as React from 'react'; import { FieldSection, PickerOwnerState } from '../../../models'; import type { UsePickerProps } from './usePicker.types'; -import { UsePickerValueResponse } from './usePickerValue.types'; +import { PickerValueManager, UsePickerValueResponse } from './usePickerValue.types'; +import { useUtils } from '../useUtils'; interface UsePickerOwnerStateParameters { props: UsePickerProps; pickerValueResponse: UsePickerValueResponse; + valueManager: PickerValueManager; } export function usePickerOwnerState( parameters: UsePickerOwnerStateParameters, -): PickerOwnerState { - const { props, pickerValueResponse } = parameters; +): PickerOwnerState { + const { props, pickerValueResponse, valueManager } = parameters; + + const utils = useUtils(); return React.useMemo( () => ({ - value: pickerValueResponse.viewProps.value, - open: pickerValueResponse.open, - disabled: props.disabled ?? false, - readOnly: props.readOnly ?? false, + isPickerValueEmpty: valueManager.areValuesEqual( + utils, + pickerValueResponse.viewProps.value, + valueManager.emptyValue, + ), + isPickerOpen: pickerValueResponse.open, + isPickerDisabled: props.disabled ?? false, + isPickerReadOnly: props.readOnly ?? false, }), - [pickerValueResponse.viewProps.value, pickerValueResponse.open, props.disabled, props.readOnly], + [ + utils, + valueManager, + pickerValueResponse.viewProps.value, + pickerValueResponse.open, + props.disabled, + props.readOnly, + ], ); } diff --git a/packages/x-date-pickers/src/models/pickers.ts b/packages/x-date-pickers/src/models/pickers.ts index c15c2e5d1e71..d255b1519e29 100644 --- a/packages/x-date-pickers/src/models/pickers.ts +++ b/packages/x-date-pickers/src/models/pickers.ts @@ -15,21 +15,21 @@ export type PickerValidDate = keyof PickerValidDateLookup extends never ? any : PickerValidDateLookup[keyof PickerValidDateLookup]; -export interface PickerOwnerState { +export interface PickerOwnerState { /** - * The value currently displayed in the field and in the view. + * `true` if the value is currently empty. */ - value: TValue; + isPickerValueEmpty: boolean; /** * `true` if the picker is open, `false` otherwise. */ - open: boolean; + isPickerOpen: boolean; /** * `true` if the picker is disabled, `false` otherwise. */ - disabled: boolean; + isPickerDisabled: boolean; /** * `true` if the picker is read-only, `false` otherwise. */ - readOnly: boolean; + isPickerReadOnly: boolean; } diff --git a/scripts/x-date-pickers-pro.exports.json b/scripts/x-date-pickers-pro.exports.json index 03ed5e2773d1..cc4f53f611bc 100644 --- a/scripts/x-date-pickers-pro.exports.json +++ b/scripts/x-date-pickers-pro.exports.json @@ -410,10 +410,8 @@ { "name": "UseClearableFieldResponse", "kind": "TypeAlias" }, { "name": "UseClearableFieldSlotProps", "kind": "Interface" }, { "name": "UseClearableFieldSlots", "kind": "Interface" }, - { "name": "UseDateFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseDateFieldProps", "kind": "Interface" }, { "name": "UseDateRangeFieldProps", "kind": "Interface" }, - { "name": "UseDateTimeFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseDateTimeFieldProps", "kind": "Interface" }, { "name": "UseMultiInputDateRangeFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseMultiInputDateRangeFieldProps", "kind": "Interface" }, @@ -429,7 +427,6 @@ { "name": "UseSingleInputDateTimeRangeFieldProps", "kind": "Interface" }, { "name": "UseSingleInputTimeRangeFieldProps", "kind": "Interface" }, { "name": "useSplitFieldProps", "kind": "Variable" }, - { "name": "UseTimeFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseTimeFieldProps", "kind": "Interface" }, { "name": "useValidation", "kind": "Function" }, { "name": "validateDate", "kind": "Variable" }, diff --git a/scripts/x-date-pickers.exports.json b/scripts/x-date-pickers.exports.json index 933900a6a6ff..08cfffab06cf 100644 --- a/scripts/x-date-pickers.exports.json +++ b/scripts/x-date-pickers.exports.json @@ -302,16 +302,13 @@ { "name": "UseClearableFieldResponse", "kind": "TypeAlias" }, { "name": "UseClearableFieldSlotProps", "kind": "Interface" }, { "name": "UseClearableFieldSlots", "kind": "Interface" }, - { "name": "UseDateFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseDateFieldProps", "kind": "Interface" }, - { "name": "UseDateTimeFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseDateTimeFieldProps", "kind": "Interface" }, { "name": "useParsedFormat", "kind": "Variable" }, { "name": "usePickerLayout", "kind": "ExportAssignment" }, { "name": "usePickersContext", "kind": "Variable" }, { "name": "usePickersTranslations", "kind": "Variable" }, { "name": "useSplitFieldProps", "kind": "Variable" }, - { "name": "UseTimeFieldComponentProps", "kind": "TypeAlias" }, { "name": "UseTimeFieldProps", "kind": "Interface" }, { "name": "useValidation", "kind": "Function" }, { "name": "validateDate", "kind": "Variable" },