first commit
This commit is contained in:
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal file
14
ui/components/prompts/fragments/playgroundPanel.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ScrollArea } from "@/components/ui/scrollArea";
|
||||
import { MessagesView } from "../components/messagesView/rootMessageView";
|
||||
import { NewMessageInputView } from "../components/newMessageInputView";
|
||||
|
||||
export function PlaygroundPanel() {
|
||||
return (
|
||||
<div className="custom-scrollbar relative flex h-full flex-col overscroll-none">
|
||||
<ScrollArea className="flex-1 scroll-mb-12 overflow-y-auto" viewportClassName="no-table">
|
||||
<MessagesView />
|
||||
</ScrollArea>
|
||||
<NewMessageInputView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal file
232
ui/components/prompts/fragments/settingsPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { ComboboxSelect } from "@/components/ui/combobox";
|
||||
import ModelParameters from "@/components/ui/custom/modelParameters";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { useGetVirtualKeysQuery } from "@/lib/store";
|
||||
import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi";
|
||||
import { ModelProviderName } from "@/lib/types/config";
|
||||
import { ModelParams } from "@/lib/types/prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PromptDeploymentsAccordionItem } from "@enterprise/components/prompt-deployments/promptDeploymentsAccordionItem";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ApiKeySelectorView } from "../components/apiKeySelectorView";
|
||||
import { VariablesTableView } from "../components/variablesTableView";
|
||||
import { usePromptContext } from "../context";
|
||||
|
||||
export function SettingsPanel() {
|
||||
const {
|
||||
provider,
|
||||
setProvider,
|
||||
model,
|
||||
setModel: onModelChange,
|
||||
modelParams,
|
||||
setModelParams: onModelParamsChange,
|
||||
apiKeyId,
|
||||
setApiKeyId,
|
||||
variables,
|
||||
setVariables,
|
||||
selectedPromptId,
|
||||
} = usePromptContext();
|
||||
|
||||
const onProviderChange = useCallback(
|
||||
(p: string) => {
|
||||
setProvider(p);
|
||||
setApiKeyId("__auto__");
|
||||
onModelChange("");
|
||||
onModelParamsChange({} as ModelParams);
|
||||
},
|
||||
[setProvider, setApiKeyId, onModelChange, onModelParamsChange],
|
||||
);
|
||||
|
||||
const onApiKeyIdChange = useCallback(
|
||||
(id: string) => {
|
||||
setApiKeyId(id);
|
||||
},
|
||||
[setApiKeyId],
|
||||
);
|
||||
// Dynamic providers
|
||||
const { data: providers, isLoading: isLoadingProviders } = useGetProvidersQuery();
|
||||
const { data: virtualKeysData } = useGetVirtualKeysQuery();
|
||||
// Keys for the API Key selector (from /api/keys endpoint, provider-filtered)
|
||||
const { data: allKeys, isSuccess: hasLoadedAllKeys } = useGetAllKeysQuery();
|
||||
|
||||
const isInitialLoading = isLoadingProviders;
|
||||
|
||||
const configuredProviders = useMemo(() => {
|
||||
const activeVirtualKeys = virtualKeysData?.virtual_keys?.filter((vk) => vk.is_active) ?? [];
|
||||
if (!hasLoadedAllKeys) {
|
||||
return providers ?? [];
|
||||
}
|
||||
const keyedProviders = new Set((allKeys ?? []).map((k) => k.provider));
|
||||
return (providers ?? []).filter((p) => {
|
||||
if (keyedProviders.has(p.name)) return true;
|
||||
// Include providers that have active virtual keys (wildcard or explicitly targeting this provider)
|
||||
return activeVirtualKeys.some(
|
||||
(vk) => !vk.provider_configs || vk.provider_configs.length === 0 || vk.provider_configs.some((pc) => pc.provider === p.name),
|
||||
);
|
||||
});
|
||||
}, [providers, virtualKeysData, allKeys, hasLoadedAllKeys]);
|
||||
|
||||
// Ensure current provider always has a label-resolved option (even before providers query loads)
|
||||
const providerOptions = useMemo(() => {
|
||||
const opts = configuredProviders.map((p) => ({ label: getProviderLabel(p.name), value: p.name }));
|
||||
if (provider && !opts.find((o) => o.value === provider)) {
|
||||
opts.unshift({ label: getProviderLabel(provider), value: provider as ModelProviderName });
|
||||
}
|
||||
return opts;
|
||||
}, [configuredProviders, provider]);
|
||||
|
||||
const providerKeys = useMemo(() => (allKeys ?? []).filter((k) => k.provider === provider), [allKeys, provider]);
|
||||
|
||||
// Virtual keys filtered by selected provider
|
||||
const providerVirtualKeys = useMemo(() => {
|
||||
const vks = virtualKeysData?.virtual_keys ?? [];
|
||||
return vks.filter((vk) => {
|
||||
if (!vk.is_active) return false;
|
||||
// No provider configs means all providers are allowed (wildcard)
|
||||
if (!vk.provider_configs || vk.provider_configs.length === 0) return true;
|
||||
// Check if selected provider is in the configured providers
|
||||
return vk.provider_configs.some((pc) => pc.provider === provider);
|
||||
});
|
||||
}, [virtualKeysData, provider]);
|
||||
|
||||
// Separate keys/vks to pass to model fetch for filtering.
|
||||
const filterKeys = useMemo(() => {
|
||||
const isProviderKey = providerKeys.some((k) => k.key_id === apiKeyId);
|
||||
if (isProviderKey) return [apiKeyId];
|
||||
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
|
||||
if (isVirtualKey) return undefined;
|
||||
// Auto: pass all provider key IDs
|
||||
return providerKeys.map((k) => k.key_id);
|
||||
}, [apiKeyId, providerKeys, providerVirtualKeys]);
|
||||
|
||||
const filterVks = useMemo(() => {
|
||||
const isVirtualKey = providerVirtualKeys.some((vk) => vk.id === apiKeyId);
|
||||
if (isVirtualKey) return [apiKeyId];
|
||||
return undefined;
|
||||
}, [apiKeyId, providerVirtualKeys]);
|
||||
|
||||
const handleModelParamsChange = useCallback(
|
||||
(params: Record<string, any>) => {
|
||||
onModelParamsChange(params as ModelParams);
|
||||
},
|
||||
[onModelParamsChange],
|
||||
);
|
||||
|
||||
const hasModel = Boolean(model);
|
||||
|
||||
type SettingsSection = "parameters" | "deployments";
|
||||
const [openSection, setOpenSection] = useState<SettingsSection | undefined>("parameters");
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-9 w-full rounded-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-9 w-full rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pt-2 pb-4">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={openSection ?? ""}
|
||||
onValueChange={(v) => {
|
||||
if (v === "parameters" || v === "deployments") {
|
||||
setOpenSection(v);
|
||||
} else {
|
||||
setOpenSection(undefined);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<AccordionItem
|
||||
value="parameters"
|
||||
className={cn("flex min-h-0 flex-col border-b-0", openSection === "parameters" ? "flex-1" : "shrink-0 overflow-hidden")}
|
||||
>
|
||||
<AccordionTrigger
|
||||
data-testid="prompts-configuration-trigger"
|
||||
className="text-muted-foreground shrink-0 py-3 pr-1 text-xs font-medium uppercase hover:no-underline"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-left font-semibold">Configuration</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
containerClassName="data-[state=open]:flex data-[state=open]:min-h-0 data-[state=open]:flex-1 data-[state=open]:flex-col"
|
||||
className="min-h-0 flex-1 overflow-y-auto pt-0 pb-2"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-2" data-testid="settings-provider">
|
||||
<Label className="text-muted-foreground text-xs font-medium uppercase">Provider</Label>
|
||||
<ComboboxSelect
|
||||
options={providerOptions}
|
||||
value={provider}
|
||||
onValueChange={(v) => v && onProviderChange(v)}
|
||||
placeholder="Select provider"
|
||||
hideClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2" data-testid="settings-model">
|
||||
<Label className="text-muted-foreground text-xs font-medium uppercase">Model</Label>
|
||||
<ModelMultiselect
|
||||
provider={provider}
|
||||
keys={filterKeys && filterKeys.length > 0 ? filterKeys : undefined}
|
||||
vks={filterVks}
|
||||
value={model}
|
||||
onChange={(v) => onModelChange(v)}
|
||||
isSingleSelect
|
||||
placeholder={!provider ? "Select a provider first" : "Select model"}
|
||||
disabled={!provider}
|
||||
unfiltered={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(providerKeys.length > 0 || providerVirtualKeys.length > 0) && !!provider && (
|
||||
<ApiKeySelectorView
|
||||
providerKeys={providerKeys}
|
||||
virtualKeys={providerVirtualKeys}
|
||||
value={apiKeyId}
|
||||
onValueChange={(v) => onApiKeyIdChange(v ?? "__auto__")}
|
||||
disabled={!provider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(variables).length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<VariablesTableView variables={variables} onChange={setVariables} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasModel && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-4">
|
||||
<ModelParameters model={model} config={modelParams} onChange={handleModelParamsChange} hideFields={["promptTools"]} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{selectedPromptId && <PromptDeploymentsAccordionItem activeSection={openSection} />}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
584
ui/components/prompts/fragments/sidebar.tsx
Normal file
584
ui/components/prompts/fragments/sidebar.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdownMenu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scrollArea";
|
||||
import { Folder, Prompt } from "@/lib/types/prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DragDropProvider, useDraggable, useDroppable } from "@dnd-kit/react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
PlusIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { usePromptContext } from "../context";
|
||||
|
||||
/**
|
||||
* Renders the prompt-manager sidebar including search, folder hierarchy, root prompts, and drag-and-drop reorganization.
|
||||
*
|
||||
* The sidebar supports creating, renaming, and deleting folders and prompts (when permitted), selecting prompts, auto-expanding the folder that contains the selected prompt, filtering by search query, and dragging prompts between folders or to the root. Visual drag-over feedback and permission gating for create/update/delete actions are applied.
|
||||
*
|
||||
* @returns The sidebar React element containing the search input, folder list, root prompt drop zone, and drag-and-drop provider.
|
||||
*/
|
||||
export function PromptSidebar() {
|
||||
const {
|
||||
folders,
|
||||
prompts,
|
||||
selectedPromptId,
|
||||
handleSelectPrompt: onSelectPrompt,
|
||||
setFolderSheet,
|
||||
setDeleteFolderDialog,
|
||||
setPromptSheet,
|
||||
setDeletePromptDialog,
|
||||
handleMovePrompt: onMovePrompt,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
} = usePromptContext();
|
||||
|
||||
const onCreateFolder = useCallback(() => setFolderSheet({ open: true }), [setFolderSheet]);
|
||||
const onEditFolder = useCallback((folder: Folder) => setFolderSheet({ open: true, folder }), [setFolderSheet]);
|
||||
const onDeleteFolder = useCallback((folder: Folder) => setDeleteFolderDialog({ open: true, folder }), [setDeleteFolderDialog]);
|
||||
const onCreatePrompt = useCallback((folderId?: string) => setPromptSheet({ open: true, folderId }), [setPromptSheet]);
|
||||
const onEditPrompt = useCallback((prompt: Prompt) => setPromptSheet({ open: true, prompt }), [setPromptSheet]);
|
||||
const onDeletePrompt = useCallback((prompt: Prompt) => setDeletePromptDialog({ open: true, prompt }), [setDeletePromptDialog]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-expand the folder containing the selected prompt
|
||||
useEffect(() => {
|
||||
if (!selectedPromptId) return;
|
||||
const prompt = prompts.find((p) => p.id === selectedPromptId);
|
||||
if (prompt?.folder_id) {
|
||||
setExpandedFolders((prev) => {
|
||||
if (prev.has(prompt.folder_id!)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(prompt.folder_id!);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedPromptId, prompts]);
|
||||
|
||||
const toggleFolder = useCallback((folderId: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId);
|
||||
} else {
|
||||
next.add(folderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Group prompts by folder, root prompts have no folder_id
|
||||
const { promptsByFolder, rootPrompts } = useMemo(() => {
|
||||
const map = new Map<string, Prompt[]>();
|
||||
const root: Prompt[] = [];
|
||||
for (const prompt of prompts) {
|
||||
if (!prompt.folder_id) {
|
||||
root.push(prompt);
|
||||
} else {
|
||||
const list = map.get(prompt.folder_id) || [];
|
||||
list.push(prompt);
|
||||
map.set(prompt.folder_id, list);
|
||||
}
|
||||
}
|
||||
return { promptsByFolder: map, rootPrompts: root };
|
||||
}, [prompts]);
|
||||
|
||||
// Filter folders and prompts based on search
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return { folders, promptsByFolder, rootPrompts };
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchedFolderIds = new Set<string>();
|
||||
const filteredPromptsByFolder = new Map<string, Prompt[]>();
|
||||
const filteredRootPrompts: Prompt[] = [];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
if (prompt.name.toLowerCase().includes(query)) {
|
||||
if (!prompt.folder_id) {
|
||||
filteredRootPrompts.push(prompt);
|
||||
} else {
|
||||
matchedFolderIds.add(prompt.folder_id);
|
||||
const list = filteredPromptsByFolder.get(prompt.folder_id) || [];
|
||||
list.push(prompt);
|
||||
filteredPromptsByFolder.set(prompt.folder_id, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredFolders = folders.filter((folder) => folder.name.toLowerCase().includes(query) || matchedFolderIds.has(folder.id));
|
||||
|
||||
return {
|
||||
folders: filteredFolders,
|
||||
promptsByFolder: filteredPromptsByFolder,
|
||||
rootPrompts: filteredRootPrompts,
|
||||
};
|
||||
}, [folders, prompts, promptsByFolder, rootPrompts, searchQuery]);
|
||||
|
||||
// Prompt lookup for drag events
|
||||
const promptMap = useMemo(() => {
|
||||
const map = new Map<string, Prompt>();
|
||||
for (const p of prompts) map.set(p.id, p);
|
||||
return map;
|
||||
}, [prompts]);
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
onDragOver={(event) => {
|
||||
if (!canUpdate) return;
|
||||
const targetId = event.operation.target?.id as string | undefined;
|
||||
setDragOverTarget(targetId ?? null);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
setDragOverTarget(null);
|
||||
if (!canUpdate) return;
|
||||
if (event.canceled || !onMovePrompt) return;
|
||||
|
||||
const sourceId = event.operation.source?.id as string | undefined;
|
||||
const targetId = event.operation.target?.id as string | undefined;
|
||||
if (!sourceId || !targetId) return;
|
||||
|
||||
const promptId = sourceId.startsWith("prompt-") ? sourceId.slice(7) : null;
|
||||
if (!promptId) return;
|
||||
|
||||
const prompt = promptMap.get(promptId);
|
||||
if (!prompt) return;
|
||||
|
||||
let targetFolderId: string | null = null;
|
||||
if (targetId === "root-drop-zone") {
|
||||
targetFolderId = null;
|
||||
} else if (targetId.startsWith("folder-")) {
|
||||
targetFolderId = targetId.slice(7);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if ((prompt.folder_id ?? null) === targetFolderId) return;
|
||||
onMovePrompt(promptId, targetFolderId);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-2 border-b p-3">
|
||||
<div className="relative grow">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search prompts..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
data-testid="sidebar-search"
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 shrink-0 bg-transparent"
|
||||
data-testid="sidebar-create-menu"
|
||||
aria-label="Create prompt or folder"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
data-testid="sidebar-create-prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreatePrompt();
|
||||
}}
|
||||
>
|
||||
New Prompt
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="sidebar-create-folder"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateFolder();
|
||||
}}
|
||||
>
|
||||
New Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="grow overflow-y-auto" viewportClassName="no-table viewport-table-height-full">
|
||||
<div className="flex flex-col p-2 px-3">
|
||||
{filteredData.folders.length === 0 && filteredData.rootPrompts.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">{searchQuery ? "No results found" : "No prompts yet"}</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredData.folders.map((folder) => (
|
||||
<DroppableFolder
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
prompts={filteredData.promptsByFolder.get(folder.id) || promptsByFolder.get(folder.id) || []}
|
||||
isExpanded={expandedFolders.has(folder.id) || !!searchQuery}
|
||||
isDragOver={dragOverTarget === `folder-${folder.id}`}
|
||||
selectedPromptId={selectedPromptId}
|
||||
onToggle={() => toggleFolder(folder.id)}
|
||||
onSelectPrompt={onSelectPrompt}
|
||||
onEdit={() => onEditFolder(folder)}
|
||||
onDelete={() => onDeleteFolder(folder)}
|
||||
onCreatePrompt={() => onCreatePrompt(folder.id)}
|
||||
onEditPrompt={onEditPrompt}
|
||||
onDeletePrompt={onDeletePrompt}
|
||||
canCreate={canCreate}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
<RootDropZone
|
||||
isDragOver={dragOverTarget === "root-drop-zone"}
|
||||
rootPrompts={filteredData.rootPrompts}
|
||||
selectedPromptId={selectedPromptId}
|
||||
onSelectPrompt={onSelectPrompt}
|
||||
onEditPrompt={onEditPrompt}
|
||||
onDeletePrompt={onDeletePrompt}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface RootDropZoneProps {
|
||||
isDragOver: boolean;
|
||||
rootPrompts: Prompt[];
|
||||
selectedPromptId?: string | null;
|
||||
onSelectPrompt: (promptId: string) => void;
|
||||
onEditPrompt: (prompt: Prompt) => void;
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the droppable root area that lists and hosts draggable root-level prompts.
|
||||
*
|
||||
* @param isDragOver - Whether a draggable item is currently over the root drop zone (applies drag-over styling).
|
||||
* @param rootPrompts - Array of prompts that belong at the root (no folder).
|
||||
* @param selectedPromptId - ID of the currently selected prompt, used to mark its item as selected.
|
||||
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected.
|
||||
* @param onEditPrompt - Callback invoked with a prompt when the prompt's edit action is triggered.
|
||||
* @param onDeletePrompt - Callback invoked with a prompt when the prompt's delete action is triggered.
|
||||
* @param canUpdate - Whether prompts are movable/editable (enables dragging).
|
||||
* @param canDelete - Whether prompts may be deleted (controls delete action visibility).
|
||||
* @returns The JSX element for the root drop zone containing draggable prompt items.
|
||||
*/
|
||||
function RootDropZone({
|
||||
isDragOver,
|
||||
rootPrompts,
|
||||
selectedPromptId,
|
||||
onSelectPrompt,
|
||||
onEditPrompt,
|
||||
onDeletePrompt,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: RootDropZoneProps) {
|
||||
const { ref } = useDroppable({ id: "root-drop-zone" });
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("min-h-[8px] grow rounded-sm transition-colors", isDragOver && "bg-primary/10 ring-primary/30 ring-1")}>
|
||||
{rootPrompts.map((prompt) => (
|
||||
<DraggablePromptItem
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
isSelected={selectedPromptId === prompt.id}
|
||||
onSelect={() => onSelectPrompt(prompt.id)}
|
||||
onEdit={() => onEditPrompt(prompt)}
|
||||
onDelete={() => onDeletePrompt(prompt)}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DroppableFolderProps {
|
||||
folder: Folder;
|
||||
prompts: Prompt[];
|
||||
isExpanded: boolean;
|
||||
isDragOver: boolean;
|
||||
selectedPromptId?: string | null;
|
||||
onToggle: () => void;
|
||||
onSelectPrompt: (promptId: string) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onCreatePrompt: () => void;
|
||||
onEditPrompt: (prompt: Prompt) => void;
|
||||
onDeletePrompt: (prompt: Prompt) => void;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a droppable folder header with optional action menu and its list of prompts.
|
||||
*
|
||||
* @param folder - Folder metadata (id, name, etc.)
|
||||
* @param prompts - Prompts that belong to this folder
|
||||
* @param isExpanded - Whether the folder is expanded to show its prompts
|
||||
* @param isDragOver - Whether a draggable item is currently over this folder (affects visual state)
|
||||
* @param selectedPromptId - ID of the currently selected prompt, used to highlight an item
|
||||
* @param onToggle - Callback invoked to toggle the folder's expanded state
|
||||
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected
|
||||
* @param onEdit - Callback invoked to start editing the folder
|
||||
* @param onDelete - Callback invoked to start deleting the folder
|
||||
* @param onCreatePrompt - Callback invoked to create a new prompt inside this folder
|
||||
* @param onEditPrompt - Callback invoked with a prompt to start editing that prompt
|
||||
* @param onDeletePrompt - Callback invoked with a prompt to start deleting that prompt
|
||||
* @param canCreate - Whether the current user may create prompts in this folder
|
||||
* @param canUpdate - Whether the current user may move/rename prompts or edit the folder
|
||||
* @param canDelete - Whether the current user may delete prompts or the folder
|
||||
* @returns A JSX element containing the folder row and, when expanded, its nested prompt items
|
||||
*/
|
||||
function DroppableFolder({
|
||||
folder,
|
||||
prompts,
|
||||
isExpanded,
|
||||
isDragOver,
|
||||
selectedPromptId,
|
||||
onToggle,
|
||||
onSelectPrompt,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreatePrompt,
|
||||
onEditPrompt,
|
||||
onDeletePrompt,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: DroppableFolderProps) {
|
||||
const { ref } = useDroppable({ id: `folder-${folder.id}` });
|
||||
const showActions = canCreate || canUpdate || canDelete;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb-1 last:mb-0">
|
||||
<div
|
||||
className={cn(
|
||||
"hover:bg-muted/50 group relative flex h-[30px] cursor-pointer items-center gap-1 rounded-sm px-2 transition-colors",
|
||||
isDragOver && "bg-primary/10 ring-primary/30 ring-1",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
data-testid={`sidebar-folder-${folder.id}`}
|
||||
>
|
||||
<button className="flex shrink-0 items-center" aria-label="Toggle folder">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm font-medium">{folder.name}</span>
|
||||
<span className="text-muted-foreground mr-1 shrink-0 text-xs">{prompts.length}</span>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()} className="bg-card absolute top-1/2 right-2 -translate-y-1/2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
data-testid={`sidebar-folder-actions-${folder.id}`}
|
||||
aria-label="Folder actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canCreate && (
|
||||
<DropdownMenuItem
|
||||
data-testid="folder-create-prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreatePrompt();
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Prompt
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canCreate && (canUpdate || canDelete) && <DropdownMenuSeparator />}
|
||||
{canUpdate && (
|
||||
<DropdownMenuItem
|
||||
data-testid="folder-action-edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="folder-action-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-4 border-l pl-2">
|
||||
{prompts.length === 0 ? (
|
||||
<div className="text-muted-foreground py-2 pl-4 text-xs">{isDragOver ? "Drop here" : "No prompts"}</div>
|
||||
) : (
|
||||
prompts.map((prompt) => (
|
||||
<DraggablePromptItem
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
isSelected={selectedPromptId === prompt.id}
|
||||
onSelect={() => onSelectPrompt(prompt.id)}
|
||||
onEdit={() => onEditPrompt(prompt)}
|
||||
onDelete={() => onDeletePrompt(prompt)}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DraggablePromptItemProps {
|
||||
prompt: Prompt;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a draggable prompt list item that shows the prompt name, selection/drag states, and an actions menu when permitted.
|
||||
*
|
||||
* Displays a file icon and truncated prompt name, applies visual styles for selection and dragging, prevents selection while dragging, and exposes rename/delete actions via a dropdown when `canUpdate` or `canDelete` are true.
|
||||
*
|
||||
* @param prompt - The prompt object to render.
|
||||
* @param isSelected - Whether this prompt is currently selected; used for styling.
|
||||
* @param onSelect - Callback invoked when the item is clicked (not invoked if the item is being dragged).
|
||||
* @param onEdit - Callback invoked to start editing/renaming the prompt.
|
||||
* @param onDelete - Callback invoked to delete the prompt.
|
||||
* @param canUpdate - When true, enables dragging and shows the rename action.
|
||||
* @param canDelete - When true, shows the delete action.
|
||||
* @returns The rendered prompt item JSX element.
|
||||
*/
|
||||
function DraggablePromptItem({ prompt, isSelected, onSelect, onEdit, onDelete, canUpdate, canDelete }: DraggablePromptItemProps) {
|
||||
const { ref, isDragging } = useDraggable({
|
||||
id: `prompt-${prompt.id}`,
|
||||
disabled: !canUpdate,
|
||||
});
|
||||
const showActions = canUpdate || canDelete;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={`sidebar-prompt-${prompt.id}`}
|
||||
className={cn(
|
||||
"group mb-1 flex h-[30px] cursor-pointer items-center gap-2 rounded-sm px-2 last:mb-0",
|
||||
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted/50",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onClick={() => {
|
||||
// Don't navigate if this was a drag
|
||||
if (isDragging) return;
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{prompt.name}</span>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
data-testid={`sidebar-prompt-actions-${prompt.id}`}
|
||||
aria-label="Prompt actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canUpdate && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
data-testid="prompt-action-rename"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="cursor-pointer"
|
||||
data-testid="prompt-action-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user