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:
Cursor Agent 2026-04-23 07:19:05 +00:00
parent 0bff3df3cc
commit 7cd5643db1
No known key found for this signature in database
3 changed files with 198 additions and 145 deletions

View file

@ -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.

View file

@ -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>
);
};

View file

@ -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>