Files
tool-evaluator/artifacts/toolrate/src/pages/tool-new.tsx
T
opencode 8f2fd89847
Build & Push Docker Image / build (push) Successful in 2m49s
feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
  (comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
  audit log entries per imported tool; gated by new 'tool-import' feature
  flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
  upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
  'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
  (administration import section, index/plaene feature tables), regenerate
  API client + zod schemas, add yaml dependency
2026-08-04 23:09:11 +02:00

377 lines
16 KiB
TypeScript

import { useLocation } from "wouter";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getListAllTagsQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { Wrench, Plus, X, ArrowLeft, LogIn } from "lucide-react";
import { Link } from "wouter";
import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
function buildToolSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(2, t("toolForm.nameMin")),
description: z.string().min(10, t("toolForm.descriptionMin")),
category: z.string().min(2, t("toolForm.categoryRequired")),
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
}
export default function ToolNew() {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const createTool = useCreateTool();
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
const toolSchema = buildToolSchema(t);
const form = useForm<ToolFormValues>({
resolver: zodResolver(toolSchema),
defaultValues: {
name: "",
description: "",
category: "",
websiteUrl: "",
iconUrl: "",
features: [{ value: "" }],
tags: [{ value: "" }],
},
});
const { fields: featureFields, append: appendFeature, remove: removeFeature } = useFieldArray({
control: form.control,
name: "features",
});
const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({
control: form.control,
name: "tags",
});
const onSubmit = (data: ToolFormValues) => {
const payload = {
...data,
websiteUrl: data.websiteUrl || undefined,
iconUrl: data.iconUrl || undefined,
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""),
};
createTool.mutate(
{ data: payload },
{
onSuccess: (newTool) => {
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllTagsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
setLocation(`/tools/${newTool.id}`);
},
onError: () => {
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
},
},
);
};
return (
<Layout>
<div className="max-w-3xl mx-auto space-y-6 pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href="/tools">
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
</div>
{!authLoading && !isAuthenticated && (
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
<LogIn className="w-5 h-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
</div>
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
{t("auth.signIn")}
</Button>
</div>
)}
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
</div>
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.name")}
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.category")}
</FormLabel>
<FormControl>
<CategoryCombobox
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.websiteUrlOptional")}
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.iconUrlOptional")}
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{field.value ? (
<img
src={field.value}
alt={t("toolForm.iconPreview")}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
)}
</div>
<Input
placeholder={t("toolForm.logoPlaceholder")}
type="url"
{...field}
data-testid="input-tool-icon-url"
className="flex-1"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.description")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t("toolForm.descriptionPlaceholderNew")}
className="min-h-[120px] resize-none"
{...field}
data-testid="input-tool-description"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.features")}
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendFeature({ value: "" })}
data-testid="button-add-feature"
>
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
</Button>
</div>
<div className="space-y-3">
{featureFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`features.${index}.value`}
render={({ field }) => (
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder={t("toolForm.featurePlaceholder")}
data-testid={`input-feature-${index}`}
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFeature(index)}
data-testid={`button-remove-feature-${index}`}
>
<X className="w-4 h-4" />
</Button>
</FormItem>
)}
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
)}
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.tags")}
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendTag({ value: "" })}
data-testid="button-add-tag"
>
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{tagFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`tags.${index}.value`}
render={({ field }) => (
<FormItem className="flex items-center space-y-0 relative w-[200px]">
<FormControl>
<TagInput
placeholder={t("toolForm.tagPlaceholder")}
className="pr-8 h-9 text-sm"
onChange={field.onChange}
value={field.value ?? ""}
data-testid={`input-tag-${index}`}
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeTag(index)}
data-testid={`button-remove-tag-${index}`}
>
<X className="w-3 h-3" />
</Button>
</FormItem>
)}
/>
))}
</div>
</div>
<div className="pt-6 border-t flex justify-end">
<Button
type="submit"
disabled={createTool.isPending || (!authLoading && !isAuthenticated)}
className="w-full sm:w-auto"
data-testid="button-submit-tool"
>
{createTool.isPending ? t("toolForm.addingTool") : t("toolForm.submitTool")}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</Layout>
);
}