Files
kandev/apps/web/app/settings/workspace/workspace-edit-client.tsx
T

544 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { IconGitBranch, IconLayoutColumns, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/workspaces";
import type { Executor } from "@/lib/types/http";
import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices";
type Workspace = WorkspaceState["items"][number];
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { useAppStore } from "@/components/state-provider";
import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator";
type WorkspaceEditClientProps = {
workspaceId: string;
};
export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) {
const workspace = useAppStore(
(state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null,
);
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" asChild>
<Link href="/settings/workspace">Back to Workspaces</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return <WorkspaceEditForm key={workspace.id} workspace={workspace} />;
}
type WorkspaceEditFormProps = {
workspace: Workspace;
};
type SelectFieldProps = {
label: string;
value: string;
onChange: (v: string) => void;
options: { id: string; name: string }[];
emptyLabel: string;
emptyValue: string;
};
function SelectField({
label,
value,
onChange,
options,
emptyLabel,
emptyValue,
}: SelectFieldProps) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<Select value={value || "none"} onValueChange={(v) => onChange(v === "none" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No default</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name}
</SelectItem>
))}
{options.length === 0 && (
<SelectItem value={emptyValue} disabled>
{emptyLabel}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
);
}
type WorkspaceSettingsCardProps = {
isDirty: boolean;
workspaceNameDraft: string;
onNameChange: (value: string) => void;
defaultExecutorId: string;
onExecutorChange: (value: string) => void;
activeExecutors: Executor[];
executorsEmpty: boolean;
defaultAgentProfileId: string;
onAgentProfileChange: (value: string) => void;
agentProfiles: AgentProfileOption[];
isLoading: boolean;
saveStatus: "idle" | "loading" | "success" | "error";
onSave: () => void;
};
function WorkspaceSettingsCard({
isDirty,
workspaceNameDraft,
onNameChange,
defaultExecutorId,
onExecutorChange,
activeExecutors,
executorsEmpty,
defaultAgentProfileId,
onAgentProfileChange,
agentProfiles,
isLoading,
saveStatus,
onSave,
}: WorkspaceSettingsCardProps) {
const executorOptions = activeExecutors.map((e: Executor) => ({ id: e.id, name: e.name }));
const profileOptions = agentProfiles.map((p: AgentProfileOption) => ({
id: p.id,
name: p.label,
}));
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Workspace Settings</span>
{isDirty && <UnsavedChangesBadge />}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Name</Label>
<Input
id="workspace-name"
value={workspaceNameDraft}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<SelectField
label="Default Executor"
value={defaultExecutorId}
onChange={onExecutorChange}
options={executorsEmpty ? [] : executorOptions}
emptyLabel="No executors available"
emptyValue=""
/>
<SelectField
label="Default Agent Profile"
value={defaultAgentProfileId}
onChange={onAgentProfileChange}
options={profileOptions}
emptyLabel="No agent profiles available"
emptyValue="empty-agent-profiles"
/>
<div className="flex justify-end pt-2">
<UnsavedSaveButton
isDirty={isDirty}
isLoading={isLoading}
status={saveStatus}
onClick={onSave}
/>
</div>
</div>
</CardContent>
</Card>
);
}
type WorkspaceLinksCardProps = {
workspaceId: string;
};
function WorkspaceLinksCard({ workspaceId }: WorkspaceLinksCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Workspace Links</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${workspaceId}/repositories`}>
<IconGitBranch className="h-4 w-4" />
Repositories
</Link>
</Button>
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${workspaceId}/workflows`}>
<IconLayoutColumns className="h-4 w-4" />
Workflows
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
type DeleteWorkspaceCardProps = {
workspaceName: string;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
deleteConfirmText: string;
setDeleteConfirmText: (text: string) => void;
onDelete: () => void;
};
function DeleteWorkspaceCard({
workspaceName,
deleteDialogOpen,
setDeleteDialogOpen,
deleteConfirmText,
setDeleteConfirmText,
onDelete,
}: DeleteWorkspaceCardProps) {
return (
<>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete Workspace</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Delete this workspace</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
</div>
<Button
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
className="cursor-pointer"
data-testid="workspace-settings-delete-button"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workspace</DialogTitle>
<DialogDescription>
Type the workspace name <span className="font-medium">{workspaceName}</span> to
confirm deletion. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="confirm-delete">Confirm Delete</Label>
<Input
id="confirm-delete"
value={deleteConfirmText}
onChange={(event) => setDeleteConfirmText(event.target.value)}
placeholder={workspaceName}
autoComplete="off"
data-testid="workspace-settings-delete-confirm-input"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="cursor-pointer"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onDelete}
disabled={deleteConfirmText !== workspaceName}
className="cursor-pointer"
data-testid="workspace-settings-delete-confirm-button"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
type SavedState = {
name: string;
executorId: string;
agentProfileId: string;
};
function buildWorkspaceUpdates(
draft: { name: string; executorId: string; agentProfileId: string },
saved: SavedState,
): Record<string, string | undefined> {
const updates: Record<string, string | undefined> = {};
if (draft.name.trim() !== saved.name) updates.name = draft.name.trim();
if (draft.executorId !== saved.executorId) updates.default_executor_id = draft.executorId;
if (draft.agentProfileId !== saved.agentProfileId)
updates.default_agent_profile_id = draft.agentProfileId;
return updates;
}
type WorkspaceDraftState = {
workspaceNameDraft: string;
defaultExecutorId: string;
defaultAgentProfileId: string;
};
type SaveRequestLike = {
run: (id: string, updates: Record<string, string | undefined>) => Promise<Workspace>;
};
type WorkspaceSaveHandlerOptions = {
currentWorkspace: Workspace;
draft: WorkspaceDraftState;
savedState: SavedState;
isDirty: boolean;
setSavedState: (s: SavedState) => void;
setCurrentWorkspace: (fn: (prev: Workspace) => Workspace) => void;
workspaces: Workspace[];
setWorkspaces: (items: Workspace[]) => void;
saveWorkspaceRequest: SaveRequestLike;
toast: ReturnType<typeof useToast>["toast"];
};
function buildSaveHandler({
currentWorkspace,
draft,
savedState,
isDirty,
setSavedState,
setCurrentWorkspace,
workspaces,
setWorkspaces,
saveWorkspaceRequest,
toast,
}: WorkspaceSaveHandlerOptions) {
return async () => {
if (!isDirty) return;
try {
const updates = buildWorkspaceUpdates(
{
name: draft.workspaceNameDraft,
executorId: draft.defaultExecutorId,
agentProfileId: draft.defaultAgentProfileId,
},
savedState,
);
const updated = await saveWorkspaceRequest.run(currentWorkspace.id, updates);
setCurrentWorkspace((prev) => ({ ...prev, ...updated }));
setSavedState({
name: updated.name ?? draft.workspaceNameDraft.trim(),
executorId: updated.default_executor_id ?? "",
agentProfileId: updated.default_agent_profile_id ?? "",
});
setWorkspaces(
workspaces.map((ws: Workspace) =>
ws.id === updated.id
? {
...ws,
name: updated.name,
default_executor_id: updated.default_executor_id ?? null,
default_environment_id: updated.default_environment_id ?? null,
default_agent_profile_id: updated.default_agent_profile_id ?? null,
}
: ws,
),
);
} catch (error) {
toast({
title: "Failed to save workspace",
description: error instanceof Error ? error.message : "Request failed",
variant: "error",
});
}
};
}
function useWorkspaceEditForm(workspace: Workspace) {
const router = useRouter();
const { toast } = useToast();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? "");
const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? "");
const [defaultAgentProfileId, setDefaultAgentProfileId] = useState(
workspace.default_agent_profile_id ?? "",
);
const [savedState, setSavedState] = useState<SavedState>({
name: workspace.name ?? "",
executorId: workspace.default_executor_id ?? "",
agentProfileId: workspace.default_agent_profile_id ?? "",
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const executors = useAppStore((state) => state.executors.items);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const workspaces = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
const activeExecutors = executors.filter((executor: Executor) => executor.status === "active");
const isDirty =
workspaceNameDraft.trim() !== savedState.name ||
defaultExecutorId !== savedState.executorId ||
defaultAgentProfileId !== savedState.agentProfileId;
const handleSave = buildSaveHandler({
currentWorkspace,
draft: { workspaceNameDraft, defaultExecutorId, defaultAgentProfileId },
savedState,
isDirty,
setSavedState,
setCurrentWorkspace,
workspaces,
setWorkspaces,
saveWorkspaceRequest,
toast,
});
const handleDeleteWorkspace = async () => {
if (deleteConfirmText !== currentWorkspace.name) return;
try {
await deleteWorkspaceRequest.run(currentWorkspace.id, currentWorkspace.name);
setWorkspaces(workspaces.filter((ws: Workspace) => ws.id !== currentWorkspace.id));
router.push("/settings/workspace");
} catch (error) {
toast({
title: "Failed to delete workspace",
description: error instanceof Error ? error.message : "Request failed",
variant: "error",
});
}
};
// Clears pre-fill so Cancel-then-reopen can't silently bypass the re-type requirement.
const handleDeleteDialogOpenChange = (open: boolean) => {
setDeleteDialogOpen(open);
if (!open) setDeleteConfirmText("");
};
return {
currentWorkspace,
workspaceNameDraft,
setWorkspaceNameDraft,
defaultExecutorId,
setDefaultExecutorId,
defaultAgentProfileId,
setDefaultAgentProfileId,
deleteDialogOpen,
setDeleteDialogOpen: handleDeleteDialogOpenChange,
deleteConfirmText,
setDeleteConfirmText,
activeExecutors,
executors,
agentProfiles,
isDirty,
saveWorkspaceRequest,
handleSave,
handleDeleteWorkspace,
};
}
function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
const {
currentWorkspace,
workspaceNameDraft,
setWorkspaceNameDraft,
defaultExecutorId,
setDefaultExecutorId,
defaultAgentProfileId,
setDefaultAgentProfileId,
deleteDialogOpen,
setDeleteDialogOpen,
deleteConfirmText,
setDeleteConfirmText,
activeExecutors,
executors,
agentProfiles,
isDirty,
saveWorkspaceRequest,
handleSave,
handleDeleteWorkspace,
} = useWorkspaceEditForm(workspace);
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{currentWorkspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage workspace details and jump into workflows or repositories.
</p>
</div>
<Separator />
<WorkspaceSettingsCard
isDirty={isDirty}
workspaceNameDraft={workspaceNameDraft}
onNameChange={setWorkspaceNameDraft}
defaultExecutorId={defaultExecutorId}
onExecutorChange={setDefaultExecutorId}
activeExecutors={activeExecutors}
executorsEmpty={executors.length === 0}
defaultAgentProfileId={defaultAgentProfileId}
onAgentProfileChange={setDefaultAgentProfileId}
agentProfiles={agentProfiles}
isLoading={saveWorkspaceRequest.isLoading}
saveStatus={saveWorkspaceRequest.status}
onSave={handleSave}
/>
<WorkspaceLinksCard workspaceId={currentWorkspace.id} />
<Separator />
<DeleteWorkspaceCard
workspaceName={currentWorkspace.name}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
deleteConfirmText={deleteConfirmText}
setDeleteConfirmText={setDeleteConfirmText}
onDelete={handleDeleteWorkspace}
/>
</div>
);
}