Repository: opencast/opencast-admin-interface
Pull request #1185: Replace MUI-datepicker with react-datepicker in the statistics component 
Author: login: snoesberger
Labels: name: type:code-enhancement
description: Internal improvements to the codebase
name: type:visual-clarity
description: Improves UI readability
name: priority:low
Created: 2025-04-25T09:36:42Z | Merged: 2025-05-15T07:41:11Z

This PR continues the work started in https://github.com/opencast/opencast-admin-interface/pull/986 and replaces the last occurrence of the MUI-datepicker (in the time series statistics component) with the react-datepicker.
At the same time, the MUI datepicker dependency is completely removed, including the old localization and styling stuff.

Addresses the issue https://github.com/opencast/opencast-admin-interface/issues/614 about unifying the date picker throughout the admin UI.

In order to be able to test these changes, the Opencast Statistics feature needs to be enabled and configured.


Diff:
diff --git a/package-lock.json b/package-lock.json
index 17632de9fb..62bbd698c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,8 +11,7 @@
         "@emotion/react": "^11.14.0",
         "@emotion/styled": "^11.14.0",
         "@hello-pangea/dnd": "^18.0.1",
-        "@mui/material": "^7.1.0",
-        "@mui/x-date-pickers": "^8.3.0",
+        "@mui/material": "^7.0.1",
         "@reduxjs/toolkit": "^2.8.1",
         "array-move": "^4.0.0",
         "axios": "^1.9.0",
@@ -1555,92 +1554,6 @@
         }
       }
     },
-    "node_modules/@mui/x-date-pickers": {
-      "version": "8.3.0",
-      "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.3.0.tgz",
-      "integrity": "sha512-N4Rw/Q6bAHBj7BpLEGE84MG05B56wWQStZch9NqA0U8vTt9TGvkj27fQaboOIikk6ERQTHM2mMoLZgwQAvlhIw==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/runtime": "^7.27.1",
-        "@mui/utils": "^7.0.2",
-        "@mui/x-internals": "8.3.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": "^5.15.14 || ^6.0.0 || ^7.0.0",
-        "@mui/system": "^5.15.14 || ^6.0.0 || ^7.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": "8.3.0",
-      "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.3.0.tgz",
-      "integrity": "sha512-wSVxg5aSO9xvJT7oarhsXqr03NeP355Whm7Qn6z3VvxdGwNc7K7vKpez3E+2KMxtdvywOmragwlSdTaO1K6qkg==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/runtime": "^7.27.1",
-        "@mui/utils": "^7.0.2"
-      },
-      "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/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
diff --git a/package.json b/package.json
index b440ea6dbf..9853abcfaa 100644
--- a/package.json
+++ b/package.json
@@ -8,8 +8,7 @@
     "@emotion/react": "^11.14.0",
     "@emotion/styled": "^11.14.0",
     "@hello-pangea/dnd": "^18.0.1",
-    "@mui/material": "^7.1.0",
-    "@mui/x-date-pickers": "^8.3.0",
+    "@mui/material": "^7.0.1",
     "@reduxjs/toolkit": "^2.8.1",
     "array-move": "^4.0.0",
     "axios": "^1.9.0",
diff --git a/src/components/shared/TimeSeriesStatistics.tsx b/src/components/shared/TimeSeriesStatistics.tsx
index 29249ce87a..ea789008cd 100644
--- a/src/components/shared/TimeSeriesStatistics.tsx
+++ b/src/components/shared/TimeSeriesStatistics.tsx
@@ -1,7 +1,7 @@
-import React from "react";
+import React, { useState } from "react";
 import moment from "moment";
 import { getCurrentLanguageInformation } from "../../utils/utils";
-import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import DatePicker from "react-datepicker";
 import { Formik, FormikErrors } from "formik";
 import { Field } from "./Field";
 import BarChart from "./BarChart";
@@ -82,23 +82,28 @@ const TimeSeriesStatistics = ({
 	// Get info about the current language and its date locale
 	const currentLanguage = getCurrentLanguageInformation();
 
+	// Set the date for the react-datepicker
+	const [startDatepicker, setStartDatepicker] = useState(fromDate ? new Date(fromDate) : null);
+	const [endDatepicker, setEndDatepicker] = useState(toDate ? new Date(toDate) : null);
+
 	// change formik values and get new statistic values from API
 	const change = (
 		setFormikValue: (field: string, value: any) => Promise<void | FormikErrors<any>>,
 		timeMode: TimeMode,
-		from: string | Date,
-		to: string | Date,
+		from: string,
+		to: string,
 		dataResolution: DataResolution
 	) => {
 		if (timeMode === "year" || timeMode === "month") {
 			from = moment(from).clone().startOf(timeMode).format("YYYY-MM-DD");
 			to = moment(from).clone().endOf(timeMode).format("YYYY-MM-DD");
-			setFormikValue("fromDat0e", from);
+			setStartDatepicker(new Date(from));
+			setEndDatepicker(new Date(to));
+			setFormikValue("fromDate", from);
 			setFormikValue("toDate", to);
 			setFormikValue("dataResolution", fixedDataResolutions(timeMode));
 			dataResolution = fixedStatisticDataResolutions(timeMode);
 		}
-
 		dispatch(onChange({id: resourceId, providerId, from, to, dataResolution, timeMode}));
 	};
 
@@ -114,30 +119,6 @@ const TimeSeriesStatistics = ({
 		change(setFormikValue, newTimeMode, from, to, dataResolution);
 	};
 
-	// change custom from date in formik and get new values from API
-	const changeFrom = async (
-		newFrom: Date,
-		setFormikValue: (field: string, value: any) => Promise<void | FormikErrors<any>>,
-		timeMode: TimeMode,
-		to: string,
-		dataResolution: DataResolution,
-	) => {
-		setFormikValue("fromDate", newFrom);
-		change(setFormikValue, timeMode, newFrom, to, dataResolution);
-	};
-
-	// change custom to date in formik and get new values from API
-	const changeTo = async (
-		newTo: Date,
-		setFormikValue: (field: string, value: any) => Promise<void | FormikErrors<any>>,
-		timeMode: TimeMode,
-		from: string,
-		dataResolution: DataResolution
-	) => {
-		setFormikValue("toDate", newTo);
-		change(setFormikValue, timeMode, from, newTo, dataResolution);
-	};
-
 	// change custom time granularity in formik and get new values from API
 	const changeGranularity = async (
 		granularity: DataResolution,
@@ -299,57 +280,47 @@ const TimeSeriesStatistics = ({
 						<span className="custom">
 							{/* time range selection */}
 							<div className="range">
+							<span>
+								{t("STATISTICS.TIMERANGE") /* Time range */}{" "}
+							</span>
 								{/* date picker for selecting start date of the statistic */}
-								<span>{t("STATISTICS.FROM") /* From */}</span>
-								<div className="chosen-container">
-									<DatePicker
-										name="fromDate"
-										value={typeof formik.values.fromDate === "string" ? parseISO(formik.values.fromDate) : formik.values.fromDate}
-										slotProps={{ textField: { placeholder: t(
-											"EVENTS.EVENTS.NEW.SOURCE.PLACEHOLDER.START_DATE"
-										) } }}
-										onChange={(value) => {
-											if (value) {
-												changeFrom(
-													value,
-													formik.setFieldValue,
-													formik.values.timeMode,
-													formik.values.toDate,
-													formik.values.dataResolution
-												)
-											}
-										}}
-									/>
-								</div>
-
-								{/* date picker for selecting end date of the statistic */}
-								<span>{t("STATISTICS.TO") /* To */}</span>
-								<div className="chosen-container">
-									<DatePicker
-										name="toDate"
-										value={typeof formik.values.toDate === "string" ? parseISO(formik.values.toDate) : formik.values.toDate}
-										slotProps={{ textField: { placeholder: t(
-											"EVENTS.EVENTS.NEW.SOURCE.PLACEHOLDER.END_DATE"
-										) } }}
-										onChange={(value) => {
-											if (value) {
-												changeTo(
-													value,
-													formik.setFieldValue,
-													formik.values.timeMode,
-													formik.values.fromDate,
-													formik.values.dataResolution
-												)
-											}
-										}}
-									/>
-								</div>
+								<DatePicker
+									selected={new Date(formik.values.fromDate)}
+									onChange={(dates) => {
+										const [startDate, endDate] = dates;
+										setStartDatepicker(startDate);
+										setEndDatepicker(endDate);
+										const newStartDate = startDate ? moment(startDate).format('YYYY-MM-DD') : formik.values.fromDate;
+										const newEndDate = endDate ? moment(endDate).format('YYYY-MM-DD') : formik.values.toDate;
+										change(
+											formik.setFieldValue,
+											formik.values.timeMode,
+											newStartDate,
+											newEndDate,
+											formik.values.dataResolution
+										);
+									}}
+									startDate={startDatepicker}
+									endDate={endDatepicker}
+									selectsRange
+									showYearDropdown
+									showMonthDropdown
+									yearDropdownItemNumber={2}
+									swapRange
+									allowSameDay
+									dateFormat="P"
+									popperPlacement="bottom"
+									popperClassName="datepicker-custom"
+									className="datepicker-custom-input"
+									locale={getCurrentLanguageInformation()?.dateLocale}
+									strictParsing
+								/>
 							</div>
 
 							{/* time granularity selection */}
 							<div>
 								<span>
-									{t("STATISTICS.GRANULARITY") + " " /* Granularity */}
+									{t("STATISTICS.GRANULARITY")/* Granularity */}{" "}
 								</span>
 								<div className="chosen-container chosen-container-single">
 									{/* drop-down for selecting the time granularity of the statistic */}
diff --git a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json
index af0d9ba519..44901350d6 100644
--- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json
+++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json
@@ -2080,9 +2080,8 @@
 	"STATISTICS": {
 		"NOT_AVAILABLE": "Statistics cannot be displayed at the moment. Please try again later.",
 		"UNSUPPORTED_TYPE": "Sorry, Opencast cannot render this type of statistical data.",
-		"FROM": "From",
-		"TO": "To",
 		"TOTAL": "Total",
+		"TIMERANGE": "Time Range",
 		"GRANULARITY": "Granularity",
 		"NAVIGATION": {
 			"ORGANIZATION": "Organization"
diff --git a/src/index.tsx b/src/index.tsx
index a407c961fa..5e0c80f3dd 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -17,21 +17,11 @@ import "./i18n/i18n";
 import "font-awesome/css/font-awesome.min.css";
 import "react-datepicker/dist/react-datepicker.css";
 import { HotkeysProvider } from "react-hotkeys-hook";
-import { ThemeProvider } from "@mui/material";
-import { createTheme } from '@mui/material/styles';
-import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
-import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
-import { getCurrentLanguageInformation } from "./utils/utils";
+
 
 // Commenting persistent stuff out can help with debugging
 const persistor = persistStore(store);
 
-const theme = createTheme({
-	zIndex: {
-		modal: 2147483550,
-	}
-})
-
 if (import.meta.env.DEV && import.meta.env.VITE_TEST_SERVER_URL) {
   axios.defaults.baseURL = import.meta.env.VITE_TEST_SERVER_URL || ""
   axios.defaults.headers.common['Authorization'] = import.meta.env.VITE_TEST_SERVER_AUTH && ("Basic " + window.btoa(import.meta.env.VITE_TEST_SERVER_AUTH));
@@ -47,13 +37,9 @@ root.render(
 	<React.StrictMode>
 		<Provider store={store}>
 			<PersistGate loading={<div>loading...</div>} persistor={persistor}>
-				<ThemeProvider theme={theme}>
-					<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={getCurrentLanguageInformation()?.dateLocale}> {/*locale={getCurrentLanguageInformation()?.dateLocale}> */}
-						<HotkeysProvider>
-							<App />
-						</HotkeysProvider>
-					</LocalizationProvider>
-				</ThemeProvider>
+				<HotkeysProvider>
+					<App />
+				</HotkeysProvider>
 			</PersistGate>
 		</Provider>
 	</React.StrictMode>,
diff --git a/src/styles/components/_components-config.scss b/src/styles/components/_components-config.scss
index 4f4ef03d86..9efb08fbe1 100644
--- a/src/styles/components/_components-config.scss
+++ b/src/styles/components/_components-config.scss
@@ -33,7 +33,6 @@
 @use "menu";
 @use "footer";
 @use "tables";
-@use "date-picker";
 @use "datepicker-custom";
 @use "inputs";
 @use "stats";
diff --git a/src/styles/components/_date-picker.scss b/src/styles/components/_date-picker.scss
deleted file mode 100644
index cc9d3f463d..0000000000
--- a/src/styles/components/_date-picker.scss
+++ /dev/null
@@ -1,87 +0,0 @@
-@use "../base/variables";
-@use "../mixins/mixins-config";
-
-/**
- * Licensed to The Apereo Foundation under one or more contributor license
- * agreements. See the NOTICE file distributed with this work for additional
- * information regarding copyright ownership.
- *
- *
- * The Apereo Foundation licenses this file to you under the Educational
- * Community License, Version 2.0 (the "License"); you may not use this file
- * except in compliance with the License. You may obtain a copy of the License
- * at:
- *
- *   http://opensource.org/licenses/ecl2.txt
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
- * License for the specific language governing permissions and limitations under
- * the License.
- *
- */
-
-
-.date-picker {
-    box-sizing: content-box;
-
-    &.popover{
-        @include mixins-config.btn-dd-content-mixin(520px);
-        left: auto;
-        right: 0;
-        height: 320px;
-        z-index: variables.$max-z;
-
-        .dates {
-            clear: both;
-            position: relative;
-
-            > label {
-                box-sizing: border-box;
-                font-size: 22px;
-                font-weight: 100;
-                margin: 0 20px;
-                width: 215px;
-                text-align: center;
-            }
-
-            .start {
-                float: left;
-            }
-
-            > span {
-                position: absolute;
-                top: 3px;
-                left: 50%;
-                color: variables.$light-prim-color;
-                margin-left: -7px;
-                text-transform: uppercase;
-            }
-
-            .end {
-                float: right;
-            }
-        }
-
-        // Set arrow position
-        &:after,
-        &:before {
-            left: 20px;
-        }
-
-        &.active {
-            opacity: 1;
-            visibility: visible;
-        }
-    }
-
-    .dashboard-container & {
-        visibility: visible;
-        opacity: 1;
-        position: static;
-        max-width: 100%;
-        height: intrinsic;
-        border-bottom: none;
-    }
-}
diff --git a/src/styles/components/_datepicker-custom.scss b/src/styles/components/_datepicker-custom.scss
index b0816d9ffe..0105684018 100644
--- a/src/styles/components/_datepicker-custom.scss
+++ b/src/styles/components/_datepicker-custom.scss
@@ -29,6 +29,7 @@
 
     &-input {
       height: 25px !important;
+      width: 100% !important;
     }
   }
 
diff --git a/src/styles/components/_statistics-graph.scss b/src/styles/components/_statistics-graph.scss
index 389e142a08..07664f2c7e 100644
--- a/src/styles/components/_statistics-graph.scss
+++ b/src/styles/components/_statistics-graph.scss
@@ -116,11 +116,6 @@
     justify-content: space-evenly;
     align-items: center;
 
-    > *:first-child {
-      width: 66%;
-      text-align: right;
-    }
-
     > * {
       width: 33%;
       text-align: left;


Reviews:
user: login: Arnei
body: Works and code looks reasonable to me. Yay, no more mui datepickers!

Small nitpicks below.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-14T14:11:49Z
user: login: snoesberger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T15:30:52Z
user: login: Arnei
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T07:41:05Z

Review comments:
diff_hunk: @@ -82,23 +82,28 @@ const TimeSeriesStatistics = ({
 	// Get info about the current language and its date locale
 	const currentLanguage = getCurrentLanguageInformation();
 
+	// Set the date for the react-datepicker
+	const [startDatepicker, setStartDatepicker] = useState(fromDate ? new Date(fromDate) : null);
+  	const [endDatepicker, setEndDatepicker] = useState(toDate ? new Date(toDate) : null);
path: src/components/shared/TimeSeriesStatistics.tsx
user: login: Arnei
body: Indentation (spaces mixed in)
created_at: 2025-05-14T13:58:04Z
author_association: MEMBER
diff_hunk: @@ -299,51 +280,41 @@ const TimeSeriesStatistics = ({
 						<span className="custom">
 							{/* time range selection */}
 							<div className="range">
+							<span>
+									{t("STATISTICS.TIMERANGE") + " " /* Time range */}
path: src/components/shared/TimeSeriesStatistics.tsx
user: login: Arnei
body: eslint is now stricter and complaining about this:

> Invalid operand for a '+' operation. Operands must each be a number or string, allowing a string + any of: `any`, `boolean`, `null`, `RegExp`, `undefined`. Got `unknown`.eslint[@typescript-eslint/restrict-plus-operands](https://typescript-eslint.io/rules/restrict-plus-operands)
created_at: 2025-05-14T14:03:54Z
author_association: MEMBER
diff_hunk: @@ -299,51 +280,41 @@ const TimeSeriesStatistics = ({
 						<span className="custom">
 							{/* time range selection */}
 							<div className="range">
+							<span>
+									{t("STATISTICS.TIMERANGE") + " " /* Time range */}
path: src/components/shared/TimeSeriesStatistics.tsx
user: login: snoesberger
body: This wasn't shown in my editor 🤷‍♂️. But I have found a workaround.
created_at: 2025-05-14T15:30:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-25T09:37:37Z
body: Use `docker` or `podman` to test this pull request locally.

Run test server using develop.opencast.org as backend:
```
podman run --rm -it -p 127.0.0.1:3000:3000 ghcr.io/opencast/opencast-admin-interface:pr-1185
```

Specify a different backend like stable.opencast.org:
```
podman run --rm -it -p 127.0.0.1:3000:3000 -e PROXY_TARGET=https://stable.opencast.org ghcr.io/opencast/opencast-admin-interface:pr-1185
```

It may take a few seconds for the interface to spin up.
It will then be available at http://127.0.0.1:3000.
For more options you can pass on to the proxy, take a look at the `README.md`.
<!-- thollander/actions-comment-pull-request "container-image" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-25T09:38:22Z
body: This pull request is deployed at [test.admin-interface.opencast.org/1185/2025-05-14_15-15-39/ ](https://test.admin-interface.opencast.org/1185/2025-05-14_15-15-39/).
It might take a few minutes for it to become available.
<!-- thollander/actions-comment-pull-request "static-test-deployment" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-14T07:39:39Z
body: This pull request has conflicts ☹
Please resolve those so we can review the pull request.
Thanks.
author_association: CONTRIBUTOR
user: login: JamesUoM
created_at: 2025-05-14T10:29:58Z
body: Sorry I didn't see this, as I've updated the MUI dependency https://github.com/opencast/opencast-admin-interface/pull/1248 and caused you a merge conflict
author_association: CONTRIBUTOR