Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions src/components/EmptyState/EmptyState.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EmptyIllustration,
EmptyBridges,
EmptyAlerts,
EmptyIncidents,
EmptyTransactions,
EmptySearch,
EmptyConnection,
Expand Down Expand Up @@ -129,31 +130,33 @@ export const EmptyBridgesWithFilters: Story = {
),
};

export const EmptyAlertsActive: Story = {
name: "EmptyAlerts — active",
render: () => (
<EmptyAlerts view="active" onConfigureAlerts={action("configure-alerts")} />
),
export const EmptyAlertsDefault: Story = {
name: "EmptyAlerts — no data",
render: () => <EmptyAlerts />,
};

export const EmptyAlertsHistory: Story = {
name: "EmptyAlerts — history",
render: () => <EmptyAlerts view="history" />,
export const EmptyAlertsWithFilters: Story = {
name: "EmptyAlerts — filters active",
render: () => (
<EmptyAlerts hasFilters onClearFilters={action("clear-filters")} />
),
};

export const EmptyAlertsSuppressed: Story = {
name: "EmptyAlerts — suppressed",
render: () => <EmptyAlerts view="suppressed" />,
export const EmptyIncidentsDefault: Story = {
name: "EmptyIncidents",
render: () => <EmptyIncidents />,
};

export const EmptyTransactionsDefault: Story = {
name: "EmptyTransactions — generic",
render: () => <EmptyTransactions />,
};

export const EmptyTransactionsBridge: Story = {
name: "EmptyTransactions — bridge specific",
render: () => <EmptyTransactions bridgeName="Circle" />,
export const EmptyTransactionsWithFilters: Story = {
name: "EmptyTransactions — filters active",
render: () => (
<EmptyTransactions hasFilters onClearFilters={action("clear-filters")} />
),
};

export const EmptySearchNoQuery: Story = {
Expand Down
2 changes: 2 additions & 0 deletions src/components/EmptyState/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* EmptyIllustration,
* EmptyBridges,
* EmptyAlerts,
* EmptyIncidents,
* EmptyTransactions,
* EmptySearch,
* EmptyConnection,
Expand All @@ -24,6 +25,7 @@ export * as EmptyIllustration from "./EmptyIllustration";
export {
EmptyBridges,
EmptyAlerts,
EmptyIncidents,
EmptyTransactions,
EmptySearch,
EmptyConnection,
Expand Down
63 changes: 63 additions & 0 deletions src/components/EmptyState/variants.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Suspense } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "../../i18n/config";
import { EmptyBridges, EmptyAlerts, EmptyIncidents, EmptyTransactions, EmptyWatchlist } from "./variants";

function renderVariant(ui: React.ReactElement) {
return render(
<MemoryRouter>
<Suspense fallback={null}>{ui}</Suspense>
</MemoryRouter>,
);
}

describe("EmptyState variants", () => {
beforeEach(async () => {
await i18n.changeLanguage("en");
});

it("EmptyBridges shows the no-data message with no clear action by default", async () => {
renderVariant(<EmptyBridges />);

expect(await screen.findByText("No bridges yet")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument();
});

it("EmptyBridges shows the filtered message and a clear-filters action when hasFilters is set", async () => {
const onClearFilters = vi.fn();
renderVariant(<EmptyBridges hasFilters onClearFilters={onClearFilters} />);

expect(await screen.findByText("No bridges match your filters")).toBeInTheDocument();
screen.getByRole("button", { name: "Clear filters" }).click();
expect(onClearFilters).toHaveBeenCalledTimes(1);
});

it("EmptyAlerts distinguishes no data from no matches", async () => {
renderVariant(<EmptyAlerts />);
expect(await screen.findByText("No active alerts")).toBeInTheDocument();

renderVariant(<EmptyAlerts hasFilters onClearFilters={() => {}} />);
expect(await screen.findByText("No alerts match your filters")).toBeInTheDocument();
});

it("EmptyIncidents renders a friendly message", async () => {
renderVariant(<EmptyIncidents />);
expect(await screen.findByText("No incidents recorded")).toBeInTheDocument();
});

it("EmptyTransactions distinguishes no data from no matches", async () => {
renderVariant(<EmptyTransactions />);
expect(await screen.findByText("No transactions found")).toBeInTheDocument();

renderVariant(<EmptyTransactions hasFilters onClearFilters={() => {}} />);
expect(await screen.findByText("No transactions match your filters")).toBeInTheDocument();
});

it("EmptyWatchlist links to the bridges page", async () => {
renderVariant(<EmptyWatchlist onBrowseBridges={() => {}} />);

expect(await screen.findByText("Your watchlist is empty")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse bridges" })).toHaveAttribute("href", "/bridges");
});
});
137 changes: 87 additions & 50 deletions src/components/EmptyState/variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Ready-to-use compositions of EmptyState + EmptyIllustration for each
* Swipely view. Import the variant that matches the page rather than
* constructing props from scratch — this keeps copy and illustrations
* consistent across the app.
* consistent across the app. Copy is routed through react-i18next.
*
* Usage:
* import { EmptyBridges, EmptyAlerts } from "@/components/EmptyState";
Expand All @@ -13,6 +13,7 @@
* if (bridges.length === 0) return <EmptyBridges onAddBridge={openModal} />;
*/

import { useTranslation } from "react-i18next";
import { EmptyState } from "./EmptyState";
import * as EmptyIllustration from "./EmptyIllustration";

Expand All @@ -25,17 +26,19 @@ interface EmptyBridgesProps {
}

export function EmptyBridges({ hasFilters, onClearFilters }: EmptyBridgesProps) {
const { t } = useTranslation();

if (hasFilters) {
return (
<EmptyState
variant="page"
illustration={<EmptyIllustration.NoResults />}
title="No bridges match your filters"
description="Try adjusting your search or filter criteria to find what you're looking for."
title={t("emptyStates.bridges.filteredTitle")}
description={t("emptyStates.bridges.filteredDescription")}
actions={[
{ label: "Clear filters", onClick: onClearFilters, variant: "primary" },
{ label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" },
]}
ariaLabel="No bridges match the current filters"
ariaLabel={t("emptyStates.bridges.filteredTitle")}
/>
);
}
Expand All @@ -44,74 +47,99 @@ export function EmptyBridges({ hasFilters, onClearFilters }: EmptyBridgesProps)
<EmptyState
variant="page"
illustration={<EmptyIllustration.NoBridges />}
title="No bridges yet"
description="Swipely hasn't detected any bridges. Data is fetched from the Stellar network automatically — check back shortly."
ariaLabel="No bridges found"
title={t("emptyStates.bridges.title")}
description={t("emptyStates.bridges.description")}
ariaLabel={t("emptyStates.bridges.title")}
/>
);
}

// ── No alerts ─────────────────────────────────────────────────────────────────

interface EmptyAlertsProps {
/** The alerts sub-view the user is on (active, history, suppressed). */
view?: "active" | "history" | "suppressed";
onConfigureAlerts?: () => void;
/** Whether any filters or search are active — changes copy and actions. */
hasFilters?: boolean;
onClearFilters?: () => void;
}

export function EmptyAlerts({ view = "active", onConfigureAlerts }: EmptyAlertsProps) {
const copy: Record<string, { title: string; description: string }> = {
active: {
title: "No active alerts",
description:
"All monitored bridges are within their configured thresholds. Alerts will appear here when anomalies are detected.",
},
history: {
title: "No alert history",
description:
"No alerts have been triggered yet. Past alerts will appear here once thresholds are breached.",
},
suppressed: {
title: "No suppressed alerts",
description:
"You haven't suppressed any alerts. Suppressed alerts are temporarily muted and won't trigger notifications.",
},
};
export function EmptyAlerts({ hasFilters, onClearFilters }: EmptyAlertsProps) {
const { t } = useTranslation();

if (hasFilters) {
return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoResults />}
title={t("emptyStates.alerts.filteredTitle")}
description={t("emptyStates.alerts.filteredDescription")}
actions={[
{ label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" },
]}
ariaLabel={t("emptyStates.alerts.filteredTitle")}
/>
);
}

return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoAlerts />}
title={copy[view].title}
description={copy[view].description}
actions={
view === "active" && onConfigureAlerts
? [{ label: "Configure alert rules", onClick: onConfigureAlerts, variant: "secondary" }]
: []
}
ariaLabel={copy[view].title}
title={t("emptyStates.alerts.title")}
description={t("emptyStates.alerts.description")}
ariaLabel={t("emptyStates.alerts.title")}
/>
);
}

// ── No incidents ──────────────────────────────────────────────────────────────

export function EmptyIncidents() {
const { t } = useTranslation();

return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoAlerts />}
title={t("emptyStates.incidents.title")}
description={t("emptyStates.incidents.description")}
ariaLabel={t("emptyStates.incidents.title")}
/>
);
}

// ── No transactions ───────────────────────────────────────────────────────────

interface EmptyTransactionsProps {
bridgeName?: string;
/** Whether any filters are active — changes copy and actions. */
hasFilters?: boolean;
onClearFilters?: () => void;
}

export function EmptyTransactions({ bridgeName }: EmptyTransactionsProps) {
export function EmptyTransactions({ hasFilters, onClearFilters }: EmptyTransactionsProps) {
const { t } = useTranslation();

if (hasFilters) {
return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoResults />}
title={t("emptyStates.transactions.filteredTitle")}
description={t("emptyStates.transactions.filteredDescription")}
actions={[
{ label: t("common.clearFilters"), onClick: onClearFilters, variant: "primary" },
]}
ariaLabel={t("emptyStates.transactions.filteredTitle")}
/>
);
}

return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoTransactions />}
title="No transactions found"
description={
bridgeName
? `No transactions have been recorded for ${bridgeName} in the selected time range.`
: "No transactions match the selected filters. Try a different time range."
}
ariaLabel="No transactions found"
title={t("emptyStates.transactions.title")}
description={t("emptyStates.transactions.description")}
ariaLabel={t("emptyStates.transactions.title")}
/>
);
}
Expand Down Expand Up @@ -166,18 +194,27 @@ interface EmptyWatchlistProps {
}

export function EmptyWatchlist({ onBrowseBridges }: EmptyWatchlistProps) {
const { t } = useTranslation();

return (
<EmptyState
variant="card"
illustration={<EmptyIllustration.NoWatchlist />}
title="Your watchlist is empty"
description="Star bridges you want to track closely. They'll show up here for quick access."
title={t("emptyStates.watchlist.title")}
description={t("emptyStates.watchlist.description")}
actions={
onBrowseBridges
? [{ label: "Browse bridges", onClick: onBrowseBridges, href: "/bridges", variant: "primary" }]
? [
{
label: t("emptyStates.watchlist.browseBridges"),
onClick: onBrowseBridges,
href: "/bridges",
variant: "primary",
},
]
: []
}
ariaLabel="Watchlist is empty"
ariaLabel={t("emptyStates.watchlist.title")}
/>
);
}
Expand Down
18 changes: 16 additions & 2 deletions src/components/IncidentHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// src/components/IncidentHeatmap.tsx
import React, { useMemo } from "react";
import { useIncidentFeed, type IncidentSeverity, type BridgeIncident } from "../hooks/useIncidentFeed";
import { EmptyIncidents } from "./EmptyState";

// Helper to bucket incidents by date (YYYY-MM-DD) and asset code
function bucketIncidents(incidents: BridgeIncident[]) {
Expand Down Expand Up @@ -52,8 +53,21 @@ export default function IncidentHeatmap() {
Failed to load incidents.
</div>
)}
{isLoading && <p className="text-stellar-text-secondary">Loading…</p>}
{!isLoading && !error && (
{isLoading && (
<div
className="grid gap-1"
style={{ gridTemplateColumns: "repeat(6, minmax(30px, 1fr))" }}
role="status"
aria-busy="true"
aria-label="Loading incident heatmap"
>
{Array.from({ length: 5 * 6 }).map((_, i) => (
<div key={i} className="skeleton w-6 h-6 rounded" />
))}
</div>
)}
{!isLoading && !error && dates.length === 0 && <EmptyIncidents />}
{!isLoading && !error && dates.length > 0 && (
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${assetsSet.length + 1}, minmax(30px, 1fr))` }}>
{/* Header row */}
<div></div>
Expand Down
Loading
Loading