From f0b2292f0ae749516089a396ab79ac030b9fb3d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 21:45:39 +0000 Subject: [PATCH] general --- .../src/Components/v2/inputs/DatePicker.tsx | 91 +++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Hooks/useMaintenanceWindowForm.ts | 79 ++++++++++++ .../Components/ConfigSelect/index.jsx | 0 .../Components/MonitorList/index.jsx | 0 .../Components/MonitorsConfig/index.jsx | 0 .../hooks/useMaintenanceActions.jsx | 0 .../hooks/useMaintenanceData.jsx | 0 .../{CreateMaintenance => create}/index.css | 0 client/src/Pages/Maintenance/create/index.tsx | 120 ++++++++++++++++++ .../index.jsx => create/old.jsx} | 0 client/src/Routes/index.jsx | 10 +- client/src/Validation/maintenanceWindow.ts | 32 +++++ client/src/locales/en.json | 52 ++++++-- 14 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 client/src/Components/v2/inputs/DatePicker.tsx create mode 100644 client/src/Hooks/useMaintenanceWindowForm.ts rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/ConfigSelect/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorList/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorsConfig/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceActions.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceData.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/index.css (100%) create mode 100644 client/src/Pages/Maintenance/create/index.tsx rename client/src/Pages/Maintenance/{CreateMaintenance/index.jsx => create/old.jsx} (100%) create mode 100644 client/src/Validation/maintenanceWindow.ts diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx new file mode 100644 index 000000000..4a852f2f1 --- /dev/null +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -0,0 +1,91 @@ +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker"; +import type { Dayjs } from "dayjs"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; +import { Calendar } from "lucide-react"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers"; + +interface DatePickerComponentProps extends Omit, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const DatePickerComponent = ({ + fieldLabel, + required, + ...props +}: DatePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + ( + + ), + ...props.slots, + }} + slotProps={{ + switchViewButton: { sx: { display: "none" } }, + nextIconButton: { sx: { ml: theme.spacing(2) } }, + day: { + sx: { + "&.MuiPickersDay-root.Mui-disabled": { + color: theme.palette.text.disabled, + }, + }, + }, + field: { + sx: { + width: "fit-content", + "& > .MuiOutlinedInput-root": { + flexDirection: "row-reverse", + }, + "& input": { + minHeight: 34, + p: 0, + pl: theme.spacing(3), + pr: theme.spacing(5), + }, + "& fieldset": { + borderColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + }, + "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": + { + borderColor: theme.palette.divider, + }, + }, + }, + inputAdornment: { sx: { ml: 0 } }, + openPickerButton: { + sx: { + py: 0, + mr: 0, + }, + }, + ...props.slotProps, + }} + /> + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {picker} + + ); + } + + return picker; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index fa04903c9..6cb40aade 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -14,3 +14,4 @@ export * from "./Switch"; export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; +export { DatePickerComponent as DatePicker } from "./DatePicker"; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts new file mode 100644 index 000000000..b1a6f86ad --- /dev/null +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { + maintenanceWindowSchema, + repeatOptions, + durationUnitOptions, + type MaintenanceWindowFormData, +} from "@/Validation/maintenanceWindow"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; + +interface UseMaintenanceWindowFormOptions { + data?: MaintenanceWindow | null; +} + +const getRepeatId = (repeatMs: number): string => { + const option = repeatOptions.find((opt) => opt.value === repeatMs); + return option?.id ?? "none"; +}; + +const getDurationAndUnit = ( + startMs: number, + endMs: number +): { duration: number; durationUnit: string } => { + const diffMs = endMs - startMs; + + for (const unit of [...durationUnitOptions].reverse()) { + if (diffMs >= unit.multiplier && diffMs % unit.multiplier === 0) { + return { + duration: diffMs / unit.multiplier, + durationUnit: unit.id, + }; + } + } + + return { + duration: diffMs / 1000, + durationUnit: "seconds", + }; +}; + +export const useMaintenanceWindowForm = ({ + data = null, +}: UseMaintenanceWindowFormOptions = {}) => { + return useMemo(() => { + let defaults: MaintenanceWindowFormData; + + if (data) { + const startDate = dayjs(data.start); + const endDate = dayjs(data.end); + const { duration, durationUnit } = getDurationAndUnit( + startDate.valueOf(), + endDate.valueOf() + ); + + defaults = { + name: data.name, + repeat: getRepeatId(data.repeat), + startDate: startDate.format("YYYY-MM-DD"), + startTime: startDate.format("HH:mm"), + duration, + durationUnit, + monitors: [data.monitorId], + }; + } else { + const now = dayjs(); + defaults = { + name: "", + repeat: "none", + startDate: now.format("YYYY-MM-DD"), + startTime: now.format("HH:mm"), + duration: 0, + durationUnit: "minutes", + monitors: [], + }; + } + + return { schema: maintenanceWindowSchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx b/client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx rename to client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.css b/client/src/Pages/Maintenance/create/index.css similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.css rename to client/src/Pages/Maintenance/create/index.css diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx new file mode 100644 index 000000000..15948ae3b --- /dev/null +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -0,0 +1,120 @@ +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { TextField, Select, DatePicker } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; +import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; +import { repeatOptions } from "@/Validation/maintenanceWindow"; +import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; +import { useGet } from "@/Hooks/UseApi"; +import { useParams } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; + +const CreateMaintenanceWindowPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>(); + const isEditMode = Boolean(maintenanceWindowId); + + const { data: existingMaintenanceWindow } = useGet( + isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null + ); + + const { schema, defaults } = useMaintenanceWindowForm({ + data: existingMaintenanceWindow, + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { control } = form; + + return ( + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + ( + { + field.onChange(date ? date.format("YYYY-MM-DD") : ""); + }} + /> + )} + /> + + } + /> + + ); +}; + +export default CreateMaintenanceWindowPage; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/client/src/Pages/Maintenance/create/old.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.jsx rename to client/src/Pages/Maintenance/create/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index ee08dc581..d4a5619d6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -49,7 +49,7 @@ import EditUser from "../Pages/Account/EditUser/index.jsx"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; -import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx"; +import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; @@ -313,7 +313,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2f61a5f62..3d2e0ba61 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -792,6 +792,45 @@ "nextWindow": "Next window", "repeat": "Repeat" } + }, + "form": { + "general": { + "title": "General settings", + "description": "Set a name and repeat option for your maintenance window.", + "option": { + "name": { + "label": "Name", + "placeholder": "e.g. Weekly Maintenance" + }, + "repeat": { + "label": "Repeat" + } + } + }, + "startTime": { + "title": "Start time", + "description": "Set the start time and duration for your maintenance window.", + "option": { + "duration": { + "label": "Duration" + }, + "startDate": { + "label": "Start date" + }, + "startTime": { + "label": "Start time" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors that the maintenance window should apply to", + "option": { + "addMonitors": { + "label": "Add monitors" + } + } + } + } } }, "notifications": { @@ -1282,16 +1321,5 @@ "monitorDeleted": "Monitor deleted successfully", "monitorPaused": "Monitor paused successfully", "monitorResumed": "Monitor resumed successfully", - "name": "Name", - "maintenanceWindow": { - "fallback": { - "actionButton": "Let's create your first maintenance window!", - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:" - } - } + "name": "Name" }