Files
kandev/apps/web/app/settings/workspace/workspace-workflows-dialogs.tsx
T
2026-09-18 00:34:50 +01:00

363 lines
13 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import { Textarea } from "@kandev/ui/textarea";
import { cn } from "@/lib/utils";
import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog";
import type {
WorkflowImportPreview,
WorkflowImportProfileConflict,
WorkflowImportProfileStep,
WorkflowTemplate,
} from "@/lib/types/http";
import { WorkflowImportProfileSelection } from "./workflow-import-profile-selection";
import type { WorkflowImportPhase, WorkflowImportSelections } from "./use-workflow-import";
const YAML_PLACEHOLDER =
"version: 2\ntype: kandev_workflow\nworkflows:\n - name: My Workflow\n steps: [...]";
type ImportWorkflowsDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
importYaml: string;
onImportYamlChange: (value: string) => void;
onFileUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onImport: () => void | Promise<void>;
importLoading: boolean;
importPhase: WorkflowImportPhase;
preview: WorkflowImportPreview | null;
selections: WorkflowImportSelections;
missingSteps: WorkflowImportProfileStep[];
profileConflicts: WorkflowImportProfileConflict[];
activeStepKey: string | null;
setActiveStepKey: (key: string | null) => void;
selectProfile: (stepKey: string, profileId: string) => void;
retryPreview: () => void | Promise<void>;
};
export function ImportWorkflowsDialog({
open,
onOpenChange,
importYaml,
onImportYamlChange,
onFileUpload,
fileInputRef,
onImport,
importLoading,
importPhase,
preview,
selections,
missingSteps,
profileConflicts,
activeStepKey,
setActiveStepKey,
selectProfile,
retryPreview,
}: ImportWorkflowsDialogProps) {
const { t } = useTranslation();
if (preview && importPhase !== "editing") {
return (
<WorkflowImportProfileSelection
open={open}
onOpenChange={onOpenChange}
preview={preview}
selections={selections}
missingSteps={missingSteps}
profileConflicts={profileConflicts}
activeStepKey={activeStepKey}
onActiveStepKeyChange={setActiveStepKey}
onSelectProfile={selectProfile}
onImport={onImport}
onRetryPreview={retryPreview}
importLoading={importLoading}
/>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("workflows:importWorkflowsTitle")}</DialogTitle>
</DialogHeader>
<div className="min-h-0 space-y-5 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="workflow-import-file">{t("workflows:uploadYamlFile")}</Label>
<input
id="workflow-import-file"
ref={fileInputRef}
type="file"
accept=".yml,.yaml"
onChange={onFileUpload}
disabled={importLoading}
className="block w-full min-w-0 cursor-pointer text-sm text-muted-foreground file:mr-3 file:h-7 file:cursor-pointer file:rounded-md file:border file:border-input file:bg-muted file:px-3 file:py-0 file:text-xs file:font-medium file:text-foreground max-md:file:h-11 [@media(pointer:coarse)]:file:h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="workflow-import-yaml">{t("workflows:orPasteYaml")}</Label>
<Textarea
id="workflow-import-yaml"
// The placeholder is a sample of the kandev_workflow export
// payload — a wire format, so its keys stay untranslated.
placeholder={YAML_PLACEHOLDER}
value={importYaml}
onChange={(e) => onImportYamlChange(e.target.value)}
disabled={importLoading}
className="min-h-56 max-h-[50dvh] resize-y overflow-auto font-mono text-xs leading-relaxed"
/>
</div>
</div>
<DialogFooter className="shrink-0">
{importLoading && (
<span className="sr-only" role="status" aria-live="polite">
{t("workflows:importing")}
</span>
)}
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
{t("common:cancel")}
</Button>
<Button
onClick={onImport}
disabled={!importYaml.trim() || importLoading}
className="cursor-pointer"
>
{importLoading ? t("workflows:importing") : t("workflows:import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TemplateRadioItem({
template,
isSelected,
}: {
template: WorkflowTemplate;
isSelected: boolean;
}) {
return (
<label
htmlFor={template.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
)}
>
<RadioGroupItem value={template.id} id={template.id} className="mt-0.5" />
<div className="flex flex-col gap-1.5 min-w-0">
<span className="font-medium">{template.name}</span>
{template.description && (
<span className="text-sm text-muted-foreground">{template.description}</span>
)}
{template.default_steps && template.default_steps.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap mt-0.5">
{template.default_steps.map((step, i) => (
<div key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/40 text-xs">&rarr;</span>}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className={cn("w-2 h-2 rounded-full", step.color ?? "bg-slate-500")} />
{step.name}
</div>
</div>
))}
</div>
)}
</div>
</label>
);
}
type CreateWorkflowDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowName: string;
onWorkflowNameChange: (value: string) => void;
selectedTemplateId: string | null;
onSelectedTemplateChange: (value: string | null) => void;
workflowTemplates: WorkflowTemplate[];
onCreate: () => void | Promise<void>;
createLoading?: boolean;
};
export function CreateWorkflowDialog({
open,
onOpenChange,
workflowName,
onWorkflowNameChange,
selectedTemplateId,
onSelectedTemplateChange,
workflowTemplates,
onCreate,
createLoading = false,
}: CreateWorkflowDialogProps) {
const { t } = useTranslation();
const handleOpenChange = (nextOpen: boolean) => {
if (createLoading && !nextOpen) return;
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:w-[900px] sm:max-w-none max-h-[90vh] flex flex-col"
data-testid="create-workflow-dialog"
>
<DialogHeader>
<DialogTitle>{t("workflows:addWorkflow")}</DialogTitle>
</DialogHeader>
<div className="space-y-6 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="workflowName">{t("workflows:name")}</Label>
<Input
id="workflowName"
placeholder={t("workflows:workflowNamePlaceholder")}
value={workflowName}
onChange={(e) => onWorkflowNameChange(e.target.value)}
data-testid="workflow-name-input"
/>
</div>
{workflowTemplates.length > 0 && (
<div className="space-y-2">
<Label>{t("workflows:template")}</Label>
<RadioGroup
value={selectedTemplateId ?? "custom"}
onValueChange={(v) => onSelectedTemplateChange(v === "custom" ? null : v)}
>
<div className="grid gap-3">
{workflowTemplates.map((template) => (
<TemplateRadioItem
key={template.id}
template={template}
isSelected={selectedTemplateId === template.id}
/>
))}
<label
htmlFor="custom"
className={cn(
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
selectedTemplateId === null
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
)}
>
<RadioGroupItem value="custom" id="custom" className="mt-0.5" />
<div className="flex flex-col gap-1.5">
<span className="font-medium">{t("workflows:customTemplate")}</span>
<span className="text-sm text-muted-foreground">
{t("workflows:customTemplateDescription")}
</span>
</div>
</label>
</div>
</RadioGroup>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={createLoading}
className="cursor-pointer"
>
{t("common:cancel")}
</Button>
<Button
onClick={onCreate}
disabled={createLoading}
className="cursor-pointer"
data-testid="confirm-create-workflow"
data-dialog-default-action
>
{createLoading ? t("workflows:adding") : t("workflows:addWorkflow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function WorkflowDialogs({
page,
}: {
page: {
isExportDialogOpen: boolean;
setIsExportDialogOpen: (open: boolean) => void;
exportYaml: string;
isImportDialogOpen: boolean;
setIsImportDialogOpen: (open: boolean) => void;
importYaml: string;
setImportYaml: (value: string) => void;
handleFileUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleImport: () => Promise<void>;
importLoading: boolean;
importPhase: WorkflowImportPhase;
preview: WorkflowImportPreview | null;
selections: WorkflowImportSelections;
missingSteps: WorkflowImportProfileStep[];
profileConflicts: WorkflowImportProfileConflict[];
activeStepKey: string | null;
setActiveStepKey: (key: string | null) => void;
selectProfile: (stepKey: string, profileId: string) => void;
retryPreview: () => void | Promise<void>;
isAddWorkflowDialogOpen: boolean;
setIsAddWorkflowDialogOpen: (open: boolean) => void;
newWorkflowName: string;
setNewWorkflowName: (name: string) => void;
selectedTemplateId: string | null;
setSelectedTemplateId: (id: string | null) => void;
workflowTemplates: WorkflowTemplate[];
handleCreateWorkflow: () => Promise<void> | void;
createWorkflowLoading: boolean;
};
}) {
const { t } = useTranslation();
return (
<>
<WorkflowExportDialog
open={page.isExportDialogOpen}
onOpenChange={page.setIsExportDialogOpen}
title={t("workflows:exportWorkflowsTitle")}
content={page.exportYaml}
/>
<ImportWorkflowsDialog
open={page.isImportDialogOpen}
onOpenChange={page.setIsImportDialogOpen}
importYaml={page.importYaml}
onImportYamlChange={page.setImportYaml}
onFileUpload={page.handleFileUpload}
fileInputRef={page.fileInputRef}
onImport={page.handleImport}
importLoading={page.importLoading}
importPhase={page.importPhase}
preview={page.preview}
selections={page.selections}
missingSteps={page.missingSteps}
profileConflicts={page.profileConflicts}
activeStepKey={page.activeStepKey}
setActiveStepKey={page.setActiveStepKey}
selectProfile={page.selectProfile}
retryPreview={page.retryPreview}
/>
<CreateWorkflowDialog
open={page.isAddWorkflowDialogOpen}
onOpenChange={page.setIsAddWorkflowDialogOpen}
workflowName={page.newWorkflowName}
onWorkflowNameChange={page.setNewWorkflowName}
selectedTemplateId={page.selectedTemplateId}
onSelectedTemplateChange={page.setSelectedTemplateId}
workflowTemplates={page.workflowTemplates}
onCreate={page.handleCreateWorkflow}
createLoading={page.createWorkflowLoading}
/>
</>
);
}