diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx new file mode 100644 index 000000000..c63c5ce08 --- /dev/null +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -0,0 +1,100 @@ +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 Typography from "@mui/material/Typography"; +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; + error?: boolean; + helperText?: string; +} + +export const DatePickerComponent = ({ + fieldLabel, + required, + error, + helperText, + ...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: error ? theme.palette.error.main : theme.palette.divider, + borderRadius: theme.shape.borderRadius, + }, + "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": + { + borderColor: error ? theme.palette.error.main : theme.palette.divider, + }, + }, + }, + inputAdornment: { sx: { ml: 0 } }, + openPickerButton: { + sx: { + py: 0, + mr: 0, + }, + }, + ...props.slotProps, + }} + /> + + ); + + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); +}; diff --git a/client/src/Components/v2/inputs/TimePicker.tsx b/client/src/Components/v2/inputs/TimePicker.tsx new file mode 100644 index 000000000..251332fe1 --- /dev/null +++ b/client/src/Components/v2/inputs/TimePicker.tsx @@ -0,0 +1,61 @@ +import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker"; +import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker"; +import type { Dayjs } from "dayjs"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers"; + +interface TimePickerComponentProps extends Omit, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const TimePickerComponent = ({ + fieldLabel, + required, + ...props +}: TimePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + + + ); + + 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..25d9febe3 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -14,3 +14,5 @@ export * from "./Switch"; export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; +export { DatePickerComponent as DatePicker } from "./DatePicker"; +export { TimePickerComponent as TimePicker } from "./TimePicker"; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts new file mode 100644 index 000000000..1f10d55ef --- /dev/null +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { + maintenanceWindowSchema, + repeatOptions, + 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"; +}; + +export const useMaintenanceWindowForm = ({ + data = null, +}: UseMaintenanceWindowFormOptions = {}) => { + return useMemo(() => { + let defaults: MaintenanceWindowFormData; + + if (data) { + const startDate = dayjs(data.start); + + defaults = { + name: data.name, + repeat: getRepeatId(data.repeat), + startDate: startDate.format("YYYY-MM-DD"), + startTime: startDate.format("HH:mm"), + duration: data.duration ?? 0, + durationUnit: data.durationUnit ?? "minutes", + 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/CreateMaintenance/Components/ConfigSelect/index.jsx deleted file mode 100644 index 8d4c0ae53..000000000 --- a/client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import PropTypes from "prop-types"; -const ConfigSelect = ({ configSelection, valueSelect, onChange, ...props }) => { - const getValueById = (config, id) => { - const item = config.find((config) => config._id === id); - return item ? (item.value ? item.value : item.name) : null; - }; - - const getIdByValue = (config, name) => { - const item = config.find((config) => { - if (config.value) { - return config.value === name; - } else { - return config.name === name; - } - }); - return item ? item._id : null; - }; - - return ( - + {repeatOptions.map((option) => ( + + {option.name} + + ))} + + )} + /> + + } + /> + + ( + { + field.onChange(date ? date.format("YYYY-MM-DD") : ""); + trigger("startDate"); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + + } + /> + + ( + { + field.onChange(time ? time.format("HH:mm") : ""); + trigger("startDate"); + }} + /> + )} + /> + + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + sx={{ width: 120 }} + /> + )} + /> + ( + + )} + /> + + + } + /> + { + const monitorsList = monitors ?? []; + const selectedMonitors = field.value + .map((id: string) => monitorsList.find((m) => m.id === id)) + .filter((m): m is Monitor => m !== undefined); + + return ( + + option.name} + value={selectedMonitors} + onChange={(_, newValue) => { + field.onChange(newValue.map((m: Monitor) => m.id)); + }} + fieldLabel={t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + )} + renderInput={(params) => ( + + )} + /> + {selectedMonitors.length > 0 && ( + + {selectedMonitors.map((monitor) => ( + + {monitor.name} + { + field.onChange( + field.value.filter((id: string) => id !== monitor.id) + ); + }} + aria-label="Remove monitor" + > + + + + ))} + + )} + + ); + }} + /> + } + /> + + + + + ); +}; + +export default CreateMaintenanceWindowPage; 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={ + <> + + + + + } /> { + const startDateTime = dayjs(data.startDate) + .set("hour", parseInt(data.startTime.split(":")[0], 10)) + .set("minute", parseInt(data.startTime.split(":")[1], 10)); + return startDateTime.isAfter(dayjs()); + }, + { + message: "Start date and time must be in the future", + path: ["startDate"], + } + ); + +export type MaintenanceWindowFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2f61a5f62..644057425 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -792,6 +792,52 @@ "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" + } + } + }, + "startDate": { + "title": "Start date", + "description": "Select the start date for your maintenance window.", + "option": { + "startDate": { + "label": "Start date" + } + } + }, + "startTime": { + "title": "Start time", + "description": "Set the start time and duration for your maintenance window. All values are in UTC", + "option": { + "duration": { + "label": "Duration" + }, + + "startTime": { + "label": "Start time" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors that the maintenance window should apply to", + "option": { + "addMonitors": { + "label": "Add monitors" + } + } + } + } } }, "notifications": { @@ -1282,16 +1328,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" } diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index b68de206e..ff309a117 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { MaintenanceWindow } from "@/types/maintenanceWindow.js"; +import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js"; type MaintenanceWindowDocumentBase = Omit & { monitorId: Types.ObjectId; @@ -34,6 +34,13 @@ const MaintenanceWindowSchema = new Schema( name: { type: String, }, + duration: { + type: Number, + }, + durationUnit: { + type: String, + enum: ["seconds", "minutes", "hours", "days"], + }, repeat: { type: Number, }, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index e85af5cb9..a8910d0df 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -33,6 +33,8 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository teamId: this.toStringId(doc.teamId), active: doc.active, name: doc.name, + duration: doc.duration, + durationUnit: doc.durationUnit, repeat: doc.repeat, start: this.toDateString(doc.start), end: this.toDateString(doc.end), diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index faf0ee943..816bdca59 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -45,6 +45,8 @@ class MaintenanceWindowService { monitorId, name: body.name, active: body.active ? body.active : true, + duration: body.duration, + durationUnit: body.durationUnit, repeat: body.repeat, start: body.start, end: body.end, diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index 33285b8e4..9e4b47959 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -1,9 +1,13 @@ +export type DurationUnit = "seconds" | "minutes" | "hours" | "days"; + export interface MaintenanceWindow { id: string; monitorId: String; teamId: string; active: boolean; name: string; + duration: number; + durationUnit: DurationUnit; repeat: number; start: string; end: string; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index d31e01420..49f919a4a 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -364,6 +364,8 @@ const createMaintenanceWindowBodyValidation = joi.object({ monitors: joi.array().items(joi.string()).required(), name: joi.string().required(), active: joi.boolean(), + duration: joi.number().required(), + durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(), start: joi.date().required(), end: joi.date().required(), repeat: joi.number().required(), @@ -402,6 +404,8 @@ const editMaintenanceByIdWindowBodyValidation = joi.object({ end: joi.date(), expiry: joi.date(), monitors: joi.array(), + duration: joi.number(), + durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"), }); //****************************************