@checkstack/ui 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/package.json +1 -1
- package/src/components/ListEmptyState.tsx +51 -0
- package/src/components/QueryErrorState.tsx +64 -0
- package/src/components/ResponsiveTable.tsx +92 -0
- package/src/components/Skeleton.tsx +39 -0
- package/src/index.ts +5 -0
- package/src/utils/toastTemplates.test.ts +82 -0
- package/src/utils/toastTemplates.ts +47 -0
- package/stories/ListEmptyState.stories.tsx +48 -0
- package/stories/QueryErrorState.stories.tsx +40 -0
- package/stories/ResponsiveTable.stories.tsx +93 -0
- package/stories/Skeleton.stories.tsx +53 -0
- package/stories/toastTemplates.stories.tsx +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @checkstack/ui
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f23f3c9: Add five additive shared UI primitives for list / query state surfaces:
|
|
8
|
+
|
|
9
|
+
- `ListEmptyState` - thin wrapper around `EmptyState` with the
|
|
10
|
+
canonical `"No {resource} yet"` headline and an `Inbox` default icon.
|
|
11
|
+
- `QueryErrorState` - inline error UI for failed queries; renders an
|
|
12
|
+
`error`-variant `Alert` with `extractErrorMessage` + a Retry button.
|
|
13
|
+
- `Skeleton` - pulsing placeholder block that drops its animation when
|
|
14
|
+
`usePerformance().isLowPower` is true.
|
|
15
|
+
- `ResponsiveTable` + `MobileCardList` - dual-layout pair for tabular
|
|
16
|
+
data that swaps to a stacked card layout below the `sm` breakpoint
|
|
17
|
+
(pure CSS, no JS media-query gating).
|
|
18
|
+
- `toastSuccess` / `toastError` - canonical verb-phrase and
|
|
19
|
+
`{action}: {message}` (truncated at 100 chars) toast helpers.
|
|
20
|
+
|
|
21
|
+
Each primitive ships with Storybook stories and unit tests. No
|
|
22
|
+
existing component or behaviour is changed - Phases 5-7 of the v1
|
|
23
|
+
polishing plan will retrofit consumer pages onto these primitives in
|
|
24
|
+
follow-up PRs. Phase 7 will use the existing `usePerformance()` hook
|
|
25
|
+
directly for low-power gating rather than introducing a separate
|
|
26
|
+
className-composition helper.
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- Updated dependencies [f23f3c9]
|
|
31
|
+
- Updated dependencies [f23f3c9]
|
|
32
|
+
- Updated dependencies [f23f3c9]
|
|
33
|
+
- @checkstack/common@0.11.0
|
|
34
|
+
- @checkstack/frontend-api@0.5.2
|
|
35
|
+
|
|
3
36
|
## 1.9.0
|
|
4
37
|
|
|
5
38
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Inbox } from "lucide-react";
|
|
3
|
+
import { EmptyState } from "./EmptyState";
|
|
4
|
+
|
|
5
|
+
interface ListEmptyStateProps {
|
|
6
|
+
/**
|
|
7
|
+
* The name of the resource type the list would display (e.g. `"checks"`,
|
|
8
|
+
* `"incidents"`). Drives the default `"No {resource} yet"` headline.
|
|
9
|
+
*/
|
|
10
|
+
resource: string;
|
|
11
|
+
/**
|
|
12
|
+
* Optional supplemental description rendered beneath the headline.
|
|
13
|
+
*/
|
|
14
|
+
description?: React.ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Optional action area, typically a primary CTA button such as
|
|
17
|
+
* "Create your first check".
|
|
18
|
+
*/
|
|
19
|
+
actions?: React.ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Optional icon override. Defaults to the lucide `Inbox` glyph so callers
|
|
22
|
+
* don't have to pick one for every list.
|
|
23
|
+
*/
|
|
24
|
+
icon?: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* ListEmptyState - the canonical empty state for a list-shaped resource.
|
|
29
|
+
*
|
|
30
|
+
* Thin wrapper around {@link EmptyState} that supplies a consistent
|
|
31
|
+
* "No {resource} yet" headline and a sensible default icon. Use this on
|
|
32
|
+
* any page that renders a list and may have zero items so the UX stays
|
|
33
|
+
* uniform across plugins.
|
|
34
|
+
*/
|
|
35
|
+
export const ListEmptyState: React.FC<ListEmptyStateProps> = ({
|
|
36
|
+
resource,
|
|
37
|
+
description,
|
|
38
|
+
actions,
|
|
39
|
+
icon,
|
|
40
|
+
}) => {
|
|
41
|
+
const resolvedIcon = icon ?? <Inbox className="h-10 w-10" />;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<EmptyState
|
|
45
|
+
title={`No ${resource} yet`}
|
|
46
|
+
description={description}
|
|
47
|
+
icon={resolvedIcon}
|
|
48
|
+
actions={actions}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AlertCircle } from "lucide-react";
|
|
3
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
4
|
+
import {
|
|
5
|
+
Alert,
|
|
6
|
+
AlertContent,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
AlertIcon,
|
|
9
|
+
AlertTitle,
|
|
10
|
+
} from "./Alert";
|
|
11
|
+
import { Button } from "./Button";
|
|
12
|
+
|
|
13
|
+
interface QueryErrorStateProps {
|
|
14
|
+
/**
|
|
15
|
+
* The error captured from a failed query (e.g. TanStack Query's `error`).
|
|
16
|
+
* Funnelled through {@link extractErrorMessage} so callers don't have to
|
|
17
|
+
* narrow the type at every call site.
|
|
18
|
+
*/
|
|
19
|
+
error: unknown;
|
|
20
|
+
/**
|
|
21
|
+
* Invoked when the user clicks the "Retry" button. Wire this to the
|
|
22
|
+
* underlying `refetch()` of the failing query.
|
|
23
|
+
*/
|
|
24
|
+
onRetry: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Optional resource name to personalise the headline, e.g.
|
|
27
|
+
* `resource="checks"` -> "Could not load checks".
|
|
28
|
+
*/
|
|
29
|
+
resource?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* QueryErrorState - canonical inline error UI for failed list / detail
|
|
34
|
+
* queries. Renders an `error`-variant {@link Alert} with the extracted
|
|
35
|
+
* error message and a Retry button.
|
|
36
|
+
*/
|
|
37
|
+
export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
|
|
38
|
+
error,
|
|
39
|
+
onRetry,
|
|
40
|
+
resource,
|
|
41
|
+
}) => {
|
|
42
|
+
const message = extractErrorMessage(error);
|
|
43
|
+
const title = resource ? `Could not load ${resource}` : "Something went wrong";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Alert variant="error">
|
|
47
|
+
<AlertIcon>
|
|
48
|
+
<AlertCircle className="h-4 w-4" />
|
|
49
|
+
</AlertIcon>
|
|
50
|
+
<AlertContent>
|
|
51
|
+
<AlertTitle>{title}</AlertTitle>
|
|
52
|
+
<AlertDescription>{message}</AlertDescription>
|
|
53
|
+
</AlertContent>
|
|
54
|
+
<Button
|
|
55
|
+
variant="outline"
|
|
56
|
+
size="sm"
|
|
57
|
+
onClick={onRetry}
|
|
58
|
+
className="shrink-0"
|
|
59
|
+
>
|
|
60
|
+
Retry
|
|
61
|
+
</Button>
|
|
62
|
+
</Alert>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResponsiveTable - dual-layout primitive for tabular data that must
|
|
3
|
+
* degrade gracefully on narrow viewports.
|
|
4
|
+
*
|
|
5
|
+
* # API decision
|
|
6
|
+
*
|
|
7
|
+
* The original plan considered a context-driven `priority` prop on a
|
|
8
|
+
* special `ResponsiveTableHead` so cells could declare which columns
|
|
9
|
+
* disappear on mobile. Implementing that without `any` requires either
|
|
10
|
+
* (a) cloning every `TableCell` child to inject a context-derived
|
|
11
|
+
* `data-priority` attribute, or (b) maintaining a parallel index of
|
|
12
|
+
* `TableHead` children to wire their priorities into the cells by
|
|
13
|
+
* position. Both shapes leak the matching responsibility into the
|
|
14
|
+
* primitive and produce gnarly typings around the `Table*` re-exports.
|
|
15
|
+
*
|
|
16
|
+
* Instead this file ships the simpler, fully type-safe fallback:
|
|
17
|
+
*
|
|
18
|
+
* - `<ResponsiveTable>` - a wrapper that renders its children inside
|
|
19
|
+
* the standard {@link Table} layout on `sm` viewports and up, and
|
|
20
|
+
* hides them on smaller screens.
|
|
21
|
+
* - `<MobileCardList>` - a sibling wrapper consumers render alongside
|
|
22
|
+
* the table. It is only visible below `sm`, so callers compose the
|
|
23
|
+
* two side-by-side and decide per-row what the mobile presentation
|
|
24
|
+
* looks like (typically a stacked card with high-priority fields).
|
|
25
|
+
*
|
|
26
|
+
* The two wrappers use Tailwind's `hidden sm:block` / `sm:hidden`
|
|
27
|
+
* utilities, so they swap purely in CSS - no JS media-query gating, no
|
|
28
|
+
* SSR/CSR mismatch risk, and consumers keep full control over which
|
|
29
|
+
* fields surface on mobile.
|
|
30
|
+
*
|
|
31
|
+
* Re-export the standard `Table*` primitives from `@checkstack/ui` for
|
|
32
|
+
* the desktop branch; do NOT use `<table>` markup inside
|
|
33
|
+
* `<MobileCardList>`.
|
|
34
|
+
*/
|
|
35
|
+
import React from "react";
|
|
36
|
+
import { cn } from "../utils";
|
|
37
|
+
|
|
38
|
+
interface ResponsiveTableProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
39
|
+
/**
|
|
40
|
+
* The desktop tabular layout. Compose with the existing `Table`,
|
|
41
|
+
* `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`
|
|
42
|
+
* primitives.
|
|
43
|
+
*/
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Desktop branch of the dual-layout pattern. Hidden below the `sm`
|
|
49
|
+
* breakpoint; render a {@link MobileCardList} alongside it to cover
|
|
50
|
+
* narrow viewports.
|
|
51
|
+
*/
|
|
52
|
+
export const ResponsiveTable = React.forwardRef<
|
|
53
|
+
HTMLDivElement,
|
|
54
|
+
ResponsiveTableProps
|
|
55
|
+
>(({ children, className, ...props }, ref) => (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
className={cn("hidden sm:block", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
ResponsiveTable.displayName = "ResponsiveTable";
|
|
66
|
+
|
|
67
|
+
interface MobileCardListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
68
|
+
/**
|
|
69
|
+
* The stacked, card-shaped layout for narrow viewports. One item per
|
|
70
|
+
* row; consumers decide which fields are surfaced.
|
|
71
|
+
*/
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mobile branch of the dual-layout pattern. Visible only below the `sm`
|
|
77
|
+
* breakpoint. Pairs with {@link ResponsiveTable}.
|
|
78
|
+
*/
|
|
79
|
+
export const MobileCardList = React.forwardRef<
|
|
80
|
+
HTMLDivElement,
|
|
81
|
+
MobileCardListProps
|
|
82
|
+
>(({ children, className, ...props }, ref) => (
|
|
83
|
+
<div
|
|
84
|
+
ref={ref}
|
|
85
|
+
className={cn("flex flex-col gap-2 sm:hidden", className)}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
));
|
|
91
|
+
|
|
92
|
+
MobileCardList.displayName = "MobileCardList";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
import { usePerformance } from "./PerformanceProvider";
|
|
4
|
+
|
|
5
|
+
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/**
|
|
7
|
+
* Override the default sizing / shape. The component already renders a
|
|
8
|
+
* muted background; pass dimensions via classes like `h-4 w-32 rounded`.
|
|
9
|
+
*/
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Skeleton - a pulsing placeholder block for loading states.
|
|
15
|
+
*
|
|
16
|
+
* Honours {@link usePerformance}: when `isLowPower` is true the pulse
|
|
17
|
+
* animation is dropped, leaving a static `bg-muted` block so low-power
|
|
18
|
+
* devices aren't forced through an infinite animation loop.
|
|
19
|
+
*/
|
|
20
|
+
export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
|
21
|
+
({ className, ...props }, ref) => {
|
|
22
|
+
const { isLowPower } = usePerformance();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={ref}
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
className={cn(
|
|
29
|
+
"rounded-md bg-muted",
|
|
30
|
+
!isLowPower && "animate-pulse",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
Skeleton.displayName = "Skeleton";
|
package/src/index.ts
CHANGED
|
@@ -64,3 +64,8 @@ export * from "./components/Sheet";
|
|
|
64
64
|
export * from "./components/Popover";
|
|
65
65
|
export * from "./hooks/useIsMobile";
|
|
66
66
|
export * from "./hooks/useInitOnceForKey";
|
|
67
|
+
export * from "./components/ListEmptyState";
|
|
68
|
+
export * from "./components/QueryErrorState";
|
|
69
|
+
export * from "./components/Skeleton";
|
|
70
|
+
export * from "./components/ResponsiveTable";
|
|
71
|
+
export * from "./utils/toastTemplates";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
toastError,
|
|
4
|
+
toastSuccess,
|
|
5
|
+
type ToastApi,
|
|
6
|
+
} from "./toastTemplates";
|
|
7
|
+
|
|
8
|
+
const makeToastMock = (): ToastApi => ({
|
|
9
|
+
show: mock(),
|
|
10
|
+
success: mock(),
|
|
11
|
+
error: mock(),
|
|
12
|
+
warning: mock(),
|
|
13
|
+
info: mock(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("toastSuccess", () => {
|
|
17
|
+
let toast: ToastApi;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
toast = makeToastMock();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("passes the action through to toast.success", () => {
|
|
24
|
+
toastSuccess(toast, "Check created");
|
|
25
|
+
|
|
26
|
+
expect(toast.success).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(toast.success).toHaveBeenCalledWith("Check created");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("toastError", () => {
|
|
32
|
+
let toast: ToastApi;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
toast = makeToastMock();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("prefixes the action and includes the extracted error message", () => {
|
|
39
|
+
toastError(toast, "Failed to create check", new Error("Backend unreachable"));
|
|
40
|
+
|
|
41
|
+
expect(toast.error).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
43
|
+
"Failed to create check: Backend unreachable",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses the fallback message when error is not an Error or string", () => {
|
|
48
|
+
toastError(toast, "Failed to delete check", null);
|
|
49
|
+
|
|
50
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
51
|
+
"Failed to delete check: An error occurred",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts a string error and includes it verbatim", () => {
|
|
56
|
+
toastError(toast, "Save failed", "validation: bad payload");
|
|
57
|
+
|
|
58
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
59
|
+
"Save failed: validation: bad payload",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("truncates the message to 100 characters with an ellipsis", () => {
|
|
64
|
+
const longMessage = "x".repeat(200);
|
|
65
|
+
toastError(toast, "Failed", new Error(longMessage));
|
|
66
|
+
|
|
67
|
+
const mockedError = toast.error as ReturnType<typeof mock>;
|
|
68
|
+
const call = mockedError.mock.calls[0];
|
|
69
|
+
const arg = call[0];
|
|
70
|
+
expect(typeof arg).toBe("string");
|
|
71
|
+
if (typeof arg !== "string") throw new Error("expected string toast message");
|
|
72
|
+
expect(arg.length).toBe(100);
|
|
73
|
+
expect(arg.endsWith("…")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("leaves messages at or under 100 characters intact", () => {
|
|
77
|
+
const shortMessage = "boom";
|
|
78
|
+
toastError(toast, "Failed", new Error(shortMessage));
|
|
79
|
+
|
|
80
|
+
expect(toast.error).toHaveBeenCalledWith("Failed: boom");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
2
|
+
import type { useToast } from "../components/ToastProvider";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shape of the toast API exposed by {@link useToast}. Re-derived from
|
|
6
|
+
* the hook's return type so the helpers stay in lock-step with any
|
|
7
|
+
* future additions to the toast surface.
|
|
8
|
+
*/
|
|
9
|
+
export type ToastApi = ReturnType<typeof useToast>;
|
|
10
|
+
|
|
11
|
+
const MAX_TOAST_MESSAGE_LENGTH = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Truncate `message` to `MAX_TOAST_MESSAGE_LENGTH` characters, replacing
|
|
15
|
+
* the tail with an ellipsis. Used to keep error toasts readable when
|
|
16
|
+
* upstream payloads carry verbose stack traces.
|
|
17
|
+
*/
|
|
18
|
+
function truncate(message: string): string {
|
|
19
|
+
if (message.length <= MAX_TOAST_MESSAGE_LENGTH) return message;
|
|
20
|
+
return `${message.slice(0, MAX_TOAST_MESSAGE_LENGTH - 1)}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* toastSuccess - shorthand for `toast.success(action)`.
|
|
25
|
+
*
|
|
26
|
+
* Use a verb-phrase for `action` so the toast reads naturally, e.g.
|
|
27
|
+
* `"Check created"`, `"Configuration saved"`.
|
|
28
|
+
*/
|
|
29
|
+
export function toastSuccess(toast: ToastApi, action: string): void {
|
|
30
|
+
toast.success(action);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* toastError - render an error toast in the canonical
|
|
35
|
+
* `"{action}: {message}"` format, truncated to 100 characters.
|
|
36
|
+
*
|
|
37
|
+
* Funnels the unknown `error` through {@link extractErrorMessage} so
|
|
38
|
+
* callers don't have to narrow at every call site.
|
|
39
|
+
*/
|
|
40
|
+
export function toastError(
|
|
41
|
+
toast: ToastApi,
|
|
42
|
+
action: string,
|
|
43
|
+
error: unknown,
|
|
44
|
+
): void {
|
|
45
|
+
const message = extractErrorMessage(error);
|
|
46
|
+
toast.error(truncate(`${action}: ${message}`));
|
|
47
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Plus, ServerCog } from "lucide-react";
|
|
3
|
+
import { Button } from "../src/components/Button";
|
|
4
|
+
import { ListEmptyState } from "../src/components/ListEmptyState";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ListEmptyState> = {
|
|
7
|
+
title: "Components/Feedback/ListEmptyState",
|
|
8
|
+
component: ListEmptyState,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
resource: "checks",
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof ListEmptyState>;
|
|
17
|
+
|
|
18
|
+
export const Default: Story = {};
|
|
19
|
+
|
|
20
|
+
export const WithDescription: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
resource: "incidents",
|
|
23
|
+
description:
|
|
24
|
+
"Incidents are reported by operators and tracked through resolution updates.",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const WithAction: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
resource: "checks",
|
|
31
|
+
description:
|
|
32
|
+
"Create a health check to start monitoring an endpoint.",
|
|
33
|
+
actions: (
|
|
34
|
+
<Button>
|
|
35
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
36
|
+
Create your first check
|
|
37
|
+
</Button>
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const CustomIcon: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
resource: "satellites",
|
|
45
|
+
description: "Satellites probe remote networks Checkstack can't reach directly.",
|
|
46
|
+
icon: <ServerCog className="h-10 w-10" />,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { QueryErrorState } from "../src/components/QueryErrorState";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof QueryErrorState> = {
|
|
5
|
+
title: "Components/Feedback/QueryErrorState",
|
|
6
|
+
component: QueryErrorState,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof QueryErrorState>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
error: new Error("Failed to reach the backend at /api/health-checks"),
|
|
16
|
+
onRetry: () => {
|
|
17
|
+
// Storybook noop - wires up to a real refetch() in the app
|
|
18
|
+
console.log("retry clicked");
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const WithResource: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
error: new Error("Network request failed (504)"),
|
|
26
|
+
resource: "checks",
|
|
27
|
+
onRetry: () => {
|
|
28
|
+
console.log("retry clicked");
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const NonErrorPayload: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
error: null,
|
|
36
|
+
onRetry: () => {
|
|
37
|
+
console.log("retry clicked");
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Badge } from "../src/components/Badge";
|
|
3
|
+
import {
|
|
4
|
+
MobileCardList,
|
|
5
|
+
ResponsiveTable,
|
|
6
|
+
} from "../src/components/ResponsiveTable";
|
|
7
|
+
import {
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableHeader,
|
|
13
|
+
TableRow,
|
|
14
|
+
} from "../src/components/Table";
|
|
15
|
+
|
|
16
|
+
const meta: Meta = {
|
|
17
|
+
title: "Components/Data/ResponsiveTable",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj;
|
|
22
|
+
|
|
23
|
+
interface Row {
|
|
24
|
+
name: string;
|
|
25
|
+
strategy: string;
|
|
26
|
+
status: "healthy" | "degraded" | "down";
|
|
27
|
+
latency: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rows: Row[] = [
|
|
31
|
+
{ name: "api.checkstack.io", strategy: "HTTP probe", status: "healthy", latency: "42 ms" },
|
|
32
|
+
{ name: "db-primary", strategy: "TCP probe", status: "degraded", latency: "340 ms" },
|
|
33
|
+
{ name: "cache", strategy: "HTTP probe", status: "down", latency: "—" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const variantFor = (status: Row["status"]) => {
|
|
37
|
+
if (status === "healthy") return "success" as const;
|
|
38
|
+
if (status === "degraded") return "warning" as const;
|
|
39
|
+
return "destructive" as const;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const DualLayout: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<div className="w-full">
|
|
45
|
+
<ResponsiveTable>
|
|
46
|
+
<Table>
|
|
47
|
+
<TableHeader>
|
|
48
|
+
<TableRow>
|
|
49
|
+
<TableHead>Name</TableHead>
|
|
50
|
+
<TableHead>Strategy</TableHead>
|
|
51
|
+
<TableHead>Status</TableHead>
|
|
52
|
+
<TableHead className="text-right">Latency</TableHead>
|
|
53
|
+
</TableRow>
|
|
54
|
+
</TableHeader>
|
|
55
|
+
<TableBody>
|
|
56
|
+
{rows.map((row) => (
|
|
57
|
+
<TableRow key={row.name}>
|
|
58
|
+
<TableCell>{row.name}</TableCell>
|
|
59
|
+
<TableCell>{row.strategy}</TableCell>
|
|
60
|
+
<TableCell>
|
|
61
|
+
<Badge variant={variantFor(row.status)}>{row.status}</Badge>
|
|
62
|
+
</TableCell>
|
|
63
|
+
<TableCell className="text-right">{row.latency}</TableCell>
|
|
64
|
+
</TableRow>
|
|
65
|
+
))}
|
|
66
|
+
</TableBody>
|
|
67
|
+
</Table>
|
|
68
|
+
</ResponsiveTable>
|
|
69
|
+
|
|
70
|
+
<MobileCardList>
|
|
71
|
+
{rows.map((row) => (
|
|
72
|
+
<div
|
|
73
|
+
key={row.name}
|
|
74
|
+
className="rounded-md border border-border bg-card p-3 flex flex-col gap-1"
|
|
75
|
+
>
|
|
76
|
+
<div className="flex items-center justify-between">
|
|
77
|
+
<span className="font-medium">{row.name}</span>
|
|
78
|
+
<Badge variant={variantFor(row.status)}>{row.status}</Badge>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="text-xs text-muted-foreground">
|
|
81
|
+
{row.strategy} · {row.latency}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</MobileCardList>
|
|
86
|
+
|
|
87
|
+
<p className="text-xs text-muted-foreground mt-4">
|
|
88
|
+
Resize the viewport: the table appears on <code>sm</code> and up, the
|
|
89
|
+
stacked card list shows on smaller screens.
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Skeleton } from "../src/components/Skeleton";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Skeleton> = {
|
|
5
|
+
title: "Components/Feedback/Skeleton",
|
|
6
|
+
component: Skeleton,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof Skeleton>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
className: "h-4 w-48",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const TextBlock: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<div className="space-y-2 max-w-md">
|
|
22
|
+
<Skeleton className="h-4 w-3/4" />
|
|
23
|
+
<Skeleton className="h-4 w-full" />
|
|
24
|
+
<Skeleton className="h-4 w-5/6" />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ListRow: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<div className="flex items-center gap-3 max-w-md">
|
|
32
|
+
<Skeleton className="h-10 w-10 rounded-full" />
|
|
33
|
+
<div className="flex-1 space-y-2">
|
|
34
|
+
<Skeleton className="h-4 w-1/2" />
|
|
35
|
+
<Skeleton className="h-3 w-1/3" />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const CardGrid: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
|
44
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
45
|
+
<div key={index} className="rounded-md border border-border p-4 space-y-3">
|
|
46
|
+
<Skeleton className="h-5 w-2/3" />
|
|
47
|
+
<Skeleton className="h-4 w-full" />
|
|
48
|
+
<Skeleton className="h-4 w-5/6" />
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Button } from "../src/components/Button";
|
|
3
|
+
import { useToast } from "../src/components/ToastProvider";
|
|
4
|
+
import { toastError, toastSuccess } from "../src/utils/toastTemplates";
|
|
5
|
+
|
|
6
|
+
const meta: Meta = {
|
|
7
|
+
title: "Foundations/toastTemplates",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj;
|
|
12
|
+
|
|
13
|
+
const Demo = () => {
|
|
14
|
+
const toast = useToast();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="space-y-3 max-w-md">
|
|
18
|
+
<p className="text-sm text-muted-foreground">
|
|
19
|
+
Fire the canonical success / error toasts. The error helper
|
|
20
|
+
prefixes the action and truncates long messages to 100 characters.
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<div className="flex flex-wrap gap-2">
|
|
24
|
+
<Button onClick={() => toastSuccess(toast, "Check created")}>
|
|
25
|
+
Fire success
|
|
26
|
+
</Button>
|
|
27
|
+
|
|
28
|
+
<Button
|
|
29
|
+
variant="destructive"
|
|
30
|
+
onClick={() =>
|
|
31
|
+
toastError(
|
|
32
|
+
toast,
|
|
33
|
+
"Failed to create check",
|
|
34
|
+
new Error("Backend unreachable"),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
Fire error
|
|
39
|
+
</Button>
|
|
40
|
+
|
|
41
|
+
<Button
|
|
42
|
+
variant="outline"
|
|
43
|
+
onClick={() =>
|
|
44
|
+
toastError(
|
|
45
|
+
toast,
|
|
46
|
+
"Failed to import config",
|
|
47
|
+
new Error(
|
|
48
|
+
"Validation failed: " + "x".repeat(300),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
Fire truncated error
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Helpers: Story = { render: () => <Demo /> };
|