@checkstack/ui 0.0.2
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 +153 -0
- package/bunfig.toml +2 -0
- package/package.json +40 -0
- package/src/components/Accordion.tsx +55 -0
- package/src/components/Alert.tsx +90 -0
- package/src/components/AmbientBackground.tsx +105 -0
- package/src/components/AnimatedCounter.tsx +54 -0
- package/src/components/BackLink.tsx +56 -0
- package/src/components/Badge.tsx +38 -0
- package/src/components/Button.tsx +55 -0
- package/src/components/Card.tsx +56 -0
- package/src/components/Checkbox.tsx +46 -0
- package/src/components/ColorPicker.tsx +69 -0
- package/src/components/CommandPalette.tsx +74 -0
- package/src/components/ConfirmationModal.tsx +134 -0
- package/src/components/DateRangeFilter.tsx +128 -0
- package/src/components/DateTimePicker.tsx +65 -0
- package/src/components/Dialog.tsx +134 -0
- package/src/components/DropdownMenu.tsx +96 -0
- package/src/components/DynamicForm/DynamicForm.tsx +126 -0
- package/src/components/DynamicForm/DynamicOptionsField.tsx +220 -0
- package/src/components/DynamicForm/FormField.tsx +690 -0
- package/src/components/DynamicForm/JsonField.tsx +98 -0
- package/src/components/DynamicForm/index.ts +11 -0
- package/src/components/DynamicForm/types.ts +95 -0
- package/src/components/DynamicForm/utils.ts +39 -0
- package/src/components/DynamicIcon.tsx +45 -0
- package/src/components/EditableText.tsx +141 -0
- package/src/components/EmptyState.tsx +32 -0
- package/src/components/HealthBadge.tsx +57 -0
- package/src/components/InfoBanner.tsx +97 -0
- package/src/components/Input.tsx +20 -0
- package/src/components/Label.tsx +17 -0
- package/src/components/LoadingSpinner.tsx +29 -0
- package/src/components/Markdown.tsx +206 -0
- package/src/components/NavItem.tsx +112 -0
- package/src/components/Page.tsx +58 -0
- package/src/components/PageLayout.tsx +83 -0
- package/src/components/PaginatedList.tsx +135 -0
- package/src/components/Pagination.tsx +195 -0
- package/src/components/PermissionDenied.tsx +31 -0
- package/src/components/PermissionGate.tsx +97 -0
- package/src/components/PluginConfigForm.tsx +91 -0
- package/src/components/SectionHeader.tsx +30 -0
- package/src/components/Select.tsx +157 -0
- package/src/components/StatusCard.tsx +78 -0
- package/src/components/StatusUpdateTimeline.tsx +222 -0
- package/src/components/StrategyConfigCard.tsx +333 -0
- package/src/components/SubscribeButton.tsx +96 -0
- package/src/components/Table.tsx +119 -0
- package/src/components/Tabs.tsx +141 -0
- package/src/components/TemplateEditor.test.ts +156 -0
- package/src/components/TemplateEditor.tsx +435 -0
- package/src/components/TerminalFeed.tsx +152 -0
- package/src/components/Textarea.tsx +22 -0
- package/src/components/ThemeProvider.tsx +76 -0
- package/src/components/Toast.tsx +118 -0
- package/src/components/ToastProvider.tsx +126 -0
- package/src/components/Toggle.tsx +47 -0
- package/src/components/Tooltip.tsx +20 -0
- package/src/components/UserMenu.tsx +79 -0
- package/src/hooks/usePagination.e2e.ts +275 -0
- package/src/hooks/usePagination.ts +231 -0
- package/src/index.ts +53 -0
- package/src/themes.css +204 -0
- package/src/utils/strip-markdown.ts +44 -0
- package/src/utils.ts +8 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "../utils";
|
|
5
|
+
|
|
6
|
+
const Dialog = DialogPrimitive.Root;
|
|
7
|
+
|
|
8
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
9
|
+
|
|
10
|
+
const DialogPortal = DialogPrimitive.Portal;
|
|
11
|
+
|
|
12
|
+
const DialogClose = DialogPrimitive.Close;
|
|
13
|
+
|
|
14
|
+
const DialogOverlay = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
17
|
+
>(({ className, ...props }, ref) => (
|
|
18
|
+
<DialogPrimitive.Overlay
|
|
19
|
+
ref={ref}
|
|
20
|
+
className={cn(
|
|
21
|
+
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
22
|
+
className
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
));
|
|
27
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
28
|
+
|
|
29
|
+
const dialogContentVariants = cva(
|
|
30
|
+
"fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg max-h-[85vh] overflow-y-auto",
|
|
31
|
+
{
|
|
32
|
+
variants: {
|
|
33
|
+
size: {
|
|
34
|
+
sm: "max-w-sm",
|
|
35
|
+
default: "max-w-lg",
|
|
36
|
+
lg: "max-w-2xl",
|
|
37
|
+
xl: "max-w-4xl",
|
|
38
|
+
full: "max-w-[90vw]",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
defaultVariants: {
|
|
42
|
+
size: "default",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
interface DialogContentProps
|
|
48
|
+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
|
49
|
+
VariantProps<typeof dialogContentVariants> {}
|
|
50
|
+
|
|
51
|
+
const DialogContent = React.forwardRef<
|
|
52
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
53
|
+
DialogContentProps
|
|
54
|
+
>(({ className, children, size, ...props }, ref) => (
|
|
55
|
+
<DialogPortal>
|
|
56
|
+
<DialogOverlay />
|
|
57
|
+
<DialogPrimitive.Content
|
|
58
|
+
ref={ref}
|
|
59
|
+
className={cn(dialogContentVariants({ size }), className)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</DialogPrimitive.Content>
|
|
64
|
+
</DialogPortal>
|
|
65
|
+
));
|
|
66
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
67
|
+
|
|
68
|
+
const DialogHeader = ({
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
DialogHeader.displayName = "DialogHeader";
|
|
81
|
+
|
|
82
|
+
const DialogFooter = ({
|
|
83
|
+
className,
|
|
84
|
+
...props
|
|
85
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
89
|
+
className
|
|
90
|
+
)}
|
|
91
|
+
{...props}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
DialogFooter.displayName = "DialogFooter";
|
|
95
|
+
|
|
96
|
+
const DialogTitle = React.forwardRef<
|
|
97
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
98
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
99
|
+
>(({ className, ...props }, ref) => (
|
|
100
|
+
<DialogPrimitive.Title
|
|
101
|
+
ref={ref}
|
|
102
|
+
className={cn(
|
|
103
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
));
|
|
109
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
110
|
+
|
|
111
|
+
const DialogDescription = React.forwardRef<
|
|
112
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
113
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
114
|
+
>(({ className, ...props }, ref) => (
|
|
115
|
+
<DialogPrimitive.Description
|
|
116
|
+
ref={ref}
|
|
117
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
));
|
|
121
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
Dialog,
|
|
125
|
+
DialogPortal,
|
|
126
|
+
DialogOverlay,
|
|
127
|
+
DialogClose,
|
|
128
|
+
DialogTrigger,
|
|
129
|
+
DialogContent,
|
|
130
|
+
DialogHeader,
|
|
131
|
+
DialogFooter,
|
|
132
|
+
DialogTitle,
|
|
133
|
+
DialogDescription,
|
|
134
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
|
|
4
|
+
export const DropdownMenu: React.FC<{ children: React.ReactNode }> = ({
|
|
5
|
+
children,
|
|
6
|
+
}) => {
|
|
7
|
+
return <div className="relative inline-block text-left">{children}</div>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const DropdownMenuTrigger: React.FC<{
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
asChild?: boolean;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}> = ({ children, onClick }) => {
|
|
15
|
+
return (
|
|
16
|
+
<div onClick={onClick} className="cursor-pointer">
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DropdownMenuContent: React.FC<{
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
isOpen: boolean;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
}> = ({ children, isOpen, onClose, className }) => {
|
|
28
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
32
|
+
if (
|
|
33
|
+
contentRef.current &&
|
|
34
|
+
!contentRef.current.contains(event.target as Node)
|
|
35
|
+
) {
|
|
36
|
+
onClose();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (isOpen) {
|
|
41
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
42
|
+
}
|
|
43
|
+
return () => {
|
|
44
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
45
|
+
};
|
|
46
|
+
}, [isOpen, onClose]);
|
|
47
|
+
|
|
48
|
+
if (!isOpen) return <React.Fragment />;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={contentRef}
|
|
53
|
+
className={cn(
|
|
54
|
+
"absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-popover shadow-lg ring-1 ring-border focus:outline-none z-[100] animate-in fade-in zoom-in-95 duration-100",
|
|
55
|
+
className
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<div className="py-1" role="none">
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const DropdownMenuItem: React.FC<{
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
onClick?: () => void;
|
|
68
|
+
className?: string;
|
|
69
|
+
icon?: React.ReactNode;
|
|
70
|
+
}> = ({ children, onClick, className, icon }) => {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
className={cn(
|
|
75
|
+
"flex items-center w-full px-4 py-2 text-sm text-popover-foreground hover:bg-accent hover:text-accent-foreground transition-colors",
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
role="menuitem"
|
|
79
|
+
>
|
|
80
|
+
{icon && <span className="mr-3 text-muted-foreground">{icon}</span>}
|
|
81
|
+
{children}
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const DropdownMenuSeparator: React.FC = () => (
|
|
87
|
+
<div className="my-1 h-px bg-border" />
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export const DropdownMenuLabel: React.FC<{ children: React.ReactNode }> = ({
|
|
91
|
+
children,
|
|
92
|
+
}) => (
|
|
93
|
+
<div className="px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
94
|
+
{children}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { EmptyState } from "../../index";
|
|
4
|
+
|
|
5
|
+
import type { DynamicFormProps, JsonSchemaProperty } from "./types";
|
|
6
|
+
import { extractDefaults } from "./utils";
|
|
7
|
+
import { FormField } from "./FormField";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a value is considered "empty" for validation purposes.
|
|
11
|
+
*/
|
|
12
|
+
function isValueEmpty(val: unknown, propSchema: JsonSchemaProperty): boolean {
|
|
13
|
+
if (val === undefined || val === null) return true;
|
|
14
|
+
if (typeof val === "string" && val.trim() === "") return true;
|
|
15
|
+
// For arrays, check if empty
|
|
16
|
+
if (Array.isArray(val) && val.length === 0) return true;
|
|
17
|
+
// For objects (nested schemas), recursively check required fields
|
|
18
|
+
if (propSchema.type === "object" && propSchema.properties) {
|
|
19
|
+
const objVal = val as Record<string, unknown>;
|
|
20
|
+
const requiredKeys = propSchema.required ?? [];
|
|
21
|
+
for (const key of requiredKeys) {
|
|
22
|
+
const nestedPropSchema = propSchema.properties[key];
|
|
23
|
+
if (nestedPropSchema && isValueEmpty(objVal[key], nestedPropSchema)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* DynamicForm generates a form from a JSON Schema.
|
|
33
|
+
* Supports all standard JSON Schema types plus custom extensions for
|
|
34
|
+
* secrets, colors, templates, dynamic options, and discriminated unions.
|
|
35
|
+
*/
|
|
36
|
+
export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
37
|
+
schema,
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
onValidChange,
|
|
41
|
+
optionsResolvers,
|
|
42
|
+
templateProperties,
|
|
43
|
+
}) => {
|
|
44
|
+
// Track previous validity to avoid redundant callbacks
|
|
45
|
+
const prevValidRef = React.useRef<boolean | undefined>(undefined);
|
|
46
|
+
|
|
47
|
+
// Initialize form with default values from schema
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (!schema || !schema.properties) return;
|
|
50
|
+
|
|
51
|
+
const defaults = extractDefaults(schema);
|
|
52
|
+
const merged = { ...defaults, ...value };
|
|
53
|
+
|
|
54
|
+
// Only update if there are new defaults to apply
|
|
55
|
+
if (JSON.stringify(merged) !== JSON.stringify(value)) {
|
|
56
|
+
onChange(merged);
|
|
57
|
+
}
|
|
58
|
+
}, [schema]); // Only run when schema changes
|
|
59
|
+
|
|
60
|
+
// Compute validity and report changes
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
if (!onValidChange || !schema || !schema.properties) return;
|
|
63
|
+
|
|
64
|
+
// Check all required fields (including hidden ones like connectionId)
|
|
65
|
+
const requiredKeys = schema.required ?? [];
|
|
66
|
+
let isValid = true;
|
|
67
|
+
|
|
68
|
+
for (const key of requiredKeys) {
|
|
69
|
+
const propSchema = schema.properties[key];
|
|
70
|
+
if (!propSchema) continue;
|
|
71
|
+
|
|
72
|
+
// Skip hidden fields - they are auto-populated
|
|
73
|
+
if (propSchema["x-hidden"]) continue;
|
|
74
|
+
|
|
75
|
+
if (isValueEmpty(value[key], propSchema)) {
|
|
76
|
+
isValid = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only call onValidChange if validity actually changed
|
|
82
|
+
if (prevValidRef.current !== isValid) {
|
|
83
|
+
prevValidRef.current = isValid;
|
|
84
|
+
onValidChange(isValid);
|
|
85
|
+
}
|
|
86
|
+
}, [schema, value, onValidChange]);
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
!schema ||
|
|
90
|
+
!schema.properties ||
|
|
91
|
+
Object.keys(schema.properties).length === 0
|
|
92
|
+
) {
|
|
93
|
+
return (
|
|
94
|
+
<EmptyState
|
|
95
|
+
title="No Configuration Required"
|
|
96
|
+
description="This component doesn't require any configuration."
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="space-y-6">
|
|
103
|
+
{Object.entries(schema.properties)
|
|
104
|
+
.filter(([, propSchema]) => !propSchema["x-hidden"])
|
|
105
|
+
.map(([key, propSchema]) => {
|
|
106
|
+
const isRequired = schema.required?.includes(key);
|
|
107
|
+
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<FormField
|
|
111
|
+
key={key}
|
|
112
|
+
id={key}
|
|
113
|
+
label={label}
|
|
114
|
+
propSchema={propSchema}
|
|
115
|
+
value={value[key]}
|
|
116
|
+
isRequired={isRequired}
|
|
117
|
+
formValues={value}
|
|
118
|
+
optionsResolvers={optionsResolvers}
|
|
119
|
+
templateProperties={templateProperties}
|
|
120
|
+
onChange={(val) => onChange({ ...value, [key]: val })}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Loader2, ChevronDown } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Input,
|
|
6
|
+
Label,
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from "../../index";
|
|
13
|
+
|
|
14
|
+
import type { DynamicOptionsFieldProps, ResolverOption } from "./types";
|
|
15
|
+
import { getCleanDescription } from "./utils";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Field component for dynamically resolved options.
|
|
19
|
+
* Fetches options using the specified resolver and renders a Select.
|
|
20
|
+
* When searchable is true, shows a searchable dropdown with filter inside.
|
|
21
|
+
*/
|
|
22
|
+
export const DynamicOptionsField: React.FC<DynamicOptionsFieldProps> = ({
|
|
23
|
+
id,
|
|
24
|
+
label,
|
|
25
|
+
description,
|
|
26
|
+
value,
|
|
27
|
+
isRequired,
|
|
28
|
+
resolverName,
|
|
29
|
+
dependsOn,
|
|
30
|
+
searchable,
|
|
31
|
+
formValues,
|
|
32
|
+
optionsResolvers,
|
|
33
|
+
onChange,
|
|
34
|
+
}) => {
|
|
35
|
+
const [options, setOptions] = React.useState<ResolverOption[]>([]);
|
|
36
|
+
const [loading, setLoading] = React.useState(true);
|
|
37
|
+
const [error, setError] = React.useState<string | undefined>();
|
|
38
|
+
const [searchQuery, setSearchQuery] = React.useState("");
|
|
39
|
+
const [open, setOpen] = React.useState(false);
|
|
40
|
+
|
|
41
|
+
// Use ref to store formValues to avoid re-renders when unrelated fields change
|
|
42
|
+
const formValuesRef = React.useRef(formValues);
|
|
43
|
+
formValuesRef.current = formValues;
|
|
44
|
+
|
|
45
|
+
// Build dependency values string for useEffect dependency tracking
|
|
46
|
+
// Only includes the specific fields this resolver depends on
|
|
47
|
+
const dependencyValues = React.useMemo(() => {
|
|
48
|
+
if (!dependsOn || dependsOn.length === 0) return "";
|
|
49
|
+
return dependsOn.map((key) => JSON.stringify(formValues[key])).join("|");
|
|
50
|
+
}, [dependsOn, formValues]);
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
const resolver = optionsResolvers[resolverName];
|
|
54
|
+
if (!resolver) {
|
|
55
|
+
setError(`Resolver "${resolverName}" not found`);
|
|
56
|
+
setLoading(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cancelled = false;
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setError(undefined);
|
|
63
|
+
|
|
64
|
+
// Use ref to get current form values without adding to dependencies
|
|
65
|
+
resolver(formValuesRef.current)
|
|
66
|
+
.then((result) => {
|
|
67
|
+
if (!cancelled) {
|
|
68
|
+
setOptions(result);
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch((error_) => {
|
|
73
|
+
if (!cancelled) {
|
|
74
|
+
setError(
|
|
75
|
+
error_ instanceof Error ? error_.message : "Failed to load options"
|
|
76
|
+
);
|
|
77
|
+
setLoading(false);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
// Only re-fetch when resolver changes or explicit dependencies change
|
|
85
|
+
}, [resolverName, optionsResolvers, dependencyValues]);
|
|
86
|
+
|
|
87
|
+
// Filter options based on search query
|
|
88
|
+
const filteredOptions = React.useMemo(() => {
|
|
89
|
+
if (!searchable || !searchQuery.trim()) return options;
|
|
90
|
+
const query = searchQuery.toLowerCase();
|
|
91
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(query));
|
|
92
|
+
}, [options, searchQuery, searchable]);
|
|
93
|
+
|
|
94
|
+
// Get the selected option label
|
|
95
|
+
const selectedLabel = React.useMemo(() => {
|
|
96
|
+
const selected = options.find((opt) => opt.value === value);
|
|
97
|
+
return selected?.label;
|
|
98
|
+
}, [options, value]);
|
|
99
|
+
|
|
100
|
+
const cleanDesc = getCleanDescription(description);
|
|
101
|
+
|
|
102
|
+
// Render searchable dropdown with search inside
|
|
103
|
+
if (searchable && !loading && !error && options.length > 0) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="space-y-2">
|
|
106
|
+
<div>
|
|
107
|
+
<Label htmlFor={id}>
|
|
108
|
+
{label} {isRequired && "*"}
|
|
109
|
+
</Label>
|
|
110
|
+
{cleanDesc && (
|
|
111
|
+
<p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="relative">
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
onClick={() => setOpen(!open)}
|
|
118
|
+
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-left ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
119
|
+
>
|
|
120
|
+
<span className={selectedLabel ? "" : "text-muted-foreground"}>
|
|
121
|
+
{selectedLabel || `Select ${label}`}
|
|
122
|
+
</span>
|
|
123
|
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
124
|
+
</button>
|
|
125
|
+
{open && (
|
|
126
|
+
<div className="absolute z-[100] mt-1 w-full rounded-md border border-border bg-popover shadow-lg">
|
|
127
|
+
<div className="p-2 border-b border-border">
|
|
128
|
+
<Input
|
|
129
|
+
type="text"
|
|
130
|
+
placeholder="Search..."
|
|
131
|
+
value={searchQuery}
|
|
132
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
133
|
+
className="h-8"
|
|
134
|
+
autoFocus
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="max-h-60 overflow-y-auto p-1">
|
|
138
|
+
{filteredOptions.length === 0 ? (
|
|
139
|
+
<div className="py-2 px-3 text-sm text-muted-foreground text-center">
|
|
140
|
+
No matching options
|
|
141
|
+
</div>
|
|
142
|
+
) : (
|
|
143
|
+
filteredOptions.map((opt) => (
|
|
144
|
+
<button
|
|
145
|
+
key={opt.value}
|
|
146
|
+
type="button"
|
|
147
|
+
onClick={() => {
|
|
148
|
+
onChange(opt.value);
|
|
149
|
+
setOpen(false);
|
|
150
|
+
setSearchQuery("");
|
|
151
|
+
}}
|
|
152
|
+
className={`w-full text-left px-3 py-1.5 text-sm rounded-sm hover:bg-accent hover:text-accent-foreground ${
|
|
153
|
+
opt.value === value
|
|
154
|
+
? "bg-accent text-accent-foreground"
|
|
155
|
+
: ""
|
|
156
|
+
}`}
|
|
157
|
+
>
|
|
158
|
+
{opt.label}
|
|
159
|
+
</button>
|
|
160
|
+
))
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Regular dropdown
|
|
171
|
+
return (
|
|
172
|
+
<div className="space-y-2">
|
|
173
|
+
<div>
|
|
174
|
+
<Label htmlFor={id}>
|
|
175
|
+
{label} {isRequired && "*"}
|
|
176
|
+
</Label>
|
|
177
|
+
{cleanDesc && (
|
|
178
|
+
<p className="text-sm text-muted-foreground mt-0.5">{cleanDesc}</p>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
<div className="relative">
|
|
182
|
+
{loading ? (
|
|
183
|
+
<div className="flex items-center gap-2 h-10 px-3 border rounded-md bg-muted/50">
|
|
184
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
185
|
+
<span className="text-sm text-muted-foreground">
|
|
186
|
+
Loading options...
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
) : error ? (
|
|
190
|
+
<div className="flex items-center h-10 px-3 border border-destructive rounded-md bg-destructive/10">
|
|
191
|
+
<span className="text-sm text-destructive">{error}</span>
|
|
192
|
+
</div>
|
|
193
|
+
) : (
|
|
194
|
+
<Select
|
|
195
|
+
value={(value as string) || ""}
|
|
196
|
+
onValueChange={(val) => onChange(val)}
|
|
197
|
+
disabled={options.length === 0}
|
|
198
|
+
>
|
|
199
|
+
<SelectTrigger id={id}>
|
|
200
|
+
<SelectValue
|
|
201
|
+
placeholder={
|
|
202
|
+
options.length === 0
|
|
203
|
+
? "No options available"
|
|
204
|
+
: `Select ${label}`
|
|
205
|
+
}
|
|
206
|
+
/>
|
|
207
|
+
</SelectTrigger>
|
|
208
|
+
<SelectContent>
|
|
209
|
+
{options.map((opt) => (
|
|
210
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
211
|
+
{opt.label}
|
|
212
|
+
</SelectItem>
|
|
213
|
+
))}
|
|
214
|
+
</SelectContent>
|
|
215
|
+
</Select>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
};
|