diff --git a/.github/workflows/test-meshery-integration.yml b/.github/workflows/test-meshery-integration.yml index 847f65e3e..1c1e60edf 100644 --- a/.github/workflows/test-meshery-integration.yml +++ b/.github/workflows/test-meshery-integration.yml @@ -51,10 +51,14 @@ jobs: uses: actions/checkout@v6 with: repository: meshery/meshery - # Pin to last known-good commit before the @mui/x-tree-view v8 upgrade - # that broke the TreeView import (meshery/meshery#18163). - # Remove this pin once meshery/meshery#18167 is merged. - ref: 7c3d02c91365dc776b11ffc50aab277026e86701 + # Pin to the commit that added date-fns as a Meshery UI dependency + # (meshery/meshery#20416), needed because Sistent's UniversalFilter + # date-range picker now transitively requires @mui/x-date-pickers' + # AdapterDateFns, which Sistent externalizes rather than bundles. + # The prior pin (before the @mui/x-tree-view v8 upgrade that broke + # the TreeView import, meshery/meshery#18163) is no longer needed + # since meshery/meshery#18167 merged. + ref: 2d988df92ad5d336230ed9255d08a85d6bebe8fd path: meshery fetch-depth: 1 diff --git a/package-lock.json b/package-lock.json index a671c9c2e..23f560df3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", + "@mui/x-date-pickers": "^9.7.0", "@reduxjs/toolkit": "^2.11.2", "@rjsf/core": "^6.5.2", "@rjsf/mui": "^6.5.2", @@ -51,6 +52,7 @@ "@typescript-eslint/parser": "^8.59.1", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", + "date-fns": "^4.4.0", "eslint": "10.4.0", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", @@ -78,16 +80,24 @@ "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", + "@mui/x-date-pickers": "^9.0.0", "@rjsf/core": "^6.0.0", "@rjsf/mui": "^6.0.0", "@rjsf/utils": "^6.0.0", "@rjsf/validator-ajv8": "^6.0.0", "@xstate/react": "^5.0.3 || ^6.0.0", + "date-fns": "^4.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "xstate": "^5.25.0" }, "peerDependenciesMeta": { + "@mui/x-date-pickers": { + "optional": true + }, + "date-fns": { + "optional": true + }, "react": { "optional": true }, @@ -579,9 +589,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -632,6 +642,29 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -1996,6 +2029,13 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3560,6 +3600,98 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-9.7.0.tgz", + "integrity": "sha512-gW/tz5LKhuwl5/naP/gv4jT3e3Fcf9gXEemvsWX6gVnO4IkO/GUJ55UbPQVooLCaJRAR/WBwMXgen7nIrBTBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.1.1", + "@mui/x-internals": "^9.7.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.7.0.tgz", + "integrity": "sha512-fdBwh96L78QFZXB1oS2v8y33gXap3A6Tc0+AJYxYzLsRwx05WnaWhtHEwNV2x7zLXLaZ4ux0CCAJIgZ6kTP4FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@base-ui/utils": "^0.3.0", + "@mui/utils": "^9.1.1", + "core-js-pure": "^3.49.0", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -6802,6 +6934,18 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/core-js-pure": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", @@ -7163,6 +7307,17 @@ "node": ">=18" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -14448,9 +14603,9 @@ } }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 7e99986a9..767541b0d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", + "@mui/x-date-pickers": "^9.7.0", "@reduxjs/toolkit": "^2.11.2", "@rjsf/core": "^6.5.2", "@rjsf/mui": "^6.5.2", @@ -68,6 +69,7 @@ "@typescript-eslint/parser": "^8.59.1", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", + "date-fns": "^4.4.0", "eslint": "10.4.0", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", @@ -95,11 +97,13 @@ "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "@mui/system": "^9.0.0", + "@mui/x-date-pickers": "^9.0.0", "@rjsf/core": "^6.0.0", "@rjsf/mui": "^6.0.0", "@rjsf/utils": "^6.0.0", "@rjsf/validator-ajv8": "^6.0.0", "@xstate/react": "^5.0.3 || ^6.0.0", + "date-fns": "^4.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "xstate": "^5.25.0" @@ -111,6 +115,12 @@ } }, "peerDependenciesMeta": { + "@mui/x-date-pickers": { + "optional": true + }, + "date-fns": { + "optional": true + }, "react": { "optional": true }, diff --git a/src/__testing__/UniversalFilter.test.tsx b/src/__testing__/UniversalFilter.test.tsx index 0d2dbfe05..a4200305c 100644 --- a/src/__testing__/UniversalFilter.test.tsx +++ b/src/__testing__/UniversalFilter.test.tsx @@ -63,6 +63,19 @@ jest.mock('../custom/TooltipIconButton', () => ({ ) })); +jest.mock('../base/DateTimePicker', () => ({ + DateTimePicker: ({ label, value, onChange, 'data-testid': testId }: any) => ( + ) => + onChange(new Date(event.target.value)) + } + /> + ) +})); + import UniversalFilter from '../custom/UniversalFilter'; import { SistentThemeProvider } from '../theme'; @@ -104,4 +117,88 @@ describe('UniversalFilter', () => { expect(setSelectedFilters).toHaveBeenCalledWith({ status: 'enabled' }); expect(handleApplyFilter).toHaveBeenCalledWith({ status: 'enabled' }); }); + + it('does not render date range controls when datePicker is not enabled', () => { + renderWithTheme( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + + expect(screen.queryByTestId('universal-filter-quick-range-select')).toBeNull(); + expect(screen.queryByTestId('universal-filter-start-date')).toBeNull(); + }); + + it('applies a quick date range immediately, without requiring Apply', () => { + const setSelectedDateRange = jest.fn(); + const lastWeekRange = { + startDate: new Date('2024-01-01T00:00:00.000Z'), + endDate: new Date('2024-01-08T00:00:00.000Z') + }; + + renderWithTheme( + lastWeekRange }]} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + + fireEvent.change(screen.getByTestId('universal-filter-quick-range-select'), { + target: { value: 'Last week' } + }); + + expect(setSelectedDateRange).toHaveBeenCalledWith(lastWeekRange); + }); + + it('clamps the end date to the new start date when the start date moves past it', () => { + const setSelectedDateRange = jest.fn(); + + renderWithTheme( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + + fireEvent.change(screen.getByTestId('universal-filter-start-date'), { + target: { value: '2024-01-15T00:00:00.000Z' } + }); + + expect(setSelectedDateRange).toHaveBeenCalledWith({ + startDate: new Date('2024-01-15T00:00:00.000Z'), + endDate: new Date('2024-01-15T00:00:00.000Z') + }); + }); }); diff --git a/src/base/DateTimePicker/DateTimePicker.tsx b/src/base/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 000000000..365e24df6 --- /dev/null +++ b/src/base/DateTimePicker/DateTimePicker.tsx @@ -0,0 +1,19 @@ +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { + DateTimePicker as MuiDateTimePicker, + type DateTimePickerProps as MuiDateTimePickerProps +} from '@mui/x-date-pickers/DateTimePicker'; +import React from 'react'; + +const DateTimePicker = React.forwardRef( + (props, ref) => { + return ( + + + + ); + } +); + +export default DateTimePicker; diff --git a/src/base/DateTimePicker/index.tsx b/src/base/DateTimePicker/index.tsx new file mode 100644 index 000000000..4fcb31ef9 --- /dev/null +++ b/src/base/DateTimePicker/index.tsx @@ -0,0 +1,5 @@ +import type { DateTimePickerProps } from '@mui/x-date-pickers/DateTimePicker'; +import DateTimePicker from './DateTimePicker'; + +export { DateTimePicker }; +export type { DateTimePickerProps }; diff --git a/src/base/index.tsx b/src/base/index.tsx index da4f57642..f416f5e06 100644 --- a/src/base/index.tsx +++ b/src/base/index.tsx @@ -26,6 +26,7 @@ export * from './ClickAwayListener'; export * from './Collapse'; export * from './Container'; export * from './CssBaseLine'; +export * from './DateTimePicker'; export * from './Dialog'; export * from './DialogActions'; export * from './DialogContent'; diff --git a/src/custom/UniversalFilter.tsx b/src/custom/UniversalFilter.tsx index 0b9336cf0..a34f70a11 100644 --- a/src/custom/UniversalFilter.tsx +++ b/src/custom/UniversalFilter.tsx @@ -1,8 +1,10 @@ import { Drawer, styled, useMediaQuery } from '@mui/material'; import { SelectChangeEvent } from '@mui/material/Select'; +import { subDays, subMonths, subYears } from 'date-fns'; import React from 'react'; import { Button } from '../base/Button'; import { ClickAwayListener } from '../base/ClickAwayListener'; +import { DateTimePicker } from '../base/DateTimePicker'; import { InputLabel } from '../base/InputLabel'; import { MenuItem } from '../base/MenuItem'; import { Paper } from '../base/Paper'; @@ -23,6 +25,24 @@ const normalizeFilters = (filters?: Record | null): Record DateRange; +} + +const DEFAULT_QUICK_DATE_RANGES: QuickDateRangeOption[] = [ + { label: 'Last 7 days', getRange: () => ({ startDate: subDays(new Date(), 7), endDate: new Date() }) }, + { label: 'Last 30 days', getRange: () => ({ startDate: subDays(new Date(), 30), endDate: new Date() }) }, + { label: 'Last 3 months', getRange: () => ({ startDate: subMonths(new Date(), 3), endDate: new Date() }) }, + { label: 'Last 6 months', getRange: () => ({ startDate: subMonths(new Date(), 6), endDate: new Date() }) }, + { label: 'Last 1 year', getRange: () => ({ startDate: subYears(new Date(), 1), endDate: new Date() }) } +]; + export interface UniversalFilterProps { filters: Record; selectedFilters: Record; @@ -32,6 +52,10 @@ export interface UniversalFilterProps { showAllOption?: boolean; id: string; 'data-testid'?: string; + datePicker?: boolean; + selectedDateRange?: DateRange; + setSelectedDateRange?: (range: DateRange) => void; + quickDateRanges?: QuickDateRangeOption[]; } export const FilterHeader = styled('div')(({ theme }) => ({ @@ -52,7 +76,11 @@ function UniversalFilter({ handleApplyFilter, showAllOption = true, id, - 'data-testid': testId = 'universal-filter' + 'data-testid': testId = 'universal-filter', + datePicker = false, + selectedDateRange, + setSelectedDateRange, + quickDateRanges = DEFAULT_QUICK_DATE_RANGES }: UniversalFilterProps): JSX.Element { const [anchorEl, setAnchorEl] = React.useState(null); const [open, setOpen] = React.useState(false); @@ -83,6 +111,29 @@ function UniversalFilter({ setOpen(false); }; + const handleQuickRangeChange = (event: SelectChangeEvent) => { + const option = quickDateRanges.find((range) => range.label === event.target.value); + if (option) { + setSelectedDateRange?.(option.getRange()); + } + }; + + const handleStartDateChange = (newStartDate: Date | null) => { + if (!newStartDate || !selectedDateRange) return; + setSelectedDateRange?.({ + startDate: newStartDate, + endDate: newStartDate > selectedDateRange.endDate ? newStartDate : selectedDateRange.endDate + }); + }; + + const handleEndDateChange = (newEndDate: Date | null) => { + if (!newEndDate || !selectedDateRange) return; + setSelectedDateRange?.({ + startDate: newEndDate < selectedDateRange.startDate ? newEndDate : selectedDateRange.startDate, + endDate: newEndDate + }); + }; + const handleFilterChange = (event: React.ChangeEvent<{ value: string }>, columnName: string) => { const value = event.target.value; setDraftFilters((prevFilters) => ({ @@ -164,6 +215,39 @@ function UniversalFilter({ ); })} + {datePicker && selectedDateRange && setSelectedDateRange && ( +
+ Quick Ranges + + + + +
+ )} +