From 68ec176719ace894d5c87be6dccdd67978313c29 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:54:05 +0100 Subject: [PATCH] Portal empty states: add CTAs and hide stat boxes (#6952) # Description of Changes Empty-state polish across the four processor (portal) list pages, so a fresh workspace gets clear next steps instead of a row of zeroed-out stat boxes. - **Sources / Pipelines** - hide the KPI stat strip when the list is empty; the empty state now shows an icon plus a primary + secondary CTA (Connect source / Read the docs; Create a pipeline / Connect a source). Also closes a gap where a successfully-fetched empty list rendered stat boxes over a blank page with no empty state at all. - **Policies** - hide the summary stat strip until at least one policy is configured; the catalogue cards stay as the "configure a policy" CTAs. - **Documents** - hide the filter-pill + search toolbar on an empty queue; the empty state gains an icon plus Create a pipeline / Connect a source CTAs. - **Storybook** - added `Default` + `Empty` stories for all four views; the preview now loads the real English copy so stories render shipped text rather than raw i18n keys. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/.storybook/declarations.d.ts | 7 ++ frontend/.storybook/preview.tsx | 22 ++++- .../public/locales/en-US/translation.toml | 3 + .../components/documents/ReviewQueue.test.tsx | 89 +++++++++++++++++++ .../components/documents/ReviewQueue.tsx | 77 ++++++++++++---- .../src/portal/views/Documents.stories.tsx | 39 ++++++++ .../src/portal/views/Pipelines.stories.tsx | 38 ++++++++ .../src/portal/views/Pipelines.test.tsx | 28 ++++++ .../editor/src/portal/views/Pipelines.tsx | 32 +++++-- .../src/portal/views/Policies.stories.tsx | 30 +++++++ frontend/editor/src/portal/views/Policies.tsx | 6 +- .../src/portal/views/Sources.stories.tsx | 37 ++++++++ .../editor/src/portal/views/Sources.test.tsx | 27 ++++++ frontend/editor/src/portal/views/Sources.tsx | 19 ++-- 14 files changed, 418 insertions(+), 36 deletions(-) create mode 100644 frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx create mode 100644 frontend/editor/src/portal/views/Documents.stories.tsx create mode 100644 frontend/editor/src/portal/views/Pipelines.stories.tsx create mode 100644 frontend/editor/src/portal/views/Policies.stories.tsx create mode 100644 frontend/editor/src/portal/views/Sources.stories.tsx diff --git a/frontend/.storybook/declarations.d.ts b/frontend/.storybook/declarations.d.ts index ef6d741f62..cc3fa9ae47 100644 --- a/frontend/.storybook/declarations.d.ts +++ b/frontend/.storybook/declarations.d.ts @@ -1 +1,8 @@ declare module "*.css" {} + +// Vite `?raw` suffix imports a file's contents as a string (used in preview.tsx +// to load the English translation TOML synchronously). +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 5ec17efb9a..a0b1549f2a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -21,19 +21,33 @@ import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import i18next from "i18next"; import { initReactI18next } from "react-i18next"; +import { parse as parseToml } from "smol-toml"; +// Load the real English copy so stories render human text, not raw keys. Bundled +// synchronously via ?raw so it's present on the very first render (no async flash). +// eslint-disable-next-line no-restricted-imports -- Storybook-only: read the public i18n asset; no @-alias covers editor/public/. +import enTranslationToml from "../editor/public/locales/en-US/translation.toml?raw"; import "@mantine/core/styles.css"; import "@core/tokens/tokens.css"; import "@core/tokens/base.css"; -// Storybook-only: init react-i18next so t(key, fallback, vars) interpolates its -// English fallback (there's no backend here to load locale files). Without this, -// the default t() returns raw templates like "{{count}} people · led by {{owner}}". +// Storybook-only: init react-i18next with the real English resources parsed from +// the app's TOML, so t(key) renders the shipped copy (e.g. "No sources connected +// yet") rather than the raw key. Falls back to an empty bundle if parsing ever +// fails, so a malformed TOML can't take the whole Storybook down. +function parseEnTranslation(): Record { + try { + return parseToml(enTranslationToml) as Record; + } catch { + return {}; + } +} + if (!i18next.isInitialized) { void i18next.use(initReactI18next).init({ lng: "en", fallbackLng: "en", - resources: { en: { translation: {} } }, + resources: { en: { translation: parseEnTranslation() } }, interpolation: { escapeValue: false }, react: { useSuspense: false }, }); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f8287a89b0..520e042ade 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6783,6 +6783,8 @@ type = "Type" user = "User" [portal.documents.queue.empty] +connectSource = "Connect a source" +createPipeline = "Create a pipeline" description = "As sources feed documents into your pipelines they'll appear here for review." title = "No documents in the queue" @@ -7424,6 +7426,7 @@ run = "Run now" [portal.pipelines.empty] action = "Create a pipeline" +connectSource = "Connect a source" description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." title = "No pipelines yet" diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx new file mode 100644 index 0000000000..ee8b0e8073 --- /dev/null +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { + render as baseRender, + screen, + type RenderResult, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter } from "react-router-dom"; +import type { ReactElement } from "react"; +import type { ReviewDocument } from "@portal/api/documents"; +import { ReviewQueue } from "@portal/components/documents/ReviewQueue"; + +// Deterministic i18n: keys returned verbatim. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +// Isolate ReviewQueue's own branching: stub the heavy children so the test +// doesn't need TierProvider (DocumentDrawer → useTier) or the real table body. +vi.mock("@portal/components/documents/DocumentDrawer", () => ({ + DocumentDrawer: () => null, +})); +vi.mock("@portal/components/documents/ReviewQueueTable", () => ({ + ReviewQueueTable: () => null, +})); + +const render = (ui: ReactElement): RenderResult => + baseRender( + + {ui} + , + ); + +const DOC: ReviewDocument = { + id: "doc-1", + name: "Invoice.pdf", + type: "PDF", + classification: "Invoice", + auto: true, + note: null, + product: "Editor", + action: null, + user: "you@acme.com", + status: "processed", + reviewer: null, + source: "Claims intake", + confidence: 0.98, + fieldsExtracted: 5, + time: "2 min ago", + sensitive: false, + extractions: [], + audit: [], +}; + +describe("ReviewQueue", () => { + it("hides the filter toolbar and shows CTAs when there are no documents", () => { + render(); + + // Empty-state panel with both CTAs. + expect( + screen.getByText("portal.documents.queue.empty.title"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.documents.queue.empty.createPipeline"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.documents.queue.empty.connectSource"), + ).toBeInTheDocument(); + + // The filter pills (counters over the list) are gone in the empty state. + expect( + screen.queryByText("portal.documents.filters.all"), + ).not.toBeInTheDocument(); + }); + + it("shows the filter toolbar when documents exist", () => { + render(); + + expect( + screen.getByText("portal.documents.filters.all"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.documents.queue.empty.title"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx index b4423fbe5c..240d61f782 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx @@ -1,7 +1,18 @@ import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { EmptyState, Input, Skeleton, Tabs, type TabItem } from "@app/ui"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { + Button, + EmptyState, + Input, + Skeleton, + Tabs, + type TabItem, +} from "@app/ui"; import type { DocumentStatus, ReviewDocument } from "@portal/api/documents"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { DocumentsIcon } from "@portal/components/icons"; import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable"; import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer"; @@ -40,6 +51,7 @@ function SearchIcon() { */ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(null); @@ -94,24 +106,28 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { return (
-
- - items={filterItems} - activeKey={filter} - onChange={setFilter} - variant="pill" - ariaLabel={t("portal.documents.filters.ariaLabel")} - /> - setQuery(e.target.value)} - placeholder={t("portal.documents.search")} - aria-label={t("portal.documents.search")} - leadingIcon={} - inputSize="sm" - /> -
+ {/* The filter pills + search are counters over the list, so hide them when + the list is empty — the empty state stands alone. */} + {!isLoading && !isEmpty && ( +
+ + items={filterItems} + activeKey={filter} + onChange={setFilter} + variant="pill" + ariaLabel={t("portal.documents.filters.ariaLabel")} + /> + setQuery(e.target.value)} + placeholder={t("portal.documents.search")} + aria-label={t("portal.documents.search")} + leadingIcon={} + inputSize="sm" + /> +
+ )} {isLoading && (
@@ -123,8 +139,31 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { {isEmpty && ( } title={t("portal.documents.queue.empty.title")} description={t("portal.documents.queue.empty.description")} + actions={ + <> + + + + } /> )} diff --git a/frontend/editor/src/portal/views/Documents.stories.tsx b/frontend/editor/src/portal/views/Documents.stories.tsx new file mode 100644 index 0000000000..8649d186cd --- /dev/null +++ b/frontend/editor/src/portal/views/Documents.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Documents } from "@portal/views/Documents"; + +const meta: Meta = { + title: "Portal/Views/Documents", + component: Documents, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: the filter pills + a populated review queue. */ +export const Default: Story = {}; + +/** + * A fresh workspace with nothing processed yet. The filter-pill toolbar + search + * stay hidden; the empty state drives the user to create a pipeline (primary) or + * connect a source (secondary) — the two things that feed the queue. + */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/proprietary/ui-data/documents", () => + HttpResponse.json({ + summary: { + totalInQueue: 0, + processed: 0, + errors: 0, + processedToday: 0, + }, + documents: [], + }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/Pipelines.stories.tsx b/frontend/editor/src/portal/views/Pipelines.stories.tsx new file mode 100644 index 0000000000..a07b50f605 --- /dev/null +++ b/frontend/editor/src/portal/views/Pipelines.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Pipelines } from "@portal/views/Pipelines"; + +const meta: Meta = { + title: "Portal/Views/Pipelines", + component: Pipelines, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: the KPI strip plus a populated pipelines table. */ +export const Default: Story = {}; + +/** + * A fresh workspace with no pipelines. The stat boxes stay hidden and the + * empty-state panel drives the user to create a pipeline (primary) or connect a + * source (secondary). + */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/policies/overview", () => + HttpResponse.json({ + kpis: [ + { value: 0, description: "" }, + { value: 0, description: "" }, + { value: 0, description: "" }, + ], + pipelines: [], + }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 6e19469cdb..ff54881c72 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -84,4 +84,32 @@ describe("Pipelines view", () => { fireEvent.click(await screen.findByText("Redaction sweep")); expect(await screen.findByText("pipeline page")).toBeInTheDocument(); }); + + it("shows the KPI stat boxes when pipelines exist", async () => { + renderView(); + await screen.findByText("Redaction sweep"); + expect(screen.getByText("portal.pipelines.kpi.total")).toBeInTheDocument(); + }); + + it("hides the stat boxes and shows create + connect-source CTAs when empty", async () => { + fetchPipelines.mockResolvedValue({ + kpis: [ + { value: 0, description: "" }, + { value: 0, description: "" }, + { value: 0, description: "" }, + ], + pipelines: [], + }); + renderView(); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.empty.connectSource"), + ).toBeInTheDocument(); + // The KPI strip is gone: no stat-box labels over an empty page. + expect( + screen.queryByText("portal.pipelines.kpi.total"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index 0122de5bbc..e2345f62a6 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -9,6 +9,7 @@ import { type PipelineView, } from "@portal/api/pipelines"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; import "@portal/views/Pipelines.css"; @@ -18,12 +19,18 @@ export function Pipelines() { const navigate = useNavigate(); const state = useAsync(() => fetchPipelines(), []); const { data, loading } = state; - const { isLoading, isEmpty } = useSectionFlags(state); + const { isLoading } = useSectionFlags(state); const pipelines = data?.pipelines ?? []; + // Empty once the fetch settles with no pipelines (or fails → no data). Gates + // both the KPI strip and the empty panel so no placeholder stat boxes sit + // above an empty page. + const showEmpty = !isLoading && pipelines.length === 0; const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); + const connectSource = () => + navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`); // A row opens that pipeline's own page (view / edit / run / delete live there). const openPipeline = (pipeline: PipelineView) => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); @@ -47,7 +54,7 @@ export function Pipelines() { - + {!showEmpty && } {isLoading && (
@@ -57,19 +64,30 @@ export function Pipelines() {
)} - {isEmpty && ( + {showEmpty && ( } title={t("portal.pipelines.empty.title")} description={t("portal.pipelines.empty.description")} actions={ - + <> + + + } /> )} - {!isLoading && !isEmpty && pipelines.length > 0 && ( + {!isLoading && pipelines.length > 0 && ( )}
diff --git a/frontend/editor/src/portal/views/Policies.stories.tsx b/frontend/editor/src/portal/views/Policies.stories.tsx new file mode 100644 index 0000000000..ac1a92b825 --- /dev/null +++ b/frontend/editor/src/portal/views/Policies.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Policies } from "@portal/views/Policies"; + +const meta: Meta = { + title: "Portal/Views/Policies", + component: Policies, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: the summary strip plus the configured catalogue. */ +export const Default: Story = {}; + +/** + * A fresh workspace with no policies configured. The summary stat boxes stay + * hidden; the catalogue cards remain, since each one is the CTA to configure + * that policy category. + */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/policies", () => HttpResponse.json([])), + http.get("/api/v1/policies/runs", () => HttpResponse.json([])), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index e620372b14..0795cef0f0 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -36,6 +36,10 @@ export function Policies() { const catalogue = data?.catalogue ?? []; const refetch = useCallback(() => setVersion((v) => v + 1), []); + // The catalogue cards are always shown (they're the "configure a policy" CTAs), + // but the summary strip is pure stat boxes: hide it until at least one policy + // is configured so a fresh workspace doesn't show a row of zeros. + const hasPolicies = !!data && data.summary.active + data.summary.paused > 0; const displayCatalogue: CatalogueEntry[] = catalogue.length > 0 @@ -123,7 +127,7 @@ export function Policies() { {pageError && } - + {hasPolicies && } {isLoading && (
diff --git a/frontend/editor/src/portal/views/Sources.stories.tsx b/frontend/editor/src/portal/views/Sources.stories.tsx new file mode 100644 index 0000000000..154faa7155 --- /dev/null +++ b/frontend/editor/src/portal/views/Sources.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Sources } from "@portal/views/Sources"; + +const meta: Meta = { + title: "Portal/Views/Sources", + component: Sources, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: the KPI strip plus a populated sources table. */ +export const Default: Story = {}; + +/** + * A fresh workspace with no sources connected. The stat boxes stay hidden and + * the empty-state panel drives the user to connect a source. + */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/sources", () => + HttpResponse.json({ + kpis: [ + { value: 0, description: "" }, + { value: 0, description: "" }, + { value: 0, description: "" }, + ], + sources: [], + }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 7226c2e017..29b38f58a9 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -146,4 +146,31 @@ describe("Sources view", () => { expect.objectContaining({ id: "src-referenced", enabled: false }), ); }); + + it("shows the KPI stat boxes when sources exist", async () => { + fetchSources.mockResolvedValue(RESPONSE); + renderView(); + await screen.findByText("Claims intake"); + expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument(); + }); + + it("hides the stat boxes and shows the connect CTA when empty", async () => { + fetchSources.mockResolvedValue({ + kpis: [ + { value: 0, description: "" }, + { value: 0, description: "" }, + { value: 0, description: "" }, + ], + sources: [], + }); + renderView(); + // The empty-state panel renders. + expect( + await screen.findByText("portal.sources.empty.title"), + ).toBeInTheDocument(); + // The KPI strip is gone: no stat-box labels over an empty page. + expect( + screen.queryByText("portal.sources.kpi.total"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 56ec5b4459..60f9d628f9 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { SourcesIcon } from "@portal/components/icons"; import { errorMessage } from "@portal/api/http"; import { createSource, @@ -29,7 +30,7 @@ export function Sources() { const [version, setVersion] = useState(0); const state = useAsync(() => fetchSources(), [version]); const { data, loading } = state; - const { isLoading, isEmpty } = useSectionFlags(state); + const { isLoading } = useSectionFlags(state); const refetch = useCallback(() => setVersion((v) => v + 1), []); const [expandedId, setExpandedId] = useState(null); @@ -43,6 +44,10 @@ export function Sources() { const sources = data?.sources ?? []; const expanded = sources.find((s) => s.id === expandedId) ?? null; + // Empty once the fetch settles with no sources (or fails → no data). Gates + // both the KPI strip and the empty panel so no placeholder stat boxes sit + // above an empty page. + const showEmpty = !isLoading && sources.length === 0; // The 30-day sparkline series lives off the list endpoint; fetch it for the one // expanded row only (empty while collapsed, so no request fires). @@ -146,7 +151,7 @@ export function Sources() { {pageError && } - + {!showEmpty && } {isLoading && (
@@ -156,19 +161,23 @@ export function Sources() {
)} - {isEmpty && ( + {showEmpty && ( } title={t("portal.sources.empty.title")} description={t("portal.sources.empty.description")} actions={ - } /> )} - {!isLoading && !isEmpty && sources.length > 0 && ( + {!isLoading && sources.length > 0 && (