refactoring

This commit is contained in:
Alex Holliday
2025-02-02 21:26:48 -08:00
parent 5a85711cac
commit 27ec0eabb2
17 changed files with 507 additions and 79 deletions

View File

@@ -12,7 +12,7 @@ import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import CreateStatus from "./Pages/Status/CreateStatus";
import CreateStatus from "./Pages/StatusPage/CreateStatus";
function App() {
const mode = useSelector((state) => state.ui.mode);
const { authToken } = useSelector((state) => state.auth);

View File

@@ -21,15 +21,17 @@ import { MuiColorInput } from "mui-color-input";
* >
* </ColorPicker>
*/
const ColorPicker = ({ id, value, error, onChange, onBlur }) => {
const ColorPicker = ({ id, name, value, error, onChange, onBlur }) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(4)}>
<MuiColorInput
format="hex"
name={name}
type="color-picker"
value={value}
id={id}
onChange={onChange}
onChange={(color) => onChange({ target: { name, value: color } })}
onBlur={onBlur}
/>
{error && (
@@ -54,7 +56,7 @@ ColorPicker.propTypes = {
value: PropTypes.string,
error: PropTypes.string,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired,
onBlur: PropTypes.func,
};
export default ColorPicker;

View File

@@ -4,7 +4,7 @@ import { useTheme } from "@emotion/react";
import TabPanel from "@mui/lab/TabPanel";
import ConfigBox from "../../../Components/ConfigBox";
import { StatusFormContext } from "../../../Pages/Status/CreateStatusContext";
import { StatusFormContext } from "../../../Pages/StatusPage/CreateStatusContext";
import { useSelector } from "react-redux";
import { logger } from "../../../Utils/Logger";
import { createToast } from "../../../Utils/toastUtils";
@@ -22,14 +22,8 @@ import { buildErrors } from "../../../Validation/error";
*/
const ContentPanel = () => {
const theme = useTheme();
const {
form,
setForm,
errors,
setErrors,
handleBlur,
handelCheckboxChange,
} = useContext(StatusFormContext);
const { form, setForm, errors, setErrors, handleBlur, handelCheckboxChange } =
useContext(StatusFormContext);
const [cards, setCards] = useState([]);
const { user, authToken } = useSelector((state) => state.auth);
const [monitors, setMonitors] = useState([]);
@@ -80,7 +74,7 @@ const ContentPanel = () => {
const handleServersBlur = () => {
const { error } = publicPageSettingsValidation.validate(
{ "monitors": form.monitors },
{ monitors: form.monitors },
{
abortEarly: false,
}
@@ -88,7 +82,7 @@ const ContentPanel = () => {
setErrors((prev) => {
return buildErrors(prev, "monitors", error);
});
};
};
return (
<TabPanel
value="Contents"
@@ -158,7 +152,7 @@ const ContentPanel = () => {
form={form}
setForm={setForm}
removeItem={removeCard}
onBlur= {handleServersBlur}
onBlur={handleServersBlur}
/>
{errors["monitors"] && (

View File

@@ -12,7 +12,7 @@ import Select from "../../Inputs/Select";
import { logoImageValidation } from "../../../Validation/validation";
import { formatBytes } from "../../../Utils/fileUtils";
import ProgressUpload from "../../ProgressBars";
import { StatusFormContext } from "../../../Pages/Status/CreateStatusContext";
import { StatusFormContext } from "../../../Pages/StatusPage/CreateStatusContext";
import ColorPicker from "../../Inputs/ColorPicker";
import Checkbox from "../../Inputs/Checkbox";
@@ -22,8 +22,15 @@ import Checkbox from "../../Inputs/Checkbox";
*/
const GeneralSettingsPanel = () => {
const theme = useTheme();
const { form, setForm, errors, setErrors, handleBlur, handleChange, handelCheckboxChange } =
useContext(StatusFormContext);
const {
form,
setForm,
errors,
setErrors,
handleBlur,
handleChange,
handelCheckboxChange,
} = useContext(StatusFormContext);
const [logo, setLogo] = useState(form.logo);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
@@ -213,7 +220,7 @@ const GeneralSettingsPanel = () => {
width: "100%",
maxWidth: "200px",
alignSelf: "center",
mt: theme.spacing(4)
mt: theme.spacing(4),
}}
>
Remove Logo

View File

@@ -87,33 +87,6 @@ const Account = ({ open = "profile" }) => {
onKeyDown={handleKeyDown}
onFocus={() => handleFocus(label.toLowerCase())}
tabIndex={index}
sx={{
fontSize: 13,
color: theme.palette.tertiary.contrastText,
backgroundColor: theme.palette.tertiary.main,
textTransform: "none",
minWidth: "fit-content",
paddingY: theme.spacing(6),
fontWeight: 400,
borderBottom: "2px solid transparent",
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
"&:first-child": { borderTopLeftRadius: "8px" },
"&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
"&:focus-visible": {
color: theme.palette.primary.contrastText,
borderColor: theme.palette.tertiary.contrastText,
borderRightColor: theme.palette.primary.lowContrast,
},
"&.Mui-selected": {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.secondary.contrastText,
borderColor: theme.palette.secondary.contrastText,
borderRightColor: theme.palette.primary.lowContrast,
},
"&:hover": {
borderColor: theme.palette.primary.lowContrast,
},
}}
/>
))}
</TabList>

View File

@@ -0,0 +1,37 @@
import { Button, Box } from "@mui/material";
import ProgressUpload from "../../../../../Components/ProgressBars";
import ImageIcon from "@mui/icons-material/Image";
import { formatBytes } from "../../../../../Utils/fileUtils";
const Progress = ({ isLoading, progressValue, logo, logoType, removeLogo, errors }) => {
if (isLoading) {
return (
<ProgressUpload
icon={<ImageIcon />}
label={logo?.name}
size={formatBytes(logo?.size)}
progress={progressValue}
onClick={removeLogo}
/>
);
}
if (logo && logoType) {
return (
<Box
width="fit-content"
alignSelf="center"
>
<Button
variant="contained"
color="secondary"
onClick={removeLogo}
>
Remove Logo
</Button>
</Box>
);
}
};
export default Progress;

View File

@@ -0,0 +1,14 @@
import { Stack, Typography } from "@mui/material";
import { TabPanel } from "@mui/lab";
const Content = ({ tabValue }) => {
return (
<TabPanel value={tabValue}>
<Stack>
<Typography>Content</Typography>
</Stack>
</TabPanel>
);
};
export default Content;

View File

@@ -0,0 +1,128 @@
// Components
import { Stack, Typography } from "@mui/material";
import { TabPanel } from "@mui/lab";
import ConfigBox from "../../../../../Components/ConfigBox";
import Checkbox from "../../../../../Components/Inputs/Checkbox";
import TextInput from "../../../../../Components/Inputs/TextInput";
import Select from "../../../../../Components/Inputs/Select";
import ImageField from "../../../../../Components/Inputs/Image";
import ColorPicker from "../../../../../Components/Inputs/ColorPicker";
import Progress from "../Progress";
// Utils
import { useTheme } from "@emotion/react";
import timezones from "../../../../../Utils/timezones.json";
const TabSettings = ({
tabValue,
form,
handleFormChange,
handleImageChange,
progress,
removeLogo,
errors,
}) => {
// Utils
const theme = useTheme();
return (
<TabPanel value={tabValue}>
<Stack gap={theme.spacing(10)}>
<ConfigBox>
<Stack>
<Typography component="h2">Access</Typography>
<Typography component="p">
If your status page is ready, you can mark it as published.
</Typography>
</Stack>
<Stack gap={theme.spacing(18)}>
<Checkbox
id="publish"
name="isPublished"
label={`Published and visible to the public`}
isChecked={form.isPublished}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">Basic Information</Typography>
<Typography component="p">
Define company name and the subdomain that your status page points to.
</Typography>
</Stack>
<Stack gap={theme.spacing(18)}>
<TextInput
id="companyName"
name="companyName"
type="text"
label="Company name"
value={form.companyName}
onChange={handleFormChange}
helperText={errors["companyName"]}
error={errors["companyName"] ? true : false}
/>
<TextInput
id="url"
name="url"
type="url"
label="Your status page address"
disabled
value={form.url}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">Timezone</Typography>
<Typography component="p">
Select the timezone that your status page will be displayed in.
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<Select
id="timezone"
name="timezone"
label="Display timezone"
items={timezones}
value={form.timezone}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
<ConfigBox>
<Stack gap={theme.spacing(6)}>
<Typography component="h2">Appearance</Typography>
<Typography component="p">
Define the default look and feel of your public status page.
</Typography>
</Stack>
<Stack gap={theme.spacing(6)}>
<ImageField
id="logo"
src={form.logo?.src}
isRound={false}
onChange={handleImageChange}
/>
<Progress
isLoading={progress.isLoading}
progressValue={progress.value}
logo={form.logo}
logoType={form.logo?.type}
removeLogo={removeLogo}
/>
<ColorPicker
id="color"
name="color"
value={form.color}
onChange={handleFormChange}
/>
</Stack>
</ConfigBox>
</Stack>
</TabPanel>
);
};
export default TabSettings;

View File

@@ -0,0 +1,165 @@
// Components
import { Stack, Tab, Button } from "@mui/material";
import { TabContext, TabList } from "@mui/lab";
import Settings from "./Components/Tabs/Settings";
import Content from "./Components/Tabs/Content";
//Utils
import { useTheme } from "@emotion/react";
import { useState, useRef } from "react";
import { publicPageSettingsValidation } from "../../../Validation/validation";
import { buildErrors } from "../../../Validation/error";
//Constants
const TAB_LIST = ["General settings", "Contents"];
const CreateStatusPage = () => {
//Redux state
//Local state
const [tab, setTab] = useState(0);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
const [form, setForm] = useState({
isPublished: false,
companyName: "",
url: "/status/public",
logo: null,
timezone: "America/Toronto",
color: "#4169E1",
});
const [errors, setErrors] = useState({});
// Refs
const intervalRef = useRef(null);
//Utils
const theme = useTheme();
// Handlers
const handleFormChange = (e) => {
let { type, name, value, checked } = e.target;
// Handle errors
const { error } = publicPageSettingsValidation.validate(
{ [name]: value },
{ abortEarly: false }
);
setErrors((prev) => {
return buildErrors(prev, name, error);
});
//Handle checkbox
if (type === "checkbox") {
setForm((prev) => ({
...prev,
[name]: checked,
}));
return;
}
// Handle other inputs
setForm((prev) => ({
...prev,
[name]: value,
}));
};
const handleImageChange = (event) => {
const img = event.target?.files?.[0];
const newLogo = {
src: URL.createObjectURL(img),
name: img.name,
type: img.type,
size: img.size,
};
setForm((prev) => ({
...prev,
logo: newLogo,
}));
intervalRef.current = setInterval(() => {
const buffer = 12;
setProgress((prev) => {
if (prev.value + buffer >= 100) {
clearInterval(intervalRef.current);
return { value: 100, isLoading: false };
}
return { ...prev, value: prev.value + buffer };
});
}, 120);
};
const removeLogo = () => {
setForm((prev) => ({
...prev,
logo: undefined,
}));
// interrupt interval if image upload is canceled prior to completing the process
clearInterval(intervalRef.current);
setProgress({ value: 0, isLoading: false });
};
const handleSubmit = () => {
let toSubmit = {
...form,
logo: { type: form.logo?.type ?? null, size: form.logo?.size ?? null },
};
const { error } = publicPageSettingsValidation.validate(toSubmit, {
abortEarly: false,
});
console.log(error);
setErrors((prev) => {
return buildErrors(prev, name, error);
});
};
return (
<Stack gap={theme.spacing(10)}>
<TabContext value={TAB_LIST[tab]}>
<TabList
onChange={(_, tab) => {
setTab(TAB_LIST.indexOf(tab));
}}
>
{TAB_LIST.map((tab, idx) => (
<Tab
key={Math.random()}
label={TAB_LIST[idx]}
value={TAB_LIST[idx]}
/>
))}
</TabList>
{tab === 0 ? (
<Settings
tabValue={TAB_LIST[0]}
form={form}
handleFormChange={handleFormChange}
handleImageChange={handleImageChange}
progress={progress}
removeLogo={removeLogo}
errors={errors}
/>
) : (
<Content
tabValue={TAB_LIST[1]}
form={form}
handleFormChange={handleFormChange}
/>
)}
</TabContext>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="contained"
color="accent"
onClick={handleSubmit}
>
Save
</Button>
</Stack>
</Stack>
);
};
export default CreateStatusPage;

View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
const useStatusPageFetch = () => {
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [statusPage, setStatusPage] = useState(undefined);
return [statusPage, isLoading, networkError];
};
export { useStatusPageFetch };

View File

@@ -0,0 +1,48 @@
// Components
import { Typography, Stack } from "@mui/material";
import GenericFallback from "../../../Components/GenericFallback";
import Fallback from "../../../Components/Fallback";
// Utils
import { useStatusPageFetch } from "./Hooks/useStatusPageFetch";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
const PublicStatus = () => {
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const [statusPage, isLoading, networkError] = useStatusPageFetch();
if (networkError === true) {
return (
<GenericFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.primary.contrastTextTertiary}
>
Network error
</Typography>
<Typography>Please check your connection</Typography>
</GenericFallback>
);
}
if (!isLoading && typeof statusPage === "undefined") {
return (
<Fallback
title="status page"
checks={[
"Display a list of monitors to track",
"Share your monitors with the public",
]}
link="/status/create"
isAdmin={isAdmin}
/>
);
}
return <Stack gap={theme.spacing(10)}>Content Here</Stack>;
};
export default PublicStatus;

View File

@@ -9,34 +9,32 @@ import { useSelector } from "react-redux";
import { logger } from "../../Utils/Logger";
/**
* The configuration page for public page that contains a general settings and
* The configuration page for public page that contains a general settings and
* content tabs, It will display a static page if there is no status page configured
* or the status page if one is already configured
*/
const Status = () => {
const theme = useTheme();
const navigate = useNavigate();
const {authToken} = useSelector((state) => state.auth);
const navigate = useNavigate();
const { authToken } = useSelector((state) => state.auth);
const [initForm, setInitForm] = useState({});
const STATUS_PAGE = import.meta.env.VITE_STATU_PAGE_URL?? "status-page";
const STATUS_PAGE = import.meta.env.VITE_STATU_PAGE_URL ?? "status-page";
useEffect(() => {
const getStatusPage = async () => {
let config = { authToken: authToken, url: STATUS_PAGE };
try {
let res = await networkService.getStatusPageByUrl(config);
if(res && res.data)
setInitForm( res.data.data)
}catch (error) {
let res = await networkService.getStatusPageByUrl(config);
if (res && res.data) setInitForm(res.data.data);
} catch (error) {
logger.error("Failed to fetch status page", error);
}
}
};
getStatusPage();
}, []);
return (
<>
{Object.keys(initForm).length===0? (
{Object.keys(initForm).length === 0 ? (
<Box
className="status"
sx={{
@@ -46,7 +44,7 @@ const Status = () => {
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
borderStyle: "dashed",
backgroundColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.main,
overflow: "hidden",
},
}}
@@ -76,10 +74,10 @@ const Status = () => {
</Stack>
</Box>
) : (
<CreateStatus initForm={initForm}/>
)}
<CreateStatus initForm={initForm} />
)}
</>
);
);
};
export default Status;

View File

@@ -29,7 +29,12 @@ import InfrastructureDetails from "../Pages/Infrastructure/Details";
import Incidents from "../Pages/Incidents";
import Status from "../Pages/Status";
//Status pages
import Status from "../Pages/StatusPage";
import CreateStatus from "../Pages/StatusPage/Create";
import OldCreateStatus from "../Pages/StatusPage/CreateStatus";
import PublicStatus from "../Pages/StatusPage/PublicStatus";
import Integrations from "../Pages/Integrations";
// Settings
@@ -41,7 +46,6 @@ import Maintenance from "../Pages/Maintenance";
import ProtectedRoute from "../Components/ProtectedRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
import withAdminCheck from "../Components/HOC/withAdminCheck";
import CreateStatus from "../Pages/Status/CreateStatus";
const Routes = () => {
const AdminCheckedRegister = withAdminCheck(AuthRegister);
@@ -111,12 +115,16 @@ const Routes = () => {
<Route
path="status"
element={<Status />}
element={<PublicStatus />}
/>
<Route
path="status/create"
element={<CreateStatus/>}
element={<CreateStatus />}
/>
<Route
path="status/old-create"
element={<OldCreateStatus />}
/>
<Route
path="integrations"

View File

@@ -302,6 +302,37 @@ const baseTheme = (palette) => ({
},
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({
fontSize: 13,
color: theme.palette.tertiary.contrastText,
backgroundColor: theme.palette.tertiary.main,
textTransform: "none",
minWidth: "fit-content",
paddingY: theme.spacing(6),
fontWeight: 400,
borderBottom: "2px solid transparent",
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
"&:first-of-type": { borderTopLeftRadius: "8px" },
"&:last-child": { borderTopRightRadius: "8px", borderRight: 0 },
"&:focus-visible": {
color: theme.palette.primary.contrastText,
borderColor: theme.palette.tertiary.contrastText,
borderRightColor: theme.palette.primary.lowContrast,
},
"&.Mui-selected": {
backgroundColor: theme.palette.secondary.main,
color: theme.palette.secondary.contrastText,
borderColor: theme.palette.secondary.contrastText,
borderRightColor: theme.palette.primary.lowContrast,
},
"&:hover": {
borderColor: theme.palette.primary.lowContrast,
},
}),
},
},
},
shape: {
borderRadius: 2,

View File

@@ -178,20 +178,33 @@ const imageValidation = joi.object({
}),
});
const logoImageValidation = joi.object({
type: joi.string().valid("image/jpeg", "image/png").messages({
"any.only": "Invalid file format.",
"string.empty": "File type required.",
}),
size: joi.number().max(3000000).messages({
"number.base": "File size must be a number.",
"number.max": "File size must be less than 3MB.",
"number.empty": "File size required.",
}),
});
const logoImageValidation = joi
.object({
type: joi
.string()
.valid("image/jpeg", "image/png")
.allow(null) // Allow null and empty string
.messages({
"any.only": "Invalid file format.",
"string.empty": "File type required.",
})
.optional(),
size: joi
.number()
.max(3000000)
.allow(null) // Allow null and empty string
.messages({
"number.base": "File size must be a number.",
"number.max": "File size must be less than 3MB.",
"number.empty": "File size required.",
})
.optional(),
})
.allow(null)
.optional(); // Make entire object optional
const publicPageSettingsValidation = joi.object({
publish: joi.bool(),
isPublished: joi.bool(),
companyName: joi
.string()
.trim()
@@ -209,7 +222,6 @@ const publicPageSettingsValidation = joi.object({
logo: logoImageValidation,
showUptimePercentage: joi.boolean(),
showBarcode: joi.boolean(),
showBarcode: joi.boolean(),
});
const settingsValidation = joi.object({
ttl: joi.number().required().messages({