This commit is contained in:
Alex Holliday committed 2026-02-04 21:45:39 +00:00
1 parent d74d79c332
commit f0b2292f0a
14 files changed
+371 -14

No files matched your search

@@ -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<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
}
export const DatePickerComponent = ({
fieldLabel,
required,
...props
}: DatePickerComponentProps) => {
const theme = useTheme();
const picker = (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
{...props}
slots={{
openPickerIcon: () => (
<Calendar
size={20}
stroke={theme.palette.text.secondary}
/>
),
...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,
}}
/>
</LocalizationProvider>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
};
@@ -14,3 +14,4 @@ export * from "./Switch";
export * from "./Slider";
export * from "./ImageUpload";
export * from "./ColorPicker";
export { DatePickerComponent as DatePicker } from "./DatePicker";
@@ -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]);
};
@@ -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<MaintenanceWindow>(
isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null
);
const { schema, defaults } = useMaintenanceWindowForm({
data: existingMaintenanceWindow,
});
const form = useForm<MaintenanceWindowFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control } = form;
return (
<BasePage>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="name"
control={control}
defaultValue={defaults.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="repeat"
control={control}
defaultValue={defaults.repeat}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
}}
/>
)}
/>
</Stack>
}
/>
</BasePage>
);
};
export default CreateMaintenanceWindowPage;
+8 -2
View File
@@ -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 = () => {
/>
<Route
path="/maintenance/create/:maintenanceWindowId?"
element={<CreateNewMaintenanceWindow />}
element={
<>
<ThemeProvider theme={v2theme}>
<CreateNewMaintenanceWindow />
</ThemeProvider>
</>
}
/>
<Route
path="settings"
@@ -0,0 +1,32 @@
import { z } from "zod";
export const repeatOptions = [
{ id: "none", name: "Don't repeat", value: 0 },
{ id: "daily", name: "Repeat daily", value: 86400000 },
{ id: "weekly", name: "Repeat weekly", value: 604800000 },
] as const;
export const durationUnitOptions = [
{ id: "seconds", name: "seconds", multiplier: 1000 },
{ id: "minutes", name: "minutes", multiplier: 60000 },
{ id: "hours", name: "hours", multiplier: 3600000 },
{ id: "days", name: "days", multiplier: 86400000 },
] as const;
export const maintenanceWindowSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be at most 100 characters"),
repeat: z.string(),
startDate: z.string().min(1, "Start date is required"),
startTime: z.string().min(1, "Start time is required"),
duration: z
.number()
.int()
.min(1, "Duration must be at least 1"),
durationUnit: z.string(),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
});
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
+40 -12
View File
@@ -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"
}