Skip to content

Commit 8fbefea

Browse files
Merge pull request #83 from tetrascience/feat/empty-state
feat(pattern): SW-1739 Add EmptyState component
2 parents 2aa576e + 2c4e8ff commit 8fbefea

4 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { FlaskConical } from "lucide-react";
2+
import { expect, within } from "storybook/test";
3+
4+
5+
import { EmptyState } from "./EmptyState";
6+
7+
import type { Meta, StoryObj } from "@storybook/react-vite";
8+
9+
import { Button } from "@/components/ui/button";
10+
11+
const meta: Meta<typeof EmptyState> = {
12+
title: "Design Patterns/EmptyState",
13+
component: EmptyState,
14+
parameters: {
15+
layout: "centered",
16+
},
17+
tags: ["autodocs"],
18+
decorators: [
19+
(Story) => (
20+
<div className="w-96 rounded-lg border bg-card">
21+
<Story />
22+
</div>
23+
),
24+
],
25+
};
26+
27+
export default meta;
28+
type Story = StoryObj<typeof meta>;
29+
30+
export const Default: Story = {
31+
parameters: {
32+
// Auto-generated by sync-storybook-zephyr - do not add manually
33+
zephyr: { testCaseId: "SW-T4912" },
34+
},
35+
play: async ({ canvasElement }) => {
36+
const canvas = within(canvasElement);
37+
expect(canvas.getByText("No records yet")).toBeInTheDocument();
38+
expect(
39+
canvas.getByText(
40+
"Import a dataset or connect a data source to get started."
41+
)
42+
).toBeInTheDocument();
43+
},
44+
};
45+
46+
export const NoData: Story = {
47+
args: {
48+
variant: "no-data",
49+
action: <Button size="sm">Import data</Button>,
50+
},
51+
parameters: {
52+
// Auto-generated by sync-storybook-zephyr - do not add manually
53+
zephyr: { testCaseId: "SW-T4913" },
54+
},
55+
play: async ({ canvasElement }) => {
56+
const canvas = within(canvasElement);
57+
expect(canvas.getByText("No records yet")).toBeInTheDocument();
58+
expect(
59+
canvas.getByRole("button", { name: "Import data" })
60+
).toBeInTheDocument();
61+
},
62+
};
63+
64+
export const NoResults: Story = {
65+
args: {
66+
variant: "no-results",
67+
action: (
68+
<Button variant="ghost" size="sm">
69+
Clear filters
70+
</Button>
71+
),
72+
},
73+
parameters: {
74+
// Auto-generated by sync-storybook-zephyr - do not add manually
75+
zephyr: { testCaseId: "SW-T4914" },
76+
},
77+
play: async ({ canvasElement }) => {
78+
const canvas = within(canvasElement);
79+
expect(canvas.getByText("No results found")).toBeInTheDocument();
80+
expect(
81+
canvas.getByRole("button", { name: "Clear filters" })
82+
).toBeInTheDocument();
83+
},
84+
};
85+
86+
export const NoAccess: Story = {
87+
args: { variant: "no-access" },
88+
parameters: {
89+
// Auto-generated by sync-storybook-zephyr - do not add manually
90+
zephyr: { testCaseId: "SW-T4915" },
91+
},
92+
play: async ({ canvasElement }) => {
93+
const canvas = within(canvasElement);
94+
expect(canvas.getByText("Access restricted")).toBeInTheDocument();
95+
expect(
96+
canvas.getByText(
97+
"You don't have permission to view this resource. Contact your admin."
98+
)
99+
).toBeInTheDocument();
100+
},
101+
};
102+
103+
export const EmptyFolder: Story = {
104+
args: {
105+
variant: "empty-folder",
106+
action: (
107+
<Button size="sm" variant="outline">
108+
Upload files
109+
</Button>
110+
),
111+
},
112+
parameters: {
113+
// Auto-generated by sync-storybook-zephyr - do not add manually
114+
zephyr: { testCaseId: "SW-T4916" },
115+
},
116+
play: async ({ canvasElement }) => {
117+
const canvas = within(canvasElement);
118+
expect(canvas.getByText("This folder is empty")).toBeInTheDocument();
119+
expect(
120+
canvas.getByRole("button", { name: "Upload files" })
121+
).toBeInTheDocument();
122+
},
123+
};
124+
125+
export const ServerError: Story = {
126+
args: {
127+
variant: "server-error",
128+
action: (
129+
<Button size="sm" variant="outline">
130+
Retry
131+
</Button>
132+
),
133+
},
134+
parameters: {
135+
// Auto-generated by sync-storybook-zephyr - do not add manually
136+
zephyr: { testCaseId: "SW-T4917" },
137+
},
138+
play: async ({ canvasElement }) => {
139+
const canvas = within(canvasElement);
140+
expect(canvas.getByText("Something went wrong")).toBeInTheDocument();
141+
expect(canvas.getByRole("button", { name: "Retry" })).toBeInTheDocument();
142+
},
143+
};
144+
145+
export const CustomContent: Story = {
146+
args: {
147+
icon: FlaskConical,
148+
title: "No experiments run yet",
149+
description: "Configure your protocol and run your first experiment.",
150+
action: <Button size="sm">Create experiment</Button>,
151+
},
152+
parameters: {
153+
// Auto-generated by sync-storybook-zephyr - do not add manually
154+
zephyr: { testCaseId: "SW-T4918" },
155+
},
156+
play: async ({ canvasElement }) => {
157+
const canvas = within(canvasElement);
158+
expect(canvas.getByText("No experiments run yet")).toBeInTheDocument();
159+
expect(
160+
canvas.getByText(
161+
"Configure your protocol and run your first experiment."
162+
)
163+
).toBeInTheDocument();
164+
expect(
165+
canvas.getByRole("button", { name: "Create experiment" })
166+
).toBeInTheDocument();
167+
},
168+
};
169+
170+
export const OverrideTitle: Story = {
171+
name: "Variant with overridden title",
172+
args: {
173+
variant: "no-data",
174+
title: "No pipelines configured",
175+
description: "Create a pipeline to start processing your data.",
176+
},
177+
parameters: {
178+
// Auto-generated by sync-storybook-zephyr - do not add manually
179+
zephyr: { testCaseId: "SW-T4919" },
180+
},
181+
play: async ({ canvasElement }) => {
182+
const canvas = within(canvasElement);
183+
expect(canvas.getByText("No pipelines configured")).toBeInTheDocument();
184+
expect(
185+
canvas.getByText("Create a pipeline to start processing your data.")
186+
).toBeInTheDocument();
187+
expect(canvas.queryByText("No records yet")).not.toBeInTheDocument();
188+
},
189+
};
190+
191+
export const EmptyTitleAndDescription: Story = {
192+
name: "Empty strings hide title and description",
193+
args: {
194+
title: "",
195+
description: "",
196+
},
197+
parameters: {
198+
// Auto-generated by sync-storybook-zephyr - do not add manually
199+
zephyr: { testCaseId: "SW-T4920" },
200+
},
201+
play: async ({ canvasElement }) => {
202+
const canvas = within(canvasElement);
203+
expect(canvas.queryByText("No records yet")).not.toBeInTheDocument();
204+
const slot = canvasElement.querySelector('[data-slot="empty-state"]');
205+
expect(slot).not.toBeNull();
206+
expect(slot?.querySelector("p")).toBeNull();
207+
},
208+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
Database,
3+
FolderOpen,
4+
Lock,
5+
SearchX,
6+
ServerCrash,
7+
} from "lucide-react";
8+
import * as React from "react";
9+
10+
import type { LucideIcon } from "lucide-react";
11+
12+
import { cn } from "@/lib/utils";
13+
14+
export type EmptyStateVariant =
15+
| "no-data"
16+
| "no-results"
17+
| "no-access"
18+
| "empty-folder"
19+
| "server-error";
20+
21+
const VARIANT_DEFAULTS: Record<
22+
EmptyStateVariant,
23+
{ icon: LucideIcon; title: string; description: string }
24+
> = {
25+
"no-data": {
26+
icon: Database,
27+
title: "No records yet",
28+
description: "Import a dataset or connect a data source to get started.",
29+
},
30+
"no-results": {
31+
icon: SearchX,
32+
title: "No results found",
33+
description:
34+
"Nothing matches your current search. Try adjusting your filters or search terms.",
35+
},
36+
"no-access": {
37+
icon: Lock,
38+
title: "Access restricted",
39+
description:
40+
"You don't have permission to view this resource. Contact your admin.",
41+
},
42+
"empty-folder": {
43+
icon: FolderOpen,
44+
title: "This folder is empty",
45+
description:
46+
"Drag files here or use the upload button to add content.",
47+
},
48+
"server-error": {
49+
icon: ServerCrash,
50+
title: "Something went wrong",
51+
description:
52+
"An unexpected error occurred. Refresh the page or try again later.",
53+
},
54+
};
55+
56+
export interface EmptyStateProps extends React.ComponentProps<"div"> {
57+
variant?: EmptyStateVariant;
58+
title?: string;
59+
description?: string;
60+
icon?: LucideIcon | React.FC<React.SVGProps<SVGSVGElement>>;
61+
action?: React.ReactNode;
62+
}
63+
64+
function EmptyState({
65+
variant = "no-data",
66+
title,
67+
description,
68+
icon,
69+
action,
70+
className,
71+
...props
72+
}: EmptyStateProps) {
73+
const defaults = VARIANT_DEFAULTS[variant];
74+
const Icon = icon ?? defaults.icon;
75+
const resolvedTitle = title ?? defaults.title;
76+
const resolvedDescription = description ?? defaults.description;
77+
78+
return (
79+
<div
80+
data-slot="empty-state"
81+
className={cn(
82+
"flex flex-col items-center justify-center gap-4 px-6 py-14 text-center",
83+
className
84+
)}
85+
{...props}
86+
>
87+
{Icon && (
88+
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
89+
<Icon className="h-7 w-7 text-muted-foreground" />
90+
</div>
91+
)}
92+
{(Boolean(resolvedTitle) || Boolean(resolvedDescription)) && (
93+
<div className="max-w-xs space-y-1">
94+
{resolvedTitle && (
95+
<p className="text-sm font-semibold">{resolvedTitle}</p>
96+
)}
97+
{resolvedDescription && (
98+
<p className="text-sm text-muted-foreground">{resolvedDescription}</p>
99+
)}
100+
</div>
101+
)}
102+
{action}
103+
</div>
104+
);
105+
}
106+
107+
export { EmptyState };
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { EmptyState } from "./EmptyState";
2+
export type { EmptyStateProps, EmptyStateVariant } from "./EmptyState";

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from "@/components/composed/FormPatterns";
66
export * from "@/components/composed/StatCard";
77
export * from "@/components/composed/DataAppShell";
88
export * from "@/components/composed/Chat";
9+
export * from "@/components/composed/EmptyState";
910

1011
// Tetra Data Platform (TDP) Specific
1112
export * from "@/components/composed/TdpSearch";

0 commit comments

Comments
 (0)