@checkstack/ui 1.8.3 → 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 +83 -0
- package/package.json +6 -2
- package/scripts/generate-stdlib-types.ts +90 -0
- package/src/components/CodeEditor/CodeEditor.tsx +7 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +203 -117
- package/src/components/CodeEditor/generateTypeDefinitions.ts +19 -26
- package/src/components/CodeEditor/generated/stdlib-types.json +1 -0
- package/src/components/CodeEditor/index.ts +7 -0
- package/src/components/CodeEditor/monacoStdlib.ts +62 -0
- package/src/components/CodeEditor/monacoWorkers.ts +118 -0
- package/src/components/CodeEditor/scriptContext.test.ts +280 -0
- package/src/components/CodeEditor/scriptContext.ts +467 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.test.ts +95 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.ts +70 -0
- package/src/components/DynamicForm/DynamicForm.tsx +6 -0
- package/src/components/DynamicForm/FormField.tsx +15 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +111 -6
- package/src/components/DynamicForm/index.ts +2 -0
- package/src/components/DynamicForm/starterTemplateSelector.test.ts +96 -0
- package/src/components/DynamicForm/starterTemplateSelector.ts +32 -0
- package/src/components/DynamicForm/types.ts +34 -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/hooks/useInitOnceForKey.test.ts +127 -0
- package/src/hooks/useInitOnceForKey.ts +87 -0
- package/src/index.ts +6 -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/tsconfig.json +3 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { shouldInitForKey } from "./useInitOnceForKey";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression suite for the form-preservation bug in the healthcheck editor.
|
|
6
|
+
*
|
|
7
|
+
* Before this hook existed, the page had:
|
|
8
|
+
*
|
|
9
|
+
* useEffect(() => {
|
|
10
|
+
* if (existingConfig) setFormState({ … });
|
|
11
|
+
* }, [existingConfig]);
|
|
12
|
+
*
|
|
13
|
+
* That fires on every refetch — including the refetches triggered by the
|
|
14
|
+
* realtime `HEALTH_CHECK_RUN_COMPLETED` signal — so the user's in-progress
|
|
15
|
+
* edits got wiped every time a healthcheck run completed anywhere on the
|
|
16
|
+
* platform.
|
|
17
|
+
*
|
|
18
|
+
* The hook delegates its decision to the pure `shouldInitForKey` function
|
|
19
|
+
* tested below. The hook is a thin React wrapper around two refs and a
|
|
20
|
+
* `useEffect` — testing the decision function gives full coverage of the
|
|
21
|
+
* logic without needing a DOM.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
describe("shouldInitForKey", () => {
|
|
25
|
+
it("returns true the first time value+key are defined and no key has been initialised", () => {
|
|
26
|
+
expect(
|
|
27
|
+
shouldInitForKey({
|
|
28
|
+
value: { name: "A" },
|
|
29
|
+
key: "id-1",
|
|
30
|
+
initialisedKey: undefined,
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false when value is undefined (query still loading)", () => {
|
|
36
|
+
expect(
|
|
37
|
+
shouldInitForKey({
|
|
38
|
+
value: undefined,
|
|
39
|
+
key: "id-1",
|
|
40
|
+
initialisedKey: undefined,
|
|
41
|
+
}),
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false when value is null", () => {
|
|
46
|
+
expect(
|
|
47
|
+
shouldInitForKey({
|
|
48
|
+
value: null,
|
|
49
|
+
key: "id-1",
|
|
50
|
+
initialisedKey: undefined,
|
|
51
|
+
}),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns false when key is undefined (no discriminator yet)", () => {
|
|
56
|
+
expect(
|
|
57
|
+
shouldInitForKey({
|
|
58
|
+
value: { name: "A" },
|
|
59
|
+
key: undefined,
|
|
60
|
+
initialisedKey: undefined,
|
|
61
|
+
}),
|
|
62
|
+
).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns false when key is null (record-not-found path)", () => {
|
|
66
|
+
expect(
|
|
67
|
+
shouldInitForKey({
|
|
68
|
+
value: { name: "A" },
|
|
69
|
+
key: null,
|
|
70
|
+
initialisedKey: undefined,
|
|
71
|
+
}),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false on a background refetch (same key already initialised)", () => {
|
|
76
|
+
// THE regression case: react-query refetched the same record after an
|
|
77
|
+
// invalidation, handing us a new `value` object reference. The
|
|
78
|
+
// `initialisedKey` already matches the new key, so we must not re-fire.
|
|
79
|
+
expect(
|
|
80
|
+
shouldInitForKey({
|
|
81
|
+
value: { name: "A (refetched)" },
|
|
82
|
+
key: "id-1",
|
|
83
|
+
initialisedKey: "id-1",
|
|
84
|
+
}),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true when the key changes (user navigated to a different record)", () => {
|
|
89
|
+
expect(
|
|
90
|
+
shouldInitForKey({
|
|
91
|
+
value: { name: "B" },
|
|
92
|
+
key: "id-2",
|
|
93
|
+
initialisedKey: "id-1",
|
|
94
|
+
}),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns true when we're returning to a previously-seen key from a different one", () => {
|
|
99
|
+
// The hook only stores the LAST initialised key, so flipping between
|
|
100
|
+
// two records re-initialises each time. That's the correct behaviour:
|
|
101
|
+
// each visit shows fresh server data.
|
|
102
|
+
expect(
|
|
103
|
+
shouldInitForKey({
|
|
104
|
+
value: { name: "A" },
|
|
105
|
+
key: "id-1",
|
|
106
|
+
initialisedKey: "id-2",
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("treats numeric keys correctly (different number → re-init)", () => {
|
|
112
|
+
expect(
|
|
113
|
+
shouldInitForKey({ value: "any", key: 1, initialisedKey: 1 }),
|
|
114
|
+
).toBe(false);
|
|
115
|
+
expect(
|
|
116
|
+
shouldInitForKey({ value: "any", key: 2, initialisedKey: 1 }),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("treats string vs number keys as different (no implicit coercion)", () => {
|
|
121
|
+
// If a caller starts passing IDs as numbers after passing strings (or
|
|
122
|
+
// vice versa) we'd rather re-init than miss an update; strict !== handles it.
|
|
123
|
+
expect(
|
|
124
|
+
shouldInitForKey({ value: "any", key: "1", initialisedKey: 1 }),
|
|
125
|
+
).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure decision function powering {@link useInitOnceForKey}. Extracted so
|
|
5
|
+
* it can be unit-tested without a DOM (the hook itself wraps this with
|
|
6
|
+
* a `useRef` + `useEffect`).
|
|
7
|
+
*
|
|
8
|
+
* Returns `true` iff the caller should run their initialiser:
|
|
9
|
+
*
|
|
10
|
+
* - `value` is defined (the query has finished loading), AND
|
|
11
|
+
* - `key` is defined (we have a discriminator to track init-per-record),
|
|
12
|
+
* AND
|
|
13
|
+
* - we haven't yet initialised for this key (i.e. `initialisedKey !== key`).
|
|
14
|
+
*
|
|
15
|
+
* Background refetches of the same record produce a new `value` reference
|
|
16
|
+
* but the same `key`, so the function returns `false` for them — that's
|
|
17
|
+
* the whole point.
|
|
18
|
+
*/
|
|
19
|
+
export function shouldInitForKey({
|
|
20
|
+
value,
|
|
21
|
+
key,
|
|
22
|
+
initialisedKey,
|
|
23
|
+
}: {
|
|
24
|
+
value: unknown;
|
|
25
|
+
key: string | number | null | undefined;
|
|
26
|
+
initialisedKey: string | number | null | undefined;
|
|
27
|
+
}): boolean {
|
|
28
|
+
if (value === undefined || value === null) return false;
|
|
29
|
+
if (key === undefined || key === null) return false;
|
|
30
|
+
return initialisedKey !== key;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a one-shot initialiser exactly once per `key`, ignoring subsequent
|
|
35
|
+
* `value` changes that keep the same key.
|
|
36
|
+
*
|
|
37
|
+
* Built for forms that need to seed local state from a react-query result
|
|
38
|
+
* but **must not** reset that state when the query refetches in the
|
|
39
|
+
* background. The canonical use case is the healthcheck editor: a realtime
|
|
40
|
+
* `HEALTH_CHECK_RUN_COMPLETED` signal invalidates the configuration query
|
|
41
|
+
* on every run, which would otherwise wipe the user's in-progress edits
|
|
42
|
+
* via a naive `useEffect([data], () => setState(data))`.
|
|
43
|
+
*
|
|
44
|
+
* Behaviour:
|
|
45
|
+
* - Calls `onInit(value)` the first time `value` is defined for a given
|
|
46
|
+
* `key`.
|
|
47
|
+
* - **Does NOT** call it again if `value` changes but `key` stays the
|
|
48
|
+
* same. Background refetches keep the same key (= the same record's
|
|
49
|
+
* primary id) and therefore don't re-run the initialiser.
|
|
50
|
+
* - **Does** call it again when `key` changes — e.g. when the user
|
|
51
|
+
* navigates to a different record without unmounting the page.
|
|
52
|
+
* - Skips initialisation entirely while either `value` or `key` is
|
|
53
|
+
* `undefined`/`null`.
|
|
54
|
+
*
|
|
55
|
+
* `onInit` is read from a ref so callers can safely pass a fresh closure
|
|
56
|
+
* each render without re-firing the effect.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* useInitOnceForKey(existingConfig, existingConfig?.id, (config) => {
|
|
60
|
+
* setFormState({
|
|
61
|
+
* name: config.name,
|
|
62
|
+
* collectors: config.collectors ?? [],
|
|
63
|
+
* });
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
export function useInitOnceForKey<T>(
|
|
67
|
+
value: T | undefined | null,
|
|
68
|
+
key: string | number | null | undefined,
|
|
69
|
+
onInit: (value: T) => void,
|
|
70
|
+
): void {
|
|
71
|
+
const initialisedKeyRef = useRef<string | number | null | undefined>(undefined);
|
|
72
|
+
const onInitRef = useRef(onInit);
|
|
73
|
+
|
|
74
|
+
// Keep the latest callback in a ref so a fresh closure each render doesn't
|
|
75
|
+
// re-trigger the effect; only `value` and `key` should drive it.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
onInitRef.current = onInit;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!shouldInitForKey({ value, key, initialisedKey: initialisedKeyRef.current })) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
initialisedKeyRef.current = key;
|
|
85
|
+
onInitRef.current(value as T);
|
|
86
|
+
}, [value, key]);
|
|
87
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,3 +63,9 @@ export * from "./components/MetricTile";
|
|
|
63
63
|
export * from "./components/Sheet";
|
|
64
64
|
export * from "./components/Popover";
|
|
65
65
|
export * from "./hooks/useIsMobile";
|
|
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 /> };
|