e164d51574
- Raise compare limit from 8 to 9 (server + client, toast on overflow) - Make import textarea placeholder match selected format (CSV/JSON/YAML/auto) - Add GuideHelp links to admin create/edit user dialogs and tool import dialog
197 lines
8.0 KiB
TypeScript
197 lines
8.0 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Label } from "@/components/ui/label";
|
|
import { GuideHelp } from "@/components/guide-help";
|
|
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
|
|
|
|
type Format = "auto" | "csv" | "json" | "yaml";
|
|
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
|
|
|
|
export function ToolImportDialog({
|
|
open,
|
|
onOpenChange,
|
|
onImported,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onImported: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const importTools = useImportTools();
|
|
const [format, setFormat] = useState<Format>("auto");
|
|
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
|
|
const [data, setData] = useState("");
|
|
const [result, setResult] = useState<ToolImportResponse | null>(null);
|
|
|
|
function handleFile(file: File) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
setData(String(reader.result ?? ""));
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function handleImport() {
|
|
importTools.mutate(
|
|
{ data: { format, delimiter, data } },
|
|
{
|
|
onSuccess: (res) => {
|
|
setResult(res);
|
|
if (res.imported > 0) onImported();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleClose(open: boolean) {
|
|
if (!importTools.isPending) {
|
|
onOpenChange(open);
|
|
if (!open) {
|
|
setData("");
|
|
setResult(null);
|
|
setFormat("auto");
|
|
setDelimiter("auto");
|
|
}
|
|
}
|
|
}
|
|
|
|
const canSubmit = data.trim().length > 0 && !importTools.isPending;
|
|
|
|
const placeholder =
|
|
format === "csv"
|
|
? t("adminTools.importTextareaPlaceholderCsv")
|
|
: format === "json"
|
|
? t("adminTools.importTextareaPlaceholderJson")
|
|
: format === "yaml"
|
|
? t("adminTools.importTextareaPlaceholderYaml")
|
|
: t("adminTools.importTextareaPlaceholderAuto");
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader className="flex flex-row items-center justify-between gap-2">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Upload className="w-4 h-4" />
|
|
{t("adminTools.importTitle")}
|
|
</DialogTitle>
|
|
<GuideHelp guide="administration" label={t("adminTools.importTitle")} />
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
|
|
|
|
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
|
|
<TabsList className="w-full grid grid-cols-4">
|
|
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
|
|
<TabsTrigger value="csv">CSV</TabsTrigger>
|
|
<TabsTrigger value="json">JSON</TabsTrigger>
|
|
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
|
|
{t("adminTools.importFormatAutoSub")}
|
|
</TabsContent>
|
|
<TabsContent value="csv" className="pt-2 space-y-3">
|
|
<div className="space-y-2">
|
|
<Label>{t("adminTools.importDelimiter")}</Label>
|
|
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
|
|
<SelectTrigger className="w-52">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
|
|
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
|
|
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
|
|
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
|
|
</TabsContent>
|
|
<TabsContent value="json" className="pt-2 space-y-3">
|
|
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
|
|
</TabsContent>
|
|
<TabsContent value="yaml" className="pt-2 space-y-3">
|
|
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
|
|
<FileUp className="w-4 h-4" />
|
|
{t("adminTools.importFile")}
|
|
</Button>
|
|
<input
|
|
id="tool-import-file"
|
|
type="file"
|
|
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) handleFile(f);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
value={data}
|
|
onChange={(e) => setData(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="min-h-[220px] font-mono text-sm"
|
|
data-testid="input-tool-import-data"
|
|
/>
|
|
|
|
{importTools.isError && (
|
|
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{result && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
|
|
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
|
|
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
|
|
</div>
|
|
{result.errors.length > 0 && (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
|
<AlertCircle className="w-4 h-4 text-amber-500" />
|
|
{t("adminTools.importErrors", { count: result.errors.length })}
|
|
</p>
|
|
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
|
|
{result.errors.map((err, i) => (
|
|
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
|
|
<span className="font-mono text-muted-foreground shrink-0">
|
|
{t("adminTools.importErrorRow", { row: err.row })}:
|
|
</span>
|
|
<span className="text-foreground break-words">{err.error}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button onClick={handleImport} disabled={!canSubmit}>
|
|
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|