mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
+7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user