mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): migrate alerting DynamicForm + email_event_settings to shadcn
- alerting/dynamic_form.tsx: antd Form/Input/InputNumber/Button + @tremor/react Badge/Icon/Text/TableRow/TableCell/Switch/Button + @heroicons/react TrashIcon/CheckCircleIcon \u2192 native <form> with local state snapshot, shadcn Badge/Button/Input/Switch/TableCell/ TableRow, lucide Trash2/CheckCircle. Emits <TableRow> children expected to be rendered inside an ambient <Table>/<TableBody> parent. - email_events/email_event_settings.tsx: antd Typography/Divider/Spin/ Checkbox + @tremor/react Card/Text/Button \u2192 shadcn Card/Button/ Checkbox/Separator/Skeleton, plain <h4>/<p>/<label> + Tailwind. Turned the async fetchEventSettings into an inline useEffect to satisfy react-hooks/exhaustive-deps (trivially the original logic). Gates: TS \u2713 | Lint \u2713 | Vitest n/a (no colocated tests) | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
0bff3df3cc
commit
7cd5643db1
3 changed files with 198 additions and 145 deletions
|
|
@ -52,3 +52,21 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
|
|||
- Scope: PolicySelect.tsx only (the single banned-import file under ToolPolicies/). The ToolPoliciesView.tsx top-level wrapper has no antd imports already. Top-level ToolPolicies.tsx legacy wrapper is not in scope.
|
||||
- Section decision: keep amber/emerald/red categorical colors for policy badges (not theme-semantic). Documented in DEVIATIONS.md.
|
||||
|
||||
## 21. Logging & Alerts / 32-a: Alerting `DynamicForm`
|
||||
|
||||
- Cycles used: 1 / 7
|
||||
- Layer outcomes per cycle:
|
||||
- cycle 1: TS ✓ | Lint ✓ | Vitest n/a (no colocated tests) | Build ✓ | Parity ⏭ | Snap ⏭
|
||||
- Final status: **done (partial — single file within the Logging & Alerts section)**
|
||||
- Scope: `src/components/alerting/dynamic_form.tsx` only. The broader
|
||||
Logging & Alerts section (alerting_settings.tsx, email_events/*) still
|
||||
has files that will be picked up by subsequent runs.
|
||||
|
||||
## 21-b. Email Event Settings
|
||||
|
||||
- Cycles used: 1 / 7
|
||||
- Layer outcomes per cycle:
|
||||
- cycle 1: TS ✓ | Lint ✓ | Vitest n/a | Build ✓ | Parity ⏭ | Snap ⏭
|
||||
- Final status: **done (partial)**
|
||||
- Scope: `src/components/email_events/email_event_settings.tsx` only.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import React from "react";
|
||||
import { Form, Input, InputNumber, Button as Button2 } from "antd";
|
||||
import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline";
|
||||
import { Button, Badge, Icon, Text, TableRow, TableCell, Switch } from "@tremor/react";
|
||||
import React, { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { CheckCircle, Trash2 } from "lucide-react";
|
||||
|
||||
interface AlertingSetting {
|
||||
field_name: string;
|
||||
field_description: string;
|
||||
field_type: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
field_value: any;
|
||||
stored_in_db: boolean | null;
|
||||
premium_field: boolean;
|
||||
|
|
@ -13,12 +18,23 @@ interface AlertingSetting {
|
|||
|
||||
interface DynamicFormProps {
|
||||
alertingSettings: AlertingSetting[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleInputChange: (fieldName: string, newValue: any) => void;
|
||||
handleResetField: (fieldName: string, index: number) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleSubmit: (formValues: Record<string, any>) => void;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerting-settings dynamic form. Settings are rendered row-by-row into an
|
||||
* existing table context (this component emits `<TableRow>` children that
|
||||
* must be placed inside a `<Table>`/`<TableBody>` parent).
|
||||
*
|
||||
* Post phase-1 migration: antd Form replaced with a native form element
|
||||
* driving a local state bag; no rhf/zod here because the field set is
|
||||
* dynamic (driven by `alertingSettings`).
|
||||
*/
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||
alertingSettings,
|
||||
handleInputChange,
|
||||
|
|
@ -26,121 +42,121 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
handleSubmit,
|
||||
premiumUser,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
// Mirror the form state locally so we can submit a consolidated snapshot
|
||||
// without depending on antd's Form.getFieldsValue().
|
||||
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
const onFinish = () => {
|
||||
console.log(`INSIDE ONFINISH`);
|
||||
const formData = form.getFieldsValue();
|
||||
const isEmpty = Object.entries(formData).every(([key, value]) => {
|
||||
if (typeof value === "boolean") {
|
||||
return false; // Boolean values are never considered empty
|
||||
}
|
||||
const updateLocal = (name: string, value: unknown) => {
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isEmpty = Object.entries(formValues).every(([, value]) => {
|
||||
if (typeof value === "boolean") return false;
|
||||
return value === "" || value === null || value === undefined;
|
||||
});
|
||||
console.log(`formData: ${JSON.stringify(formData)}, isEmpty: ${isEmpty}`);
|
||||
if (!isEmpty) {
|
||||
handleSubmit(formData);
|
||||
handleSubmit(formValues);
|
||||
} else {
|
||||
console.log("Some form fields are empty.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} onFinish={onFinish} labelAlign="left">
|
||||
{alertingSettings.map((value, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell align="center">
|
||||
<Text>{value.field_name}</Text>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "#808080",
|
||||
fontStyle: "italic",
|
||||
<form onSubmit={onSubmit}>
|
||||
{alertingSettings.map((value, index) => {
|
||||
const renderInput = () => {
|
||||
if (value.field_type === "Integer") {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
defaultValue={value.field_value ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === "" ? null : Number(e.target.value);
|
||||
handleInputChange(value.field_name, v);
|
||||
updateLocal(value.field_name, v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (value.field_type === "Boolean") {
|
||||
return (
|
||||
<Switch
|
||||
checked={!!value.field_value}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInputChange(value.field_name, checked);
|
||||
updateLocal(value.field_name, checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
defaultValue={value.field_value ?? ""}
|
||||
onChange={(e) => {
|
||||
handleInputChange(value.field_name, e.target.value);
|
||||
updateLocal(value.field_name, e.target.value);
|
||||
}}
|
||||
className="mt-1"
|
||||
>
|
||||
{value.field_description}
|
||||
</p>
|
||||
</TableCell>
|
||||
{value.premium_field ? (
|
||||
premiumUser ? (
|
||||
<Form.Item name={value.field_name}>
|
||||
<TableCell>
|
||||
{value.field_type === "Integer" ? (
|
||||
<InputNumber
|
||||
step={1}
|
||||
value={value.field_value}
|
||||
onChange={(e) => handleInputChange(value.field_name, e)}
|
||||
/>
|
||||
) : value.field_type === "Boolean" ? (
|
||||
<Switch
|
||||
checked={value.field_value}
|
||||
onChange={(checked) => handleInputChange(value.field_name, checked)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={value.field_value} onChange={(e) => handleInputChange(value.field_name, e)} />
|
||||
)}
|
||||
</TableCell>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<TableCell>
|
||||
<Button className="flex items-center justify-center">
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<p>{value.field_name}</p>
|
||||
<p className="text-[0.65rem] text-muted-foreground italic mt-1">
|
||||
{value.field_description}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.premium_field && !premiumUser ? (
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://forms.gle/W3U4PZpJGFHWtHyA9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
✨ Enterprise Feature
|
||||
</a>
|
||||
</Button>
|
||||
</TableCell>
|
||||
)
|
||||
) : (
|
||||
<Form.Item
|
||||
name={value.field_name}
|
||||
className="mb-0"
|
||||
valuePropName={value.field_type === "Boolean" ? "checked" : "value"}
|
||||
>
|
||||
<TableCell>
|
||||
{value.field_type === "Integer" ? (
|
||||
<InputNumber
|
||||
step={1}
|
||||
value={value.field_value}
|
||||
onChange={(e) => handleInputChange(value.field_name, e)}
|
||||
className="p-0"
|
||||
/>
|
||||
) : value.field_type === "Boolean" ? (
|
||||
<Switch
|
||||
checked={value.field_value}
|
||||
onChange={(checked) => {
|
||||
handleInputChange(value.field_name, checked);
|
||||
form.setFieldsValue({ [value.field_name]: checked });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input value={value.field_value} onChange={(e) => handleInputChange(value.field_name, e)} />
|
||||
)}
|
||||
</TableCell>
|
||||
</Form.Item>
|
||||
)}
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
<Badge icon={CheckCircleIcon} className="text-white">
|
||||
In DB
|
||||
</Badge>
|
||||
) : value.stored_in_db == false ? (
|
||||
<Badge className="text-gray bg-white outline">In Config</Badge>
|
||||
) : (
|
||||
<Badge className="text-gray bg-white outline">Not Set</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name, index)}>
|
||||
Reset
|
||||
</Icon>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
) : (
|
||||
renderInput()
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db === true ? (
|
||||
<Badge className="gap-1">
|
||||
<CheckCircle size={12} />
|
||||
In DB
|
||||
</Badge>
|
||||
) : value.stored_in_db === false ? (
|
||||
<Badge variant="outline">In Config</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not Set</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleResetField(value.field_name, index)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Reset
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button2 htmlType="submit">Update Settings</Button2>
|
||||
<Button type="submit">Update Settings</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,45 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Text, Button } from "@tremor/react";
|
||||
import { Typography, Divider, Spin, Checkbox } from "antd";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } from "../networking";
|
||||
import {
|
||||
getEmailEventSettings,
|
||||
updateEmailEventSettings,
|
||||
resetEmailEventSettings,
|
||||
} from "../networking";
|
||||
import { EmailEvent } from "../../types";
|
||||
import { EmailEventSetting } from "./types";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
interface EmailEventSettingsProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken }) => {
|
||||
const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({
|
||||
accessToken,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [eventSettings, setEventSettings] = useState<EmailEventSetting[]>([]);
|
||||
|
||||
// Fetch email event settings on component mount
|
||||
useEffect(() => {
|
||||
const fetchEventSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getEmailEventSettings(accessToken);
|
||||
setEventSettings(response.settings);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch email event settings:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchEventSettings();
|
||||
}, [accessToken]);
|
||||
|
||||
const fetchEventSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getEmailEventSettings(accessToken);
|
||||
setEventSettings(response.settings);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch email event settings:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (event: EmailEvent, checked: boolean) => {
|
||||
const updatedSettings = eventSettings.map((setting) =>
|
||||
setting.event === event ? { ...setting, enabled: checked } : setting,
|
||||
|
|
@ -45,7 +49,6 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
const handleSaveSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
await updateEmailEventSettings(accessToken, { settings: eventSettings });
|
||||
NotificationsManager.success("Email event settings updated successfully");
|
||||
|
|
@ -57,27 +60,26 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
const handleResetSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
await resetEmailEventSettings(accessToken);
|
||||
NotificationsManager.success("Email event settings reset to defaults");
|
||||
// Refresh settings after reset
|
||||
fetchEventSettings();
|
||||
// Refresh
|
||||
setLoading(true);
|
||||
const response = await getEmailEventSettings(accessToken);
|
||||
setEventSettings(response.settings);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset email event settings:", error);
|
||||
NotificationsManager.fromBackend(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get a description for each event type
|
||||
const getEventDescription = (event: EmailEvent): string => {
|
||||
// Convert event name to a sentence with more context
|
||||
if (event.includes("Virtual Key Created")) {
|
||||
return "An email will be sent to the user when a new virtual key is created with their user ID";
|
||||
} else if (event.includes("New User Invitation")) {
|
||||
return "An email will be sent to the email address of the user when a new user is created";
|
||||
} else {
|
||||
// Handle any other event type from the API
|
||||
const words = event
|
||||
.split(/(?=[A-Z])/)
|
||||
.join(" ")
|
||||
|
|
@ -87,37 +89,54 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>Email Notifications</Title>
|
||||
<Text>Select which events should trigger email notifications.</Text>
|
||||
<Divider />
|
||||
<Card className="p-6">
|
||||
<h4 className="text-lg font-semibold m-0">Email Notifications</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select which events should trigger email notifications.
|
||||
</p>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<Spin size="large" />
|
||||
<div className="py-5 flex justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{eventSettings.map((setting) => (
|
||||
<div key={setting.event} className="flex items-center">
|
||||
<div key={setting.event} className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={`email-event-${setting.event}`}
|
||||
checked={setting.enabled}
|
||||
onChange={(e) => handleCheckboxChange(setting.event, e.target.checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange(setting.event, !!checked)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<Text>{setting.event}</Text>
|
||||
<div className="text-sm text-gray-500 block">{getEventDescription(setting.event)}</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`email-event-${setting.event}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{setting.event}
|
||||
</label>
|
||||
<div className="text-sm text-muted-foreground block">
|
||||
{getEventDescription(setting.event)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex space-x-4">
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button onClick={handleSaveSettings} disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button onClick={handleResetSettings} variant="secondary" disabled={loading}>
|
||||
<Button
|
||||
onClick={handleResetSettings}
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue