import React, { useState } from "react"; import { Button } from "@/src/components/ui/button"; import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess"; import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/src/components/ui/dialog"; import { PlusIcon, Trash } from "lucide-react"; import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; import { isPresent, ScoreDataType, availableDataTypes } from "@langfuse/shared"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/src/components/ui/select"; import { api } from "@/src/utils/api"; import { Textarea } from "@/src/components/ui/textarea"; import { isBooleanDataType, isCategoricalDataType, isNumericDataType, } from "@/src/features/scores/lib/helpers"; import DocPopup from "@/src/components/layouts/doc-popup"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { z } from "zod/v4"; const Category = z.object({ label: z.string().min(1), value: z.number(), }); const createConfigSchema = z.object({ name: z.string().min(1).max(35), dataType: z.enum(availableDataTypes), minValue: z.coerce.number().optional(), maxValue: z.coerce.number().optional(), categories: z.array(Category).optional(), description: z.string().optional(), }); type CreateConfig = z.infer; const validateScoreConfig = (values: CreateConfig): string | null => { const { dataType, maxValue, minValue, categories } = values; if (isNumericDataType(dataType)) { if ( isPresent(maxValue) && isPresent(minValue) && Number(maxValue) <= Number(minValue) ) { return "Maximum value must be greater than Minimum value."; } } else if (isCategoricalDataType(dataType)) { if (!categories || categories.length === 0) { return "At least one category is required for categorical data types."; } } else if (isBooleanDataType(dataType)) { if (categories?.length !== 2) return "Boolean data type must have exactly 2 categories."; const isBooleanCategoryInvalid = categories?.some( (category) => category.value !== 0 && category.value !== 1, ); if (isBooleanCategoryInvalid) return "Boolean data type must have categories with values 0 and 1."; } const uniqueNames = new Set(); const uniqueValues = new Set(); for (const category of categories || []) { if (uniqueNames.has(category.label)) { return "Category names must be unique."; } uniqueNames.add(category.label); if (uniqueValues.has(category.value)) { return "Category values must be unique."; } uniqueValues.add(category.value); } return null; }; export function CreateScoreConfigButton({ projectId }: { projectId: string }) { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const capture = usePostHogClientCapture(); const hasAccess = useHasProjectAccess({ projectId: projectId, scope: "scoreConfigs:CUD", }); const utils = api.useUtils(); const createScoreConfig = api.scoreConfigs.create.useMutation({ onSuccess: () => utils.scoreConfigs.invalidate(), onError: (error) => setFormError(error.message ?? "An error occurred while creating config."), }); const form = useForm({ resolver: zodResolver(createConfigSchema), defaultValues: { dataType: ScoreDataType.NUMERIC, minValue: undefined, maxValue: undefined, name: "", }, }) as UseFormReturn; const { fields, append, remove, replace } = useFieldArray({ control: form.control, name: "categories", }); if (!hasAccess) return null; async function onSubmit(values: CreateConfig) { const error = validateScoreConfig(values); setFormError(error); const isValid = await form.trigger(); if (!isValid || error) return; return createScoreConfig .mutateAsync({ projectId, ...values, categories: values.categories?.length ? values.categories : undefined, }) .then(() => { capture("score_configs:create_form_submit", { dataType: values.dataType, }); form.reset(); setOpen(false); setConfirmOpen(false); }) .catch((error) => { console.error(error); }); } const handleSubmitConfirm = async () => { const error = validateScoreConfig(form.getValues()); setFormError(error); const isValid = await form.trigger(); if (isValid && !error) { setConfirmOpen(true); } }; return ( <> { setOpen(v); form.reset(); setFormError(null); }} > Add new score config
( Name field.onChange(e.target.value.trimEnd()) } /> )} /> ( Data type )} /> {isNumericDataType(form.getValues("dataType")) ? ( <> ( Minimum (optional) { const value = e.target.value; field.onChange( value === "" ? undefined : Number(value), ); }} type="number" /> )} /> ( Maximum (optional) { const value = e.target.value; field.onChange( value === "" ? undefined : Number(value), ); }} type="number" /> )} /> ) : (
( <> {fields.length > 0 && (
Value Label
)} {fields.map((category, index) => (
( )} />
( field.onChange( e.target.value.trimEnd(), ) } readOnly={isBooleanDataType( form.getValues("dataType"), )} /> )} /> {isCategoricalDataType( form.getValues("dataType"), ) && ( )}
))} {isCategoricalDataType( form.getValues("dataType"), ) && (
)} )} />
)} ( <> Description (optional)