feat(ui): migrate CloudZero cost-tracking to shadcn

- CloudZeroCostTracking: antd Card/Typography \u2192 shadcn Card.
- CloudZeroCreateModal: antd Form/Modal/Input/Input.Password \u2192 shadcn
  Dialog/Input/Label + react-hook-form. Adds a show/hide-password toggle
  using lucide Eye/EyeOff (replaces antd Input.Password's built-in toggle).
- CloudZeroEmptyPlaceholder: antd Empty/Typography/Button \u2192 plain Tailwind
  empty-state with shadcn Button + lucide Inbox icon.
- CloudZeroIntegrationSettings: antd Alert/Card/Descriptions/Divider/
  Popconfirm/Tag \u2192 shadcn Card + Badge + Separator + Alert + AlertDialog
  (for the dry-run / export confirmation flow). The descriptions list is
  rebuilt as a plain <dl> grid with bg-muted dt headers.
- CloudZeroUpdateModal: same Dialog + react-hook-form + Eye/EyeOff
  treatment as the create modal. Connection_id null-coalesces to '' when
  resetting the form.

Tests updated to match required-field label regex.

Gates: TS \u2713 | Lint \u2713 | Vitest 6/6 \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 07:48:54 +00:00
parent 43524c1f62
commit ad60a746f8
No known key found for this signature in database
8 changed files with 480 additions and 288 deletions

View file

@ -89,6 +89,17 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
Tests updated to use semantic queries (button disabled state) and to match
the rhf submit shape (object containing tag_name).
## 19. Cost Tracking — CloudZero (cost-tracking, cloudzero subset)
- Cycles used: 1 / 7
- Layer outcomes per cycle:
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (6/6) | Build ✓ | Parity ⏭ | Snap ⏭
- Final status: **done (cloudzero subset)**
- Scope: CloudZeroCostTracking, CloudZeroCreateModal, CloudZeroEmptyPlaceholder,
CloudZeroIntegrationSettings, CloudZeroUpdateModal. The broader cost-tracking
section (CostTrackingSettings/*) is pending in a follow-up commit.
- Tests updated for required-field label regex matching.
## 24. Guardrails Monitor (guardrails-monitor)
- Cycles used: 1 / 7

View file

@ -1,6 +1,6 @@
import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Card, Typography } from "antd";
import { Card } from "@/components/ui/card";
import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder";
import { useState } from "react";
import CloudZeroCreationModal from "./CloudZeroCreateModal";
@ -10,7 +10,11 @@ import { CloudZeroIntegrationSettings } from "./CloudZeroIntegrationSettings";
export default function CloudZeroCostTracking() {
const { accessToken } = useAuthorized();
const { data: settings, isLoading, error } = useCloudZeroSettings(accessToken);
const {
data: settings,
isLoading,
error,
} = useCloudZeroSettings(accessToken);
const queryClient = useQueryClient();
const cloudZeroSettingsKeys = createQueryKeys("cloudZeroSettings");
@ -18,27 +22,28 @@ export default function CloudZeroCostTracking() {
const handleCreateModalOk = async () => {
setIsCreateModalOpen(false);
await queryClient.invalidateQueries({ queryKey: cloudZeroSettingsKeys.list({}) });
await queryClient.invalidateQueries({
queryKey: cloudZeroSettingsKeys.list({}),
});
};
const handleCreateModalCancel = () => {
setIsCreateModalOpen(false);
};
const handleCreateModalCancel = () => setIsCreateModalOpen(false);
if (isLoading) {
return (
<Card>
<Typography.Text>Loading CloudZero settings...</Typography.Text>
<Card className="p-6">
<span className="text-sm">Loading CloudZero settings...</span>
</Card>
);
}
if (error) {
return (
<Card>
<Typography.Text className="text-red-600">
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
</Typography.Text>
<Card className="p-6">
<span className="text-sm text-destructive">
Error loading CloudZero settings:{" "}
{error instanceof Error ? error.message : String(error)}
</span>
</Card>
);
}
@ -46,7 +51,9 @@ export default function CloudZeroCostTracking() {
if (!settings) {
return (
<>
<CloudZeroEmptyPlaceholder startCreation={() => setIsCreateModalOpen(true)} />
<CloudZeroEmptyPlaceholder
startCreation={() => setIsCreateModalOpen(true)}
/>
<CloudZeroCreationModal
open={isCreateModalOpen}
onOk={handleCreateModalOk}
@ -57,8 +64,9 @@ export default function CloudZeroCostTracking() {
}
return (
<>
<CloudZeroIntegrationSettings settings={settings} onSettingsUpdated={handleCreateModalOk} />
</>
<CloudZeroIntegrationSettings
settings={settings}
onSettingsUpdated={handleCreateModalOk}
/>
);
}

View file

@ -48,8 +48,9 @@ describe("CloudZeroCreateModal", () => {
);
expect(screen.getByText("Create CloudZero Integration")).toBeInTheDocument();
expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument();
expect(screen.getByLabelText("Connection ID")).toBeInTheDocument();
// Required-field labels include a required indicator post phase-1.
expect(screen.getByLabelText(/CloudZero API Key/)).toBeInTheDocument();
expect(screen.getByLabelText(/Connection ID/)).toBeInTheDocument();
expect(screen.getByLabelText("Timezone")).toBeInTheDocument();
});
});

View file

@ -1,8 +1,21 @@
import { Form, Modal, Input } from "antd";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MessageManager from "@/components/molecules/message_manager";
import { useEffect } from "react";
import { Eye, EyeOff } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate";
import { FormProvider, useForm } from "react-hook-form";
import { useState } from "react";
interface CloudZeroCreationModalProps {
open: boolean;
@ -10,92 +23,155 @@ interface CloudZeroCreationModalProps {
onCancel: () => void;
}
export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) {
interface CreateValues {
api_key: string;
connection_id: string;
timezone: string;
}
const defaultValues: CreateValues = {
api_key: "",
connection_id: "",
timezone: "",
};
export default function CloudZeroCreationModal({
open,
onOk,
onCancel,
}: CloudZeroCreationModalProps) {
const { accessToken } = useAuthorized();
const [form] = Form.useForm();
const form = useForm<CreateValues>({
defaultValues,
mode: "onSubmit",
});
const createMutation = useCloudZeroCreate(accessToken || "");
const [showApiKey, setShowApiKey] = useState(false);
useEffect(() => {
if (open) {
form.resetFields();
}
}, [open, form]);
if (open) form.reset(defaultValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
createMutation.mutate(
{
connection_id: values.connection_id,
timezone: values.timezone || "UTC",
...(values.api_key && { api_key: values.api_key }),
const handleSubmit = form.handleSubmit((values) => {
createMutation.mutate(
{
connection_id: values.connection_id,
timezone: values.timezone || "UTC",
...(values.api_key && { api_key: values.api_key }),
},
{
onSuccess: () => {
MessageManager.success(
"CloudZero integration created successfully",
);
form.reset(defaultValues);
onOk();
},
{
onSuccess: () => {
MessageManager.success("CloudZero integration created successfully");
form.resetFields();
onOk();
},
onError: (error: any) => {
if (error?.errorFields) {
return;
}
MessageManager.error(error?.message || "Failed to create CloudZero integration");
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
MessageManager.error(
error?.message || "Failed to create CloudZero integration",
);
},
);
} catch (error: any) {
if (error?.errorFields) {
return;
}
MessageManager.error(error?.message || "Failed to create CloudZero integration");
}
};
},
);
});
const handleCancel = () => {
form.resetFields();
form.reset(defaultValues);
onCancel();
};
return (
<Modal
title="Create CloudZero Integration"
<Dialog
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={createMutation.isPending}
okText={createMutation.isPending ? "Creating..." : "Create"}
cancelText="Cancel"
okButtonProps={{
disabled: createMutation.isPending,
}}
cancelButtonProps={{
disabled: createMutation.isPending,
}}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="CloudZero API Key"
name="api_key"
rules={[{ required: true, message: "Please enter your CloudZero API key" }]}
>
<Input.Password placeholder="Enter your CloudZero API key" />
</Form.Item>
<Form.Item
label="Connection ID"
name="connection_id"
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
>
<Input placeholder="Enter your CloudZero connection ID" />
</Form.Item>
<Form.Item
label="Timezone"
name="timezone"
tooltip="Timezone for date handling (defaults to UTC if not provided)"
>
<Input placeholder="UTC" />
</Form.Item>
</Form>
</Modal>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create CloudZero Integration</DialogTitle>
<DialogDescription className="sr-only">
Configure a new CloudZero billing integration.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api_key">
CloudZero API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="api_key"
type={showApiKey ? "text" : "password"}
placeholder="Enter your CloudZero API key"
{...form.register("api_key", {
required: "Please enter your CloudZero API key",
})}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-label={showApiKey ? "Hide api key" : "Show api key"}
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{form.formState.errors.api_key && (
<p className="text-sm text-destructive">
{form.formState.errors.api_key.message as string}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="connection_id">
Connection ID <span className="text-destructive">*</span>
</Label>
<Input
id="connection_id"
placeholder="Enter your CloudZero connection ID"
{...form.register("connection_id", {
required: "Please enter your CloudZero connection ID",
})}
/>
{form.formState.errors.connection_id && (
<p className="text-sm text-destructive">
{form.formState.errors.connection_id.message as string}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input
id="timezone"
placeholder="UTC"
{...form.register("timezone")}
/>
<p className="text-xs text-muted-foreground">
Timezone for date handling (defaults to UTC if not provided)
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
}

View file

@ -1,29 +1,28 @@
import { Empty, Typography, Button } from "antd";
const { Title, Paragraph } = Typography;
import { Button } from "@/components/ui/button";
import { Inbox } from "lucide-react";
interface CloudZeroEmptyPlaceholderProps {
startCreation: () => void;
}
export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) {
export default function CloudZeroEmptyPlaceholder({
startCreation,
}: CloudZeroEmptyPlaceholderProps) {
return (
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center max-w-2xl mx-auto mt-8">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div className="space-y-2">
<Title level={4}>No CloudZero Integration Found</Title>
<Paragraph type="secondary" className="max-w-md mx-auto">
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
</Paragraph>
</div>
}
>
<Button type="primary" size="large" onClick={startCreation} className="flex items-center gap-2 mx-auto mt-4">
<div className="bg-background p-12 rounded-lg border border-dashed border-border text-center max-w-2xl mx-auto mt-8">
<div className="flex flex-col items-center gap-3">
<Inbox className="h-12 w-12 text-muted-foreground" />
<h4 className="text-lg font-semibold">
No CloudZero Integration Found
</h4>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Connect your CloudZero account to start tracking and analyzing your
cloud costs directly from LiteLLM.
</p>
<Button onClick={startCreation} className="mt-4">
Add CloudZero Integration
</Button>
</Empty>
</div>
</div>
);
}

View file

@ -3,7 +3,22 @@ import { useCloudZeroExport } from "@/app/(dashboard)/hooks/cloudzero/useCloudZe
import { useCloudZeroDeleteSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { Alert, Button, Card, Descriptions, Divider, Popconfirm, Tag } from "antd";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import MessageManager from "@/components/molecules/message_manager";
import { CheckCircle, Edit, Play, Trash2, Upload } from "lucide-react";
import { useState } from "react";
@ -15,7 +30,10 @@ interface CloudZeroIntegrationSettingsProps {
onSettingsUpdated: () => void;
}
export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) {
export function CloudZeroIntegrationSettings({
settings,
onSettingsUpdated,
}: CloudZeroIntegrationSettingsProps) {
const { accessToken } = useAuthorized();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -26,11 +44,10 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
const handleDryRun = () => {
if (!accessToken) return;
dryRunMutation.mutate(
{ limit: 10 },
{
onSuccess: (data) => {
onSuccess: () => {
MessageManager.success("Dry run completed successfully");
},
onError: (error) => {
@ -40,11 +57,12 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
);
};
const dryRunResult = dryRunMutation.data ? JSON.stringify(dryRunMutation.data, null, 2) : null;
const dryRunResult = dryRunMutation.data
? JSON.stringify(dryRunMutation.data, null, 2)
: null;
const handleExport = () => {
if (!accessToken) return;
exportMutation.mutate(
{ operation: "replace_hourly" },
{
@ -58,26 +76,13 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
);
};
const handleEdit = () => {
setIsEditModalOpen(true);
};
const handleEditModalOk = async () => {
setIsEditModalOpen(false);
onSettingsUpdated();
};
const handleEditModalCancel = () => {
setIsEditModalOpen(false);
};
const handleDeleteClick = () => {
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = () => {
if (!accessToken) return;
deleteMutation.mutate(undefined, {
onSuccess: () => {
MessageManager.success("CloudZero integration deleted successfully");
@ -85,118 +90,143 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
onSettingsUpdated();
},
onError: (error) => {
MessageManager.error(error?.message || "Failed to delete CloudZero integration");
MessageManager.error(
error?.message || "Failed to delete CloudZero integration",
);
},
});
};
const handleDeleteCancel = () => {
setIsDeleteModalOpen(false);
};
return (
<>
<div className="space-y-6 w-full max-w-4xl mx-auto">
<Card
title={
<Card className="shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">CloudZero Configuration</span>
<Tag color="success" className="ml-2 capitalize">
<span className="text-lg font-semibold">
CloudZero Configuration
</span>
<Badge variant="default" className="capitalize">
{settings.status || "Active"}
</Tag>
</Badge>
</div>
}
extra={
<div className="flex gap-2">
<Button icon={<Edit size={16} />} onClick={handleEdit} className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsEditModalOpen(true)}
>
<Edit size={16} />
Edit
</Button>
<Button
danger
icon={<Trash2 size={16} />}
onClick={handleDeleteClick}
className="flex items-center gap-2"
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
>
<Trash2 size={16} />
Delete
</Button>
</div>
}
className="shadow-sm"
>
<Descriptions
bordered
column={{
xxl: 1,
xl: 1,
lg: 1,
md: 1,
sm: 1,
xs: 1,
}}
>
<Descriptions.Item label="API Key (Redacted)">
<span className="font-mono text-gray-600">
{settings.api_key_masked || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Connection ID">
<span className="font-mono text-gray-600">
{settings.connection_id || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Timezone">
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}
</Descriptions.Item>
</Descriptions>
</div>
<Divider orientation="left" className="text-gray-500">
<dl className="border border-border rounded-md overflow-hidden text-sm">
<div className="grid grid-cols-[200px_1fr] border-b border-border">
<dt className="bg-muted px-4 py-3 font-medium">
API Key (Redacted)
</dt>
<dd className="px-4 py-3">
<span className="font-mono text-muted-foreground">
{settings.api_key_masked || (
<span className="text-muted-foreground italic">
Not configured
</span>
)}
</span>
</dd>
</div>
<div className="grid grid-cols-[200px_1fr] border-b border-border">
<dt className="bg-muted px-4 py-3 font-medium">Connection ID</dt>
<dd className="px-4 py-3">
<span className="font-mono text-muted-foreground">
{settings.connection_id || (
<span className="text-muted-foreground italic">
Not configured
</span>
)}
</span>
</dd>
</div>
<div className="grid grid-cols-[200px_1fr]">
<dt className="bg-muted px-4 py-3 font-medium">Timezone</dt>
<dd className="px-4 py-3">
{settings.timezone || (
<span className="text-muted-foreground italic">
Default (UTC)
</span>
)}
</dd>
</div>
</dl>
<Separator className="my-6" />
<h4 className="text-sm font-medium text-muted-foreground mb-3">
Actions
</Divider>
</h4>
<div className="flex flex-wrap gap-4 mb-6">
<Button
variant="outline"
onClick={handleDryRun}
loading={dryRunMutation.isPending}
icon={<Play size={16} />}
className="flex items-center gap-2"
disabled={dryRunMutation.isPending}
>
Run Dry Run Simulation
<Play size={16} />
{dryRunMutation.isPending
? "Running…"
: "Run Dry Run Simulation"}
</Button>
<Popconfirm
title="Export Data to CloudZero"
description="This will push the current accumulated cost data to CloudZero. Continue?"
onConfirm={handleExport}
okText="Export"
cancelText="Cancel"
>
<Button
type="primary"
loading={exportMutation.isPending}
icon={<Upload size={16} />}
className="flex items-center gap-2"
>
Export Data Now
</Button>
</Popconfirm>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={exportMutation.isPending}>
<Upload size={16} />
{exportMutation.isPending
? "Exporting…"
: "Export Data Now"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Export Data to CloudZero
</AlertDialogTitle>
<AlertDialogDescription>
This will push the current accumulated cost data to
CloudZero. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleExport}>
Export
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{dryRunResult && (
<div className="mt-6 animate-in fade-in slide-in-from-top-4 duration-300">
<Alert
message="Dry Run Results"
description={
<div className="mt-2">
<p className="mb-2 text-gray-600">Simulation output for connection: {settings.connection_id}</p>
<pre className="bg-gray-50 p-4 rounded-md border border-gray-200 overflow-x-auto text-xs font-mono text-gray-800">
{dryRunResult}
</pre>
</div>
}
type="info"
showIcon
icon={<CheckCircle className="text-blue-500" />}
/>
<div className="mt-6">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertTitle>Dry Run Results</AlertTitle>
<AlertDescription>
<p className="mb-2 text-muted-foreground">
Simulation output for connection: {settings.connection_id}
</p>
<pre className="bg-muted p-4 rounded-md border border-border overflow-x-auto text-xs font-mono text-foreground">
{dryRunResult}
</pre>
</AlertDescription>
</Alert>
</div>
)}
</Card>
@ -205,7 +235,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
<CloudZeroUpdateModal
open={isEditModalOpen}
onOk={handleEditModalOk}
onCancel={handleEditModalCancel}
onCancel={() => setIsEditModalOpen(false)}
settings={settings}
/>
@ -225,7 +255,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
value: settings.timezone || "Default (UTC)",
},
]}
onCancel={handleDeleteCancel}
onCancel={() => setIsDeleteModalOpen(false)}
onOk={handleDeleteConfirm}
confirmLoading={deleteMutation.isPending}
/>

View file

@ -56,7 +56,9 @@ describe("CloudZeroUpdateModal", () => {
expect(screen.getByText("Edit CloudZero Integration")).toBeInTheDocument();
expect(screen.getByLabelText("CloudZero API Key")).toBeInTheDocument();
expect(screen.getByLabelText("Connection ID")).toBeInTheDocument();
// Connection ID label includes a required indicator post phase-1; match
// by partial text.
expect(screen.getByLabelText(/Connection ID/)).toBeInTheDocument();
expect(screen.getByLabelText("Timezone")).toBeInTheDocument();
});
});

View file

@ -1,9 +1,21 @@
import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Form, Input, Modal } from "antd";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MessageManager from "@/components/molecules/message_manager";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { CloudZeroSettings } from "./types";
import { FormProvider, useForm } from "react-hook-form";
interface CloudZeroUpdateModalProps {
open: boolean;
@ -12,99 +24,152 @@ interface CloudZeroUpdateModalProps {
settings: CloudZeroSettings;
}
export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) {
interface UpdateValues {
api_key: string;
connection_id: string;
timezone: string;
}
export default function CloudZeroUpdateModal({
open,
onOk,
onCancel,
settings,
}: CloudZeroUpdateModalProps) {
const { accessToken } = useAuthorized();
const [form] = Form.useForm();
const updateMutation = useCloudZeroUpdateSettings(accessToken || "");
const [showApiKey, setShowApiKey] = useState(false);
const form = useForm<UpdateValues>({
defaultValues: { api_key: "", connection_id: "", timezone: "" },
mode: "onSubmit",
});
useEffect(() => {
if (open && settings) {
form.setFieldsValue({
connection_id: settings.connection_id,
form.reset({
connection_id: settings.connection_id ?? "",
timezone: settings.timezone || "UTC",
api_key: "",
});
} else if (open) {
form.resetFields();
form.reset({ api_key: "", connection_id: "", timezone: "" });
}
}, [open, settings, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, settings]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
updateMutation.mutate(
{
connection_id: values.connection_id,
timezone: values.timezone || "UTC",
...(values.api_key && { api_key: values.api_key }),
const handleSubmit = form.handleSubmit((values) => {
updateMutation.mutate(
{
connection_id: values.connection_id,
timezone: values.timezone || "UTC",
...(values.api_key && { api_key: values.api_key }),
},
{
onSuccess: () => {
MessageManager.success(
"CloudZero integration updated successfully",
);
form.reset({ api_key: "", connection_id: "", timezone: "" });
onOk();
},
{
onSuccess: () => {
MessageManager.success("CloudZero integration updated successfully");
form.resetFields();
onOk();
},
onError: (error: any) => {
if (error?.errorFields) {
return;
}
MessageManager.error(error?.message || "Failed to update CloudZero integration");
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
MessageManager.error(
error?.message || "Failed to update CloudZero integration",
);
},
);
} catch (error: any) {
if (error?.errorFields) {
return;
}
MessageManager.error(error?.message || "Failed to update CloudZero integration");
}
};
},
);
});
const handleCancel = () => {
form.resetFields();
form.reset({ api_key: "", connection_id: "", timezone: "" });
onCancel();
};
return (
<Modal
title="Edit CloudZero Integration"
<Dialog
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={updateMutation.isPending}
okText={updateMutation.isPending ? "Updating..." : "Update"}
cancelText="Cancel"
okButtonProps={{
disabled: updateMutation.isPending,
}}
cancelButtonProps={{
disabled: updateMutation.isPending,
}}
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="CloudZero API Key"
name="api_key"
rules={[{ required: false, message: "Please enter your CloudZero API key" }]}
tooltip="Leave empty to keep the existing API key"
>
<Input.Password placeholder="Leave empty to keep existing" />
</Form.Item>
<Form.Item
label="Connection ID"
name="connection_id"
rules={[{ required: true, message: "Please enter your CloudZero connection ID" }]}
>
<Input placeholder="Enter your CloudZero connection ID" />
</Form.Item>
<Form.Item
label="Timezone"
name="timezone"
tooltip="Timezone for date handling (defaults to UTC if not provided)"
>
<Input placeholder="UTC" />
</Form.Item>
</Form>
</Modal>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit CloudZero Integration</DialogTitle>
<DialogDescription className="sr-only">
Update the CloudZero billing integration settings.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api_key">CloudZero API Key</Label>
<div className="relative">
<Input
id="api_key"
type={showApiKey ? "text" : "password"}
placeholder="Leave empty to keep existing"
{...form.register("api_key")}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-label={showApiKey ? "Hide api key" : "Show api key"}
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
Leave empty to keep the existing API key
</p>
</div>
<div className="space-y-2">
<Label htmlFor="connection_id">
Connection ID <span className="text-destructive">*</span>
</Label>
<Input
id="connection_id"
placeholder="Enter your CloudZero connection ID"
{...form.register("connection_id", {
required: "Please enter your CloudZero connection ID",
})}
/>
{form.formState.errors.connection_id && (
<p className="text-sm text-destructive">
{form.formState.errors.connection_id.message as string}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Input
id="timezone"
placeholder="UTC"
{...form.register("timezone")}
/>
<p className="text-xs text-muted-foreground">
Timezone for date handling (defaults to UTC if not provided)
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={updateMutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Updating…" : "Update"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
}