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.
This commit is contained in:
Anthony Stirling
2026-07-10 08:54:05 +00:00
committed by GitHub
parent d17c3f4fec
commit 68ec176719
14 changed files with 418 additions and 36 deletions
+7
View File
@@ -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;
}
+18 -4
View File
@@ -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<string, unknown> {
try {
return parseToml(enTranslationToml) as Record<string, unknown>;
} 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 },
});
@@ -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"
@@ -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(
<MemoryRouter>
<MantineProvider>{ui}</MantineProvider>
</MemoryRouter>,
);
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(<ReviewQueue documents={[]} loading={false} />);
// 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(<ReviewQueue documents={[DOC]} loading={false} />);
expect(
screen.getByText("portal.documents.filters.all"),
).toBeInTheDocument();
expect(
screen.queryByText("portal.documents.queue.empty.title"),
).not.toBeInTheDocument();
});
});
@@ -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<QueueFilter>("all");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -94,24 +106,28 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
return (
<div className="portal-documents__queue">
<div className="portal-documents__toolbar">
<Tabs<QueueFilter>
items={filterItems}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel={t("portal.documents.filters.ariaLabel")}
/>
<Input
className="portal-documents__search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.documents.search")}
aria-label={t("portal.documents.search")}
leadingIcon={<SearchIcon />}
inputSize="sm"
/>
</div>
{/* The filter pills + search are counters over the list, so hide them when
the list is empty — the empty state stands alone. */}
{!isLoading && !isEmpty && (
<div className="portal-documents__toolbar">
<Tabs<QueueFilter>
items={filterItems}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel={t("portal.documents.filters.ariaLabel")}
/>
<Input
className="portal-documents__search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.documents.search")}
aria-label={t("portal.documents.search")}
leadingIcon={<SearchIcon />}
inputSize="sm"
/>
</div>
)}
{isLoading && (
<div className="portal-documents__table-skeleton" aria-hidden>
@@ -123,8 +139,31 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
{isEmpty && (
<EmptyState
icon={<DocumentsIcon size={28} />}
title={t("portal.documents.queue.empty.title")}
description={t("portal.documents.queue.empty.description")}
actions={
<>
<Button
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`)
}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.documents.queue.empty.createPipeline")}
</Button>
<Button
variant="secondary"
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
}
>
{t("portal.documents.queue.empty.connectSource")}
</Button>
</>
}
/>
)}
@@ -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<typeof Documents> = {
title: "Portal/Views/Documents",
component: Documents,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Documents>;
/** 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: [],
}),
),
],
},
},
};
@@ -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<typeof Pipelines> = {
title: "Portal/Views/Pipelines",
component: Pipelines,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Pipelines>;
/** 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: [],
}),
),
],
},
},
};
@@ -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();
});
});
+25 -7
View File
@@ -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<PipelinesOverviewResponse>(() => 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() {
</Button>
</header>
<KpiStrip data={data} loading={loading} />
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-pipelines__table-skeleton" aria-hidden>
@@ -57,19 +64,30 @@ export function Pipelines() {
</div>
)}
{isEmpty && (
{showEmpty && (
<EmptyState
icon={<PipelinesIcon size={28} />}
title={t("portal.pipelines.empty.title")}
description={t("portal.pipelines.empty.description")}
actions={
<Button onClick={openCreate}>
{t("portal.pipelines.empty.action")}
</Button>
<>
<Button
onClick={openCreate}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.pipelines.empty.action")}
</Button>
<Button variant="secondary" onClick={connectSource}>
{t("portal.pipelines.empty.connectSource")}
</Button>
</>
}
/>
)}
{!isLoading && !isEmpty && pipelines.length > 0 && (
{!isLoading && pipelines.length > 0 && (
<PipelinesTable pipelines={pipelines} onRowClick={openPipeline} />
)}
</div>
@@ -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<typeof Policies> = {
title: "Portal/Views/Policies",
component: Policies,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Policies>;
/** 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([])),
],
},
},
};
@@ -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 && <Banner tone="danger" description={pageError} />}
<CatalogueSummary data={data} loading={loading} />
{hasPolicies && <CatalogueSummary data={data} loading={loading} />}
{isLoading && (
<div className="portal-policies__grid" aria-hidden>
@@ -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<typeof Sources> = {
title: "Portal/Views/Sources",
component: Sources,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Sources>;
/** 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: [],
}),
),
],
},
},
};
@@ -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();
});
});
+14 -5
View File
@@ -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<SourcesResponse>(() => 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<string | null>(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 && <Banner tone="danger" description={pageError} />}
<KpiStrip data={data} loading={loading} />
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
@@ -156,19 +161,23 @@ export function Sources() {
</div>
)}
{isEmpty && (
{showEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.sources.empty.title")}
description={t("portal.sources.empty.description")}
actions={
<Button onClick={openCreate}>
<Button
onClick={openCreate}
leftSection={<span aria-hidden>+</span>}
>
{t("portal.sources.actions.connectSource")}
</Button>
}
/>
)}
{!isLoading && !isEmpty && sources.length > 0 && (
{!isLoading && sources.length > 0 && (
<SourcesTable
sources={sources}
expandedId={expandedId}