Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 17 additions & 72 deletions app/(main)/guardrails/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ import {
SavedValidatorConfig,
OrgContext,
} from "@/app/lib/types/guardrails";
import { buildValidatorUpdatePayload } from "@/app/lib/utils/guardrails";
import ValidatorConfigPanel from "@/app/components/guardrails/ValidatorConfigPanel";
import SavedConfigsList from "@/app/components/guardrails/SavedConfigsList";
import DeleteConfigModal from "@/app/components/guardrails/DeleteConfigModal";

export default function GuardrailsPage() {
const { sidebarCollapsed } = useApp();
Expand All @@ -38,8 +36,6 @@ export default function GuardrailsPage() {
const [selectedSavedConfig, setSelectedSavedConfig] =
useState<SavedValidatorConfig | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [configPendingDelete, setConfigPendingDelete] =
useState<SavedValidatorConfig | null>(null);

useEffect(() => {
if (!isHydrated) return;
Expand Down Expand Up @@ -122,40 +118,11 @@ export default function GuardrailsPage() {
setSelectedSavedConfig(null);
};

const handleRequestDeleteConfig = (configId: string) => {
const cfg = savedConfigs.find((c) => c.id === configId);
if (cfg) setConfigPendingDelete(cfg);
};

const handleConfirmDeleteConfig = async () => {
if (!configPendingDelete) return;
if (!configsQueryString) {
setConfigPendingDelete(null);
return;
}
const configId = configPendingDelete.id;
setConfigPendingDelete(null);
try {
await guardrailsFetch(
`/api/guardrails/validators/configs/${configId}${configsQueryString}`,
apiKey,
{ method: "DELETE" },
);
toast.success("Config deleted");
if (selectedSavedConfig?.id === configId) {
handleClearForm();
}
fetchSavedConfigs();
} catch {
toast.error("Failed to delete config");
}
};

const handleSaveConfig = async (
name: string,
configValues: Record<string, unknown>,
) => {
if (!name.trim()) {
const handleSaveConfig = async (configValues: Record<string, unknown>) => {
if (selectedSavedConfig) return;
const name =
typeof configValues.name === "string" ? configValues.name.trim() : "";
if (!name) {
toast.error("Please enter a config name");
return;
}
Expand All @@ -165,33 +132,18 @@ export default function GuardrailsPage() {
}
setIsSaving(true);
try {
const isUpdate = !!selectedSavedConfig;
const base = `/api/guardrails/validators/configs`;
const url = isUpdate
? `${base}/${selectedSavedConfig!.id}${configsQueryString}`
: `${base}${configsQueryString}`;

const body = isUpdate
? buildValidatorUpdatePayload(configValues)
: configValues;

await guardrailsFetch(url, apiKey, {
method: isUpdate ? "PATCH" : "POST",
body: JSON.stringify(body),
});
toast.success(
isUpdate ? `Config "${name}" updated` : `Config "${name}" saved`,
await guardrailsFetch(
`/api/guardrails/validators/configs${configsQueryString}`,
apiKey,
{
method: "POST",
body: JSON.stringify(configValues),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
);
const savedConfigId = selectedSavedConfig?.id;
const freshList = await fetchSavedConfigs();
if (isUpdate && savedConfigId) {
setSelectedSavedConfig(
freshList.find((c) => c.id === savedConfigId) ?? null,
);
} else {
setSelectedSavedConfig(null);
setSelectedValidatorType(null);
}
toast.success(`Config "${name}" saved`);
await fetchSavedConfigs();
setSelectedSavedConfig(null);
setSelectedValidatorType(null);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save config");
} finally {
Expand Down Expand Up @@ -234,7 +186,6 @@ export default function GuardrailsPage() {
isLoading={savedConfigsLoading}
selectedConfigId={selectedSavedConfig?.id ?? null}
onSelectConfig={handleSelectSavedConfig}
onDeleteConfig={handleRequestDeleteConfig}
onNewConfig={handleClearForm}
/>
</div>
Expand All @@ -250,17 +201,11 @@ export default function GuardrailsPage() {
isSaving={isSaving}
onSave={handleSaveConfig}
onClear={handleClearForm}
readOnly={!!selectedSavedConfig}
/>
</div>
</div>
</div>

<DeleteConfigModal
open={!!configPendingDelete}
configName={configPendingDelete?.name}
onClose={() => setConfigPendingDelete(null)}
onConfirm={handleConfirmDeleteConfig}
/>
</div>
);
}
68 changes: 0 additions & 68 deletions app/api/guardrails/validators/configs/[config_id]/route.ts

This file was deleted.

84 changes: 68 additions & 16 deletions app/components/guardrails/BanListField.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import BanListModal from "./BanListModal";
import { guardrailsFetch } from "@/app/lib/guardrailsClient";
import { useAuth } from "@/app/lib/context/AuthContext";
import { Loader, Select } from "@/app/components/ui";
import { CheckLineIcon, CopyIcon } from "@/app/components/icons";
import { BanList, BanListFieldProps } from "@/app/lib/types/guardrails";

interface BanList {
id: string;
name: string;
}

interface BanListFieldProps {
value: string | null;
onChange: (id: string | null) => void;
}
const WORDS_MIN_PX = 44;
const WORDS_MAX_PX = 220;

export default function BanListField({ value, onChange }: BanListFieldProps) {
const { activeKey } = useAuth();
Expand All @@ -24,6 +19,31 @@ export default function BanListField({ value, onChange }: BanListFieldProps) {
const [bannedWords, setBannedWords] = useState<string[]>([]);
const [wordsLoading, setWordsLoading] = useState(false);
const [wordsError, setWordsError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const wordsRef = useRef<HTMLTextAreaElement>(null);

const autoSize = (
el: HTMLTextAreaElement | null,
minPx: number,
maxPx: number,
) => {
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(Math.max(el.scrollHeight, minPx), maxPx)}px`;
};

const wordsAsText = bannedWords.join(", ");

const handleCopy = async () => {
if (!wordsAsText) return;
try {
await navigator.clipboard.writeText(wordsAsText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};

const fetchBanLists = () => {
setLoading(true);
Expand Down Expand Up @@ -87,6 +107,10 @@ export default function BanListField({ value, onChange }: BanListFieldProps) {
}
}, [value]);

useEffect(() => {
autoSize(wordsRef.current, WORDS_MIN_PX, WORDS_MAX_PX);
}, [wordsAsText, wordsLoading]);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value || null);
};
Expand Down Expand Up @@ -168,15 +192,43 @@ export default function BanListField({ value, onChange }: BanListFieldProps) {
}
if (bannedWords.length > 0) {
return (
<div className="flex flex-wrap gap-1">
{bannedWords.map((word) => (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-medium text-text-secondary">
{bannedWords.length} word{bannedWords.length !== 1 ? "s" : ""} ·
select text to copy, or use the copy button
</span>
<span
key={word}
className="inline-block text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text border border-status-success-border"
role="button"
tabIndex={0}
onClick={handleCopy}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCopy();
}
}}
aria-label={copied ? "Copied" : "Copy banned words"}
title={copied ? "Copied" : "Copy comma-separated list"}
className="shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium text-accent-primary bg-accent-primary/5 hover:bg-accent-primary/10 transition-colors cursor-pointer"
>
{word}
{copied ? (
<CheckLineIcon className="w-3 h-3 text-status-success" />
) : (
<CopyIcon className="w-3 h-3" />
)}
{copied ? "Copied" : "Copy"}
</span>
))}
</div>
<textarea
ref={wordsRef}
value={wordsAsText}
readOnly
rows={2}
spellCheck={false}
onFocus={(e) => e.currentTarget.select()}
className="w-full text-sm rounded-md border border-status-success-border bg-status-success-bg text-status-success-text px-2.5 py-1.5 outline-none resize-none font-mono leading-relaxed overflow-hidden cursor-text"
/>
</div>
);
}
Expand Down
20 changes: 15 additions & 5 deletions app/components/guardrails/BanListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function BanListModal({
const [isPublic, setIsPublic] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [descriptionError, setDescriptionError] = useState("");
const [wordsError, setWordsError] = useState("");

const handleCreate = async () => {
Expand All @@ -31,6 +32,12 @@ export default function BanListModal({
} else {
setNameError("");
}
if (!description.trim()) {
setDescriptionError("Description is required");
hasError = true;
} else {
setDescriptionError("");
}
if (!bannedWords.trim()) {
setWordsError("At least one banned word is required");
hasError = true;
Expand Down Expand Up @@ -85,15 +92,20 @@ export default function BanListModal({

<div>
<label className="block text-xs font-medium mb-1 text-text-secondary">
Description
Description *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this ban list covers…"
rows={2}
className={textareaClass}
className={`${textareaClass} ${descriptionError ? "border-status-error-border" : ""}`}
/>
{descriptionError && (
<p className="text-xs text-status-error-text">
{descriptionError}
</p>
)}
</div>

<div>
Expand All @@ -109,9 +121,7 @@ export default function BanListModal({
className={`${textareaClass} ${wordsError ? "border-status-error-border" : ""}`}
/>
{wordsError && (
<p className="text-xs text-status-error-text mt-1">
{wordsError}
</p>
<p className="text-xs text-status-error-text">{wordsError}</p>
)}
</div>

Expand Down
Loading
Loading