feat(client): unified EmptyState across all pages and table empties

- New EmptyState + EmptyStateIllustration components: stacked-cards SVG
  with grid background, theme-aware light/dark palette.
- Variants: default (centered with title + description), compact
  (horizontal, in-section), fullscreen (vertically centers in viewport).
- Wired into Checks, Incidents, Logs (Logs+Queue tabs), Notifications,
  Maintenance, Status pages, and Team tab — each with i18n title +
  description and a fullscreen layout when truly empty.
- Table.EmptyView now wraps the EmptyState in a divider-bordered card
  so short messages no longer look orphaned.
- Incidents Option-A short-circuit: when summary.total === 0, replace
  the whole page chrome with EmptyState; otherwise keep filters + cards.
- Fallback.tsx (BaseFallback / EmptyFallback / EmptyMonitorFallback /
  ErrorFallback) refactored to delegate to EmptyState.
This commit is contained in:
gorkem-bwl committed 2026-04-25 23:26:34 -04:00
1 parent ff208710d7
commit 031a6fdf3c
12 files changed
+188 -145

No files matched your search

@@ -17,6 +17,7 @@ interface EmptyStateProps {
secondaryText?: string;
secondaryHref?: string;
compact?: boolean;
fullscreen?: boolean;
alert?: ReactNode;
children?: ReactNode;
}
@@ -30,6 +31,7 @@ export const EmptyState = ({
secondaryText,
secondaryHref,
compact = false,
fullscreen = false,
alert,
children,
}: EmptyStateProps) => {
@@ -78,6 +80,7 @@ export const EmptyState = ({
alignItems="center"
justifyContent="center"
sx={{
...(fullscreen && { minHeight: "65vh" }),
py: theme.spacing(LAYOUT.XXL),
px: theme.spacing(LAYOUT.MD),
textAlign: "center",
@@ -1,12 +1,37 @@
import { useTheme } from "@mui/material/styles";
interface EmptyStateIllustrationProps {
width?: number | string;
height?: number | string;
}
const PALETTE = {
light: {
grid: "#CBD5E1",
backFill: "#F5F7FA",
backStroke: "#D5DBE3",
backInner: "#B6C2CF",
frontFill: "#EEF4FE",
frontStroke: "#7DA8E8",
frontInner: "#7DA8E8",
},
dark: {
grid: "#3A4452",
backFill: "#475569",
backStroke: "#64748B",
backInner: "#CBD5E1",
frontFill: "#1E40AF",
frontStroke: "#60A5FA",
frontInner: "#DBEAFE",
},
};
export const EmptyStateIllustration = ({
width = 160,
height = 120,
}: EmptyStateIllustrationProps) => {
const theme = useTheme();
const p = theme.palette.mode === "dark" ? PALETTE.dark : PALETTE.light;
return (
<svg
viewBox="0 0 160 120"
@@ -14,50 +39,8 @@ export const EmptyStateIllustration = ({
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"
stroke={p.grid}
strokeWidth="0.75"
>
<line
@@ -114,15 +97,15 @@ export const EmptyStateIllustration = ({
/>
</g>
<g mask="url(#emptyStateCardMask)">
<g>
<rect
x="38"
y="22"
width="92"
height="42"
rx="6"
fill="#F5F7FA"
stroke="#D5DBE3"
fill={p.backFill}
stroke={p.backStroke}
strokeWidth="0.5"
/>
<rect
@@ -131,7 +114,7 @@ export const EmptyStateIllustration = ({
width="14"
height="22"
rx="2"
fill="#B6C2CF"
fill={p.backInner}
opacity="0.5"
/>
<rect
@@ -140,7 +123,7 @@ export const EmptyStateIllustration = ({
width="50"
height="2.5"
rx="1.25"
fill="#B6C2CF"
fill={p.backInner}
opacity="0.7"
/>
<rect
@@ -149,7 +132,7 @@ export const EmptyStateIllustration = ({
width="38"
height="2.5"
rx="1.25"
fill="#B6C2CF"
fill={p.backInner}
opacity="0.55"
/>
<rect
@@ -158,7 +141,7 @@ export const EmptyStateIllustration = ({
width="44"
height="2.5"
rx="1.25"
fill="#B6C2CF"
fill={p.backInner}
opacity="0.55"
/>
<rect
@@ -167,8 +150,8 @@ export const EmptyStateIllustration = ({
width="92"
height="42"
rx="6"
fill="#EEF4FE"
stroke="#7DA8E8"
fill={p.frontFill}
stroke={p.frontStroke}
strokeWidth="0.5"
strokeOpacity="0.4"
/>
@@ -178,7 +161,7 @@ export const EmptyStateIllustration = ({
width="14"
height="22"
rx="2"
fill="#7DA8E8"
fill={p.frontInner}
opacity="0.35"
/>
<rect
@@ -187,7 +170,7 @@ export const EmptyStateIllustration = ({
width="50"
height="2.5"
rx="1.25"
fill="#7DA8E8"
fill={p.frontInner}
opacity="0.55"
/>
<rect
@@ -196,7 +179,7 @@ export const EmptyStateIllustration = ({
width="38"
height="2.5"
rx="1.25"
fill="#7DA8E8"
fill={p.frontInner}
opacity="0.4"
/>
<rect
@@ -205,7 +188,7 @@ export const EmptyStateIllustration = ({
width="44"
height="2.5"
rx="1.25"
fill="#7DA8E8"
fill={p.frontInner}
opacity="0.4"
/>
</g>
@@ -32,32 +32,29 @@ export const ErrorFallback = ({
}) => {
return (
<EmptyState
fullscreen
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,
description,
actionButtonText,
actionLink,
}: {
title: string;
bullets: unknown;
description?: string;
actionButtonText: string;
actionLink: string;
}) => {
return (
<EmptyState
fullscreen
title={title}
description={bulletsToDescription(bullets)}
description={description}
actionText={actionButtonText}
actionTo={actionLink}
/>
@@ -66,20 +63,21 @@ export const EmptyFallback = ({
export const EmptyMonitorFallback = ({
title,
bullets,
description,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
bullets: unknown;
description?: string;
actionButtonText: string;
actionLink: string;
}) => {
return (
<EmptyState
fullscreen
title={title}
description={bulletsToDescription(bullets)}
description={description}
actionText={actionButtonText}
actionTo={actionLink}
/>
@@ -18,10 +18,9 @@ import {
ChevronsRight,
ChevronLeft,
ChevronRight,
Coffee,
Ellipsis,
PartyPopper,
} from "lucide-react";
import { EmptyState } from "./EmptyState";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
@@ -548,49 +547,18 @@ export const Pagination = ({ ...props }: PaginationProps) => {
);
};
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
const theme = useTheme();
const EmptyView = ({ text }: { text?: string; positive?: boolean }) => {
const { t } = useTranslation();
const Icon = positive ? PartyPopper : Coffee;
const theme = useTheme();
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
<Box
sx={{
py: theme.spacing(LAYOUT.XL),
px: theme.spacing(LAYOUT.XS),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
textAlign: "center",
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
backgroundColor: theme.palette.background.paper,
}}
>
<Box
sx={{
width: 64,
height: 64,
borderRadius: "50%",
backgroundColor: theme.palette.action.hover,
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(SPACING.LG),
}}
>
<Icon
size={28}
strokeWidth={1}
color={theme.palette.text.secondary}
/>
</Box>
<Typography
variant="subtitle1"
color={theme.palette.text.primary}
sx={{ fontWeight: 500 }}
>
{text ?? t("common.table.empty")}
</Typography>
</Stack>
<EmptyState title={text ?? t("common.table.empty")} />
</Box>
);
};
+15 -1
View File
@@ -1,15 +1,18 @@
import { Stack } from "@mui/material";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { HeaderTeamControls } from "./components/HeaderTeamControls";
import { TeamTable } from "./components/TeamTable";
import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog";
import { AddTeamMemberDialog } from "./components/AddTeamMemberDialog";
import { EmptyState } from "@/Components/design-elements";
import { useGet } from "@/Hooks/UseApi";
import type { User, UserRole } from "@/Types/User";
export const TabTeam = () => {
const theme = useTheme();
const { t } = useTranslation();
const [filter, setFilter] = useState<UserRole | "">("");
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
@@ -33,6 +36,9 @@ export const TabTeam = () => {
refetch();
};
const totalUsers = users?.length ?? 0;
const noUsers = users !== undefined && totalUsers === 0;
return (
<Stack gap={theme.spacing(8)}>
<HeaderTeamControls
@@ -41,7 +47,15 @@ export const TabTeam = () => {
onInviteClick={handleOpenInviteDialog}
onAddMemberClick={handleOpenAddMemberDialog}
/>
<TeamTable users={filteredUsers} />
{noUsers ? (
<EmptyState
fullscreen
title={t("pages.account.team.fallback.title")}
description={t("pages.account.team.fallback.description")}
/>
) : (
<TeamTable users={filteredUsers} />
)}
<InviteTeamMemberDialog
open={inviteDialogOpen}
onClose={handleCloseInviteDialog}
+13 -1
View File
@@ -1,6 +1,7 @@
import Stack from "@mui/material/Stack";
import {
BasePage,
EmptyState,
TotalChecksBox,
UpChecksBox,
DownChecksBox,
@@ -102,8 +103,19 @@ const Checks = () => {
const downChecks = summaryResponse?.downChecks || 0;
const upChecks = totalChecks - (summaryResponse?.downChecks || 0);
const noMonitors = !isLoadingMonitors && (monitorsResponse?.length ?? 0) === 0;
if (noMonitors) {
return (
<EmptyState
fullscreen
title={t("pages.checks.fallback.title")}
description={t("pages.checks.fallback.description")}
/>
);
}
return (
<BasePage>
<BasePage headerKey="checks">
<Stack
direction={{ xs: "column", md: "row" }}
gap={4}
+15 -10
View File
@@ -1,4 +1,4 @@
import { BasePage } from "@/Components/design-elements";
import { BasePage, EmptyState } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
@@ -153,8 +153,20 @@ const IncidentsPage = () => {
refetchSummary();
};
const trulyEmpty = summaryData !== undefined && (summaryData?.total ?? 0) === 0;
if (trulyEmpty) {
return (
<EmptyState
fullscreen
title={t("pages.incidents.fallback.title")}
description={t("pages.incidents.fallback.description")}
/>
);
}
return (
<BasePage>
<BasePage headerKey="incidents">
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
@@ -171,7 +183,7 @@ const IncidentsPage = () => {
setSelectedResolutionType={setFilter}
onClearFilters={handleClearFilters}
/>
{activeIncidentsCount > 0 ? (
{activeIncidentsCount > 0 && (
<>
<Typography
variant="h6"
@@ -193,13 +205,6 @@ const IncidentsPage = () => {
/>
<Divider />
</>
) : (
<Typography
variant="body1"
sx={{ mb: theme.spacing(4), color: theme.palette.success.main }}
>
{t("pages.incidents.summaryCard.activeIncidents.active_zero")}
</Typography>
)}
<Typography
+12 -1
View File
@@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import { Select } from "@/Components/inputs";
import { TableLogs } from "@/Pages/Logs/components/TableLogs";
import { EmptyState } from "@/Components/design-elements";
import { useTheme } from "@mui/material";
import { useGet } from "@/Hooks/UseApi";
@@ -15,7 +16,7 @@ import type { RootState } from "@/Types/state";
export const TabLogs = () => {
const theme = useTheme();
const { data: logs } = useGet<Log[]>("/logs");
const { data: logs, isLoading } = useGet<Log[]>("/logs");
const [selectedLogLevel, setSelectedLogLevel] = useState<LogLevelOption>("all");
const [page, setPage] = useState(0);
const rowsPerPage = useSelector(
@@ -33,6 +34,16 @@ export const TabLogs = () => {
const paginatedLogs =
filteredLogs?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ?? [];
if (!isLoading && (logs?.length ?? 0) === 0) {
return (
<EmptyState
fullscreen
title={t("pages.logs.fallback.title")}
description={t("pages.logs.fallback.description")}
/>
);
}
return (
<Stack gap={theme.spacing(8)}>
<Select
+66 -14
View File
@@ -1,20 +1,22 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { TableJobs, TableFailedJobs } from "./components/TableJobs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { useGet, usePost } from "@/Hooks/UseApi";
import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/inputs";
import { EmptyState } from "@/Components/design-elements";
export const TabQueue = () => {
const theme = useTheme();
const { t } = useTranslation();
const {
data: queueData,
// isLoading,
isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
@@ -23,6 +25,17 @@ export const TabQueue = () => {
const jobs = queueData?.jobs ?? [];
const metrics = queueData?.metrics ?? null;
const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
const noQueueData = !isLoading && metrics === null && jobs.length === 0;
if (hasNeverRun || noQueueData) {
return (
<EmptyState
fullscreen
title={t("pages.logs.queueFallback.title")}
description={t("pages.logs.queueFallback.description")}
/>
);
}
const handleFlushQueue = async () => {
await post("/queue/flush", {});
@@ -32,19 +45,58 @@ export const TabQueue = () => {
return (
<Stack gap={theme.spacing(8)}>
<Metrics metrics={metrics} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.jobQueue")}
</Typography>
<Stack gap={theme.spacing(1)}>
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.jobQueue")}
</Typography>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
<Trans
i18nKey="pages.logs.jobQueueExplainer"
components={{
highlight: (
<Box
component="span"
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(19, 113, 91, 0.18)"
: "#ECF7F2",
color: theme.palette.text.primary,
px: 1,
py: 0.25,
borderRadius: 0.5,
}}
/>
),
}}
/>
</Typography>
</Stack>
<TableJobs jobs={jobs} />
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.failedJobs")}
</Typography>
<Stack gap={theme.spacing(1)}>
<Typography
variant="h6"
sx={{ textTransform: "uppercase" }}
>
{t("pages.logs.failedJobs")}
</Typography>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
{t("pages.logs.failedJobsExplainer")}
</Typography>
</Stack>
<TableFailedJobs metrics={metrics} />
<Stack alignItems={"flex-end"}>
<Button
+2 -3
View File
@@ -32,11 +32,10 @@ const MaintenanceWindowPage = () => {
return (
<BasePageWithStates
headerKey="maintenanceWindow"
page={t("pages.maintenanceWindow.fallback.title")}
totalCount={maintenanceWindowCount}
bullets={
t("pages.maintenanceWindow.fallback.checks", { returnObjects: true }) as string[]
}
description={t("pages.maintenanceWindow.fallback.description")}
loading={isLoading}
error={!!error}
actionButtonText={t("pages.maintenanceWindow.fallback.actionButton")}
+2 -3
View File
@@ -39,10 +39,9 @@ const NotificationsPage = () => {
return (
<BasePageWithStates
headerKey="notifications"
page={t("pages.notifications.fallback.title")}
bullets={
t("pages.notifications.fallback.checks", { returnObjects: true }) as string[]
}
description={t("pages.notifications.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={notifications?.length ?? 0}
@@ -37,11 +37,10 @@ const StatusPages = () => {
return (
<BasePageWithStates
page={t("pages.statusPages.title")}
headerKey="statusPages"
page={t("pages.statusPages.fallback.title")}
loading={isLoading}
bullets={
t("pages.statusPages.fallback.checks", { returnObjects: true }) as string[]
}
description={t("pages.statusPages.fallback.description")}
error={!!error}
totalCount={statusPages?.length ?? 0}
actionButtonText={t("pages.statusPages.fallback.actionButton")}