From 95d78ce495720a5131b15856eb4b090d26417c19 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 22 May 2026 03:27:06 +0200 Subject: [PATCH] polish(empty-state): unify copy and CTA pattern across pages (#837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty states on /, /users, /connections, /widget-lab used inconsistent tone, button labels, and CTA presence. Unifying them around a single pattern: "No X yet" title + one-line subtitle + "Create your first X" primary + "Read the docs" secondary. - EmptyState gains an optional secondaryAction prop rendered below the primary action with consistent spacing. Backward compatible. - / (reader case): trim multi-line description to one sentence, move "Read the docs" link from primary action to secondaryAction (it's the only CTA readers can use). - /users: "No users found" → "No users yet"; "Create User" → "Create your first user"; add docs link. PageHeader's "Create User" button unchanged (so existing E2E selectors keep working). - /connections: "Add your first connection" → "Create your first connection" (consistent verb); add docs link. - /widget-lab: ADD primary "Create your first template" button wired to handleCreate (was previously CTA-less); add docs link. Filtered empty state unchanged. - 3 new EmptyState unit tests covering the secondaryAction prop Closes #837 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/app/(dashboard)/connections/page.tsx | 14 ++++- app/src/app/(dashboard)/page.tsx | 22 +++---- app/src/app/(dashboard)/users/page.tsx | 16 ++++- app/src/app/(dashboard)/widget-lab/page.tsx | 18 +++++- .../composed/__tests__/empty-state.test.tsx | 61 +++++++++++++++++-- .../src/components/composed/empty-state.tsx | 14 +++-- 6 files changed, 117 insertions(+), 28 deletions(-) diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index 6cdf5b95..6e3b9572 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -1156,13 +1156,23 @@ export default function ConnectionsPage() { } title="No connections yet" - description="Add your first database connection to start querying data." + description="Connect a database to start building dashboards." action={ } + secondaryAction={ + + Read the docs + + } /> )} diff --git a/app/src/app/(dashboard)/page.tsx b/app/src/app/(dashboard)/page.tsx index 7da02301..c556e9dd 100644 --- a/app/src/app/(dashboard)/page.tsx +++ b/app/src/app/(dashboard)/page.tsx @@ -599,18 +599,16 @@ export default function DashboardListPage() { } title="No dashboards yet" - description="No dashboards have been shared with you yet. Ask an admin or editor to share one, or read the docs to learn what NeoBoard can do." - action={ - + description="Ask an admin or editor to share one with you." + secondaryAction={ + + Read the docs + } /> ) diff --git a/app/src/app/(dashboard)/users/page.tsx b/app/src/app/(dashboard)/users/page.tsx index 6ca04dab..1252f897 100644 --- a/app/src/app/(dashboard)/users/page.tsx +++ b/app/src/app/(dashboard)/users/page.tsx @@ -582,14 +582,24 @@ export default function UsersPage() { ) : !users?.length ? ( } - title="No users found" - description="Create your first user to get started." + title="No users yet" + description="Add team members so they can collaborate on dashboards." action={ } + secondaryAction={ + + Read the docs + + } /> ) : ( } title="No templates yet" - description='Create a new template or save a widget from any dashboard using the "Save to Widget Lab" action.' + description="Reusable widgets you can drop into any dashboard." + action={ + + } + secondaryAction={ + + Read the docs + + } /> ) : ( { }); it("renders description when provided", () => { - render(); + render( + , + ); expect(screen.getByText("Try adjusting your search")).toBeInTheDocument(); }); it("does not render description when not provided", () => { render(); - expect(screen.queryByText("Try adjusting your search")).not.toBeInTheDocument(); + expect( + screen.queryByText("Try adjusting your search"), + ).not.toBeInTheDocument(); }); it("renders icon when provided", () => { - render(!} />); + render( + !} + />, + ); expect(screen.getByTestId("icon")).toBeInTheDocument(); }); it("renders action when provided", () => { - render(Create new} />); - expect(screen.getByRole("button", { name: "Create new" })).toBeInTheDocument(); + render( + Create new} />, + ); + expect( + screen.getByRole("button", { name: "Create new" }), + ).toBeInTheDocument(); }); it("applies custom className", () => { - const { container } = render(); + const { container } = render( + , + ); expect(container.firstChild).toHaveClass("my-class"); }); + + it("renders secondaryAction when provided", () => { + render( + Create new} + secondaryAction={Read the docs} + />, + ); + expect( + screen.getByRole("link", { name: "Read the docs" }), + ).toBeInTheDocument(); + }); + + it("does not render secondaryAction container when not provided", () => { + render( + Create new} />, + ); + expect( + screen.queryByRole("link", { name: "Read the docs" }), + ).not.toBeInTheDocument(); + }); + + it("renders secondaryAction even when primary action is absent", () => { + render( + Read the docs} + />, + ); + expect( + screen.getByRole("link", { name: "Read the docs" }), + ).toBeInTheDocument(); + }); }); diff --git a/component/src/components/composed/empty-state.tsx b/component/src/components/composed/empty-state.tsx index 5e2d41cf..cd885176 100644 --- a/component/src/components/composed/empty-state.tsx +++ b/component/src/components/composed/empty-state.tsx @@ -6,6 +6,8 @@ export interface EmptyStateProps { title: string; description?: string; action?: React.ReactNode; + /** Optional secondary action (e.g. a "Read the docs" link) rendered below `action`. */ + secondaryAction?: React.ReactNode; className?: string; } @@ -14,18 +16,17 @@ function EmptyState({ title, description, action, + secondaryAction, className, }: EmptyStateProps) { return (
- {icon && ( -
{icon}
- )} + {icon &&
{icon}
}

{title}

{description && (

@@ -33,6 +34,11 @@ function EmptyState({

)} {action &&
{action}
} + {secondaryAction && ( +
+ {secondaryAction} +
+ )}
); }