feat(client): unified EmptyState component for empty/error placeholders
Replaces the cards-with-halo SkeletonCard fallback (which produced a washed-out, hard-to-read placeholder) with a single canonical empty state used everywhere in the app. What's new: - EmptyStateIllustration.tsx — custom inline SVG: two stacked monitor cards (front blue, back gray, both with hairline strokes and a soft fade toward the bottom), set against a faint grid of solid + dashed background lines. Uses only lightened palette tokens (#7DA8E8 blue, #B6C2CF gray, #CBD5E1 grid). - EmptyState.tsx — the layout: illustration + title + description + optional CTA + optional secondary link + optional inline alert slot. Supports a compact horizontal variant for in-panel use. Migrations: - Fallback.tsx: BaseFallback / EmptyFallback / EmptyMonitorFallback / ErrorFallback now delegate to EmptyState. Public APIs unchanged so every existing consumer (BasePageWithStates, MonitorBasePageWithStates, Pages/Uptime, Pages/Notifications, Pages/Infrastructure, etc.) keeps working without edits. - BasePage.tsx: PageSpeedKeyPriorityFallback rebuilt on top of EmptyState with the warning slot. Fixes the previous reports of "white cloudy area" + half-faded text — message and link now render in consistent dark text with green Settings link. No prop changes for any page-level consumer. The illustration is a single SVG with no external assets.
This commit is contained in:
1 parent
29f1041cc3
commit
ff208710d7
5 files changed
+451
-144
No files matched your search
@@ -1,15 +1,14 @@
|
||||
import Logo from "@/assets/icons/checkmate-icon.svg?react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Link from "@mui/material/Link";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import {
|
||||
ErrorFallback,
|
||||
EmptyFallback,
|
||||
EmptyMonitorFallback,
|
||||
BaseFallback,
|
||||
} from "@/Components/design-elements/Fallback";
|
||||
import { EmptyState } from "@/Components/design-elements/EmptyState";
|
||||
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
|
||||
@@ -21,32 +20,58 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
export const PageSpeedKeyPriorityFallback = () => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color="inherit"
|
||||
fontWeight="inherit"
|
||||
/>
|
||||
),
|
||||
<EmptyState
|
||||
title="PageSpeed monitor needs an API key"
|
||||
alert={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${theme.palette.warning.main}`,
|
||||
backgroundColor: theme.palette.warning.light,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: theme.palette.warning.dark,
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
mt: "2px",
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Alert>
|
||||
</BaseFallback>
|
||||
>
|
||||
⚠
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { EmptyStateIllustration } from "./EmptyStateIllustration";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actionText?: string;
|
||||
actionTo?: string;
|
||||
onAction?: () => void;
|
||||
secondaryText?: string;
|
||||
secondaryHref?: string;
|
||||
compact?: boolean;
|
||||
alert?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const EmptyState = ({
|
||||
title,
|
||||
description,
|
||||
actionText,
|
||||
actionTo,
|
||||
onAction,
|
||||
secondaryText,
|
||||
secondaryHref,
|
||||
compact = false,
|
||||
alert,
|
||||
children,
|
||||
}: EmptyStateProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
sx={{ py: theme.spacing(LAYOUT.MD), px: theme.spacing(LAYOUT.SM) }}
|
||||
>
|
||||
<EmptyStateIllustration
|
||||
width={80}
|
||||
height={60}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
py: theme.spacing(LAYOUT.XXL),
|
||||
px: theme.spacing(LAYOUT.MD),
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<EmptyStateIllustration />
|
||||
<Typography
|
||||
sx={{
|
||||
mt: theme.spacing(LAYOUT.MD),
|
||||
mb: theme.spacing(LAYOUT.XS),
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
maxWidth: 360,
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
mb: theme.spacing(LAYOUT.LG),
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{alert && (
|
||||
<Stack sx={{ width: "100%", maxWidth: 520, mb: theme.spacing(LAYOUT.MD) }}>
|
||||
{alert}
|
||||
</Stack>
|
||||
)}
|
||||
{(actionText || secondaryText) && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{actionText && (actionTo || onAction) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
{...(actionTo
|
||||
? { component: RouterLink, to: actionTo }
|
||||
: { onClick: onAction })}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryText && secondaryHref && (
|
||||
<Typography
|
||||
component="a"
|
||||
href={secondaryHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{secondaryText} →
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
interface EmptyStateIllustrationProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
export const EmptyStateIllustration = ({
|
||||
width = 160,
|
||||
height = 120,
|
||||
}: EmptyStateIllustrationProps) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 160 120"
|
||||
width={width}
|
||||
height={height}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="emptyStateCardFade"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="white"
|
||||
stopOpacity="1"
|
||||
/>
|
||||
<stop
|
||||
offset="65%"
|
||||
stopColor="white"
|
||||
stopOpacity="1"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="white"
|
||||
stopOpacity="0.35"
|
||||
/>
|
||||
</linearGradient>
|
||||
<mask
|
||||
id="emptyStateCardMask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="160"
|
||||
height="120"
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="160"
|
||||
height="120"
|
||||
fill="url(#emptyStateCardFade)"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<g
|
||||
stroke="#CBD5E1"
|
||||
strokeWidth="0.75"
|
||||
>
|
||||
<line
|
||||
x1="16"
|
||||
y1="0"
|
||||
x2="16"
|
||||
y2="120"
|
||||
/>
|
||||
<line
|
||||
x1="56"
|
||||
y1="0"
|
||||
x2="56"
|
||||
y2="120"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="104"
|
||||
y1="0"
|
||||
x2="104"
|
||||
y2="120"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="144"
|
||||
y1="0"
|
||||
x2="144"
|
||||
y2="120"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="12"
|
||||
x2="160"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="44"
|
||||
x2="160"
|
||||
y2="44"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="76"
|
||||
x2="160"
|
||||
y2="76"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="108"
|
||||
x2="160"
|
||||
y2="108"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g mask="url(#emptyStateCardMask)">
|
||||
<rect
|
||||
x="38"
|
||||
y="22"
|
||||
width="92"
|
||||
height="42"
|
||||
rx="6"
|
||||
fill="#F5F7FA"
|
||||
stroke="#D5DBE3"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="48"
|
||||
y="32"
|
||||
width="14"
|
||||
height="22"
|
||||
rx="2"
|
||||
fill="#B6C2CF"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="34"
|
||||
width="50"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#B6C2CF"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="42"
|
||||
width="38"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#B6C2CF"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="50"
|
||||
width="44"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#B6C2CF"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="22"
|
||||
y="50"
|
||||
width="92"
|
||||
height="42"
|
||||
rx="6"
|
||||
fill="#EEF4FE"
|
||||
stroke="#7DA8E8"
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="60"
|
||||
width="14"
|
||||
height="22"
|
||||
rx="2"
|
||||
fill="#7DA8E8"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="62"
|
||||
width="50"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#7DA8E8"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="70"
|
||||
width="38"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#7DA8E8"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="78"
|
||||
width="44"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill="#7DA8E8"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,8 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useNavigate } from "react-router";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
|
||||
interface BaseFallbackProps extends StackProps {
|
||||
@@ -15,30 +11,13 @@ interface BaseFallbackProps extends StackProps {
|
||||
|
||||
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
margin={isSmall ? "inherit" : "auto"}
|
||||
marginTop={isSmall ? "33%" : "10%"}
|
||||
width={{
|
||||
sm: "90%",
|
||||
md: "70%",
|
||||
lg: "50%",
|
||||
xl: "40%",
|
||||
}}
|
||||
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ width: "100%", py: theme.spacing(LAYOUT.LG) }}
|
||||
{...props}
|
||||
>
|
||||
<SkeletonCard showHalo={true} />
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
@@ -51,21 +30,19 @@ export const ErrorFallback = ({
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(LAYOUT.XS)}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography>{subtitle}</Typography>
|
||||
</BaseFallback>
|
||||
<EmptyState
|
||||
title={title}
|
||||
description={subtitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const bulletsToDescription = (bullets: unknown): string | undefined => {
|
||||
if (!Array.isArray(bullets) || bullets.length === 0) return undefined;
|
||||
return bullets.join(" • ");
|
||||
};
|
||||
|
||||
export const EmptyFallback = ({
|
||||
title,
|
||||
bullets,
|
||||
@@ -73,52 +50,21 @@ export const EmptyFallback = ({
|
||||
actionLink,
|
||||
}: {
|
||||
title: string;
|
||||
bullets: any;
|
||||
bullets: unknown;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography component="h1">{title}</Typography>
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(SPACING.MD),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.isArray(bullets) &&
|
||||
bullets?.map((bullet: string) => (
|
||||
<BulletPointCheck
|
||||
text={bullet}
|
||||
key={`${bullet}-${Math.random()}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(actionLink)}
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseFallback>
|
||||
<EmptyState
|
||||
title={title}
|
||||
description={bulletsToDescription(bullets)}
|
||||
actionText={actionButtonText}
|
||||
actionTo={actionLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyMonitorFallback = ({
|
||||
page,
|
||||
title,
|
||||
bullets,
|
||||
actionButtonText,
|
||||
@@ -126,51 +72,16 @@ export const EmptyMonitorFallback = ({
|
||||
}: {
|
||||
page: string;
|
||||
title: string;
|
||||
bullets: any;
|
||||
bullets: unknown;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(SPACING.MD),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.isArray(bullets) &&
|
||||
bullets?.map((bullet: string, index: number) => (
|
||||
<BulletPointCheck
|
||||
text={bullet}
|
||||
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(actionLink)}
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseFallback>
|
||||
<EmptyState
|
||||
title={title}
|
||||
description={bulletsToDescription(bullets)}
|
||||
actionText={actionButtonText}
|
||||
actionTo={actionLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./Breadcrumb";
|
||||
export * from "./BasePage";
|
||||
export * from "./EmptyState";
|
||||
export * from "./EmptyStateIllustration";
|
||||
export * from "./Fallback";
|
||||
export * from "./SkeletonCard";
|
||||
export * from "./BulletPointCheck";
|
||||
|
||||
Reference in new issue
Block a user