Enhance chart customization options by adding color selection, grid display, tooltip display, animation, and value formatting.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/52091063-0956-4712-acd3-7bb273cc6f8d.jpg
This commit is contained in:
alphaeusmote
2025-04-10 00:38:59 +00:00
parent 2c4733cbf0
commit 46487e7855
3 changed files with 567 additions and 500 deletions
@@ -1,24 +1,23 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { import { useToast } from "@/hooks/use-toast";
Dialog, import {
DialogContent, Dialog,
DialogHeader, DialogContent,
DialogTitle, DialogDescription,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ColorPicker } from "@/components/ui/color-picker"; import { ColorPicker } from "@/components/ui/color-picker";
import { CopyIcon, Share2, CheckIcon, Download } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { DashboardConfig } from "./dashboard-layout"; import { DashboardConfig } from "./dashboard-layout";
import { generateSnapshot } from "@/lib/snapshot-utils"; import { generateSnapshot, SnapshotBranding, SnapshotData } from "@/lib/snapshot-utils";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface ShareDashboardDialogProps { interface ShareDashboardDialogProps {
open: boolean; open: boolean;
@@ -32,149 +31,84 @@ interface ShareDashboardDialogProps {
}>; }>;
} }
export function ShareDashboardDialog({ export function ShareDashboardDialog({
open, open,
onOpenChange, onOpenChange,
dashboard, dashboard,
dataSources dataSources,
}: ShareDashboardDialogProps) { }: ShareDashboardDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const [shareUrl, setShareUrl] = useState(""); const [activeTab, setActiveTab] = useState<string>("branding");
const [copied, setCopied] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [logoUrl, setLogoUrl] = useState("");
const [companyName, setCompanyName] = useState("");
const [customTitle, setCustomTitle] = useState(dashboard.name);
const [description, setDescription] = useState("");
const [primaryColor, setPrimaryColor] = useState("#0f172a");
const [secondaryColor, setSecondaryColor] = useState("#6366f1");
const [showDate, setShowDate] = useState(true);
const [showFooter, setShowFooter] = useState(true);
const [footerText, setFooterText] = useState("Generated with Active Directory Management Platform");
const [expiryDays, setExpiryDays] = useState(7);
const [includeData, setIncludeData] = useState(true);
const [isSharing, setIsSharing] = useState(false);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [snapshotUrl, setSnapshotUrl] = useState<string | null>(null);
const [branding, setBranding] = useState<SnapshotBranding>({
logoUrl: "",
companyName: "",
title: dashboard.name || "Dashboard Snapshot",
description: "Dashboard snapshot generated from Active Directory Management",
primaryColor: "#3f51b5",
secondaryColor: "#f5f5f5",
showDate: true,
showFooter: true,
footerText: "© " + new Date().getFullYear() + " - Generated by Active Directory Management",
expiresInDays: 30,
});
// When user uploads a logo // Reset the snapshot URL when the dialog is opened
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { useEffect(() => {
const file = e.target.files?.[0]; if (open) {
if (file) { setSnapshotUrl(null);
const reader = new FileReader(); setActiveTab("branding");
reader.onloadend = () => {
setLogoUrl(reader.result as string);
};
reader.readAsDataURL(file);
} }
}; }, [open]);
// Generate a sharable URL with snapshot data // Update title when dashboard name changes
const handleGenerateShareLink = async () => { useEffect(() => {
setIsSharing(true); if (dashboard.name) {
try { setBranding(prev => ({ ...prev, title: dashboard.name }));
const snapshotData = generateSnapshot({
dashboard,
dataSources: includeData ? dataSources : [],
branding: {
logoUrl,
companyName,
title: customTitle,
description,
primaryColor,
secondaryColor,
showDate,
showFooter,
footerText,
expiresInDays: expiryDays
}
});
// In a real app, this would make an API call to store the snapshot
// For now, we're simulating it with localStorage
const snapshotId = `dash_${Date.now().toString(36)}`;
localStorage.setItem(`snapshot_${snapshotId}`, JSON.stringify(snapshotData));
// Generate share URL
const shareUrl = `${window.location.origin}/dashboards/shared/${snapshotId}`;
setShareUrl(shareUrl);
toast({
title: "Share link generated",
description: "The share link for this dashboard has been created.",
});
} catch (error) {
console.error("Error generating share link:", error);
toast({
title: "Error",
description: "Failed to generate share link. Please try again.",
variant: "destructive",
});
} finally {
setIsSharing(false);
} }
}, [dashboard.name]);
const handleBrandingChange = (key: keyof SnapshotBranding, value: any) => {
setBranding((prev) => ({
...prev,
[key]: value,
}));
}; };
// Copy the URL to clipboard const generateDashboardSnapshot = async () => {
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
toast({
title: "Copied!",
description: "Share link copied to clipboard.",
});
setTimeout(() => {
setCopied(false);
}, 2000);
};
// Generate a PDF or image snapshot for download
const handleDownloadSnapshot = async () => {
setIsGenerating(true);
try { try {
const snapshotData = generateSnapshot({ setIsGenerating(true);
// Here you would normally make an API call to store the snapshot
// For now, we'll just simulate it
const snapshotData: SnapshotData = generateSnapshot({
dashboard, dashboard,
dataSources: includeData ? dataSources : [], dataSources,
branding: { branding,
logoUrl,
companyName,
title: customTitle,
description,
primaryColor,
secondaryColor,
showDate,
showFooter,
footerText,
expiresInDays: 0 // Download doesn't expire
}
}); });
// In a real implementation, we would generate a PDF or image here // Mock API call - in production this would save to server and return a URL
// For now, we'll just download the JSON data await new Promise((resolve) => setTimeout(resolve, 1500));
const dataStr = JSON.stringify(snapshotData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const a = document.createElement('a'); // Simulate a returned URL
a.href = url; const mockSnapshotId = `snapshot-${Date.now().toString(36)}`;
a.download = `${dashboard.name.replace(/\s+/g, '_')}_snapshot.json`; const url = `${window.location.origin}/snapshot/${mockSnapshotId}`;
document.body.appendChild(a);
a.click();
// Clean up setSnapshotUrl(url);
setTimeout(() => { setActiveTab("share");
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
toast({ toast({
title: "Snapshot downloaded", title: "Snapshot Generated",
description: "Your dashboard snapshot has been downloaded.", description: "Your dashboard snapshot has been created successfully.",
}); });
} catch (error) { } catch (error) {
console.error("Error generating snapshot:", error); console.error("Error generating snapshot:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to generate snapshot. Please try again.", description: "Failed to generate dashboard snapshot. Please try again.",
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@@ -182,238 +116,218 @@ export function ShareDashboardDialog({
} }
}; };
const copyToClipboard = async () => {
if (!snapshotUrl) return;
try {
await navigator.clipboard.writeText(snapshotUrl);
toast({
title: "Copied to Clipboard",
description: "Snapshot URL has been copied to your clipboard.",
});
} catch (error) {
console.error("Error copying to clipboard:", error);
toast({
title: "Error",
description: "Failed to copy URL to clipboard. Please try manually.",
variant: "destructive",
});
}
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Share Dashboard: {dashboard.name}</DialogTitle> <DialogTitle>Share Dashboard: {dashboard.name}</DialogTitle>
<DialogDescription> <DialogDescription>
Create a shareable snapshot of this dashboard with custom branding. Create a shareable snapshot of your dashboard with custom branding.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="link" className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="link">Share Link</TabsTrigger> <TabsTrigger value="branding">Branding</TabsTrigger>
<TabsTrigger value="branding">Branding Options</TabsTrigger> <TabsTrigger value="share" disabled={!snapshotUrl}>Share</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="link" className="space-y-4 mt-4"> {/* Branding Tab */}
<div className="space-y-4"> <TabsContent value="branding" className="space-y-4 py-4">
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Label>Include data in snapshot</Label> <div className="space-y-4">
<div className="flex items-center space-x-2"> <div className="space-y-2">
<Switch <Label htmlFor="companyName">Company Name</Label>
checked={includeData} <Input
onCheckedChange={setIncludeData} id="companyName"
id="include-data" value={branding.companyName}
onChange={(e) => handleBrandingChange("companyName", e.target.value)}
placeholder="Enter your company name"
/> />
<Label htmlFor="include-data">
{includeData
? "Data included (snapshot works offline)"
: "Live data only (requires access to API)"}
</Label>
</div> </div>
</div>
<div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="logoUrl">Logo URL</Label>
<Label>Snapshot expiry</Label> <Input
<div className="flex flex-col space-y-2"> id="logoUrl"
<div className="flex items-center justify-between"> value={branding.logoUrl}
<span>Expires after</span> onChange={(e) => handleBrandingChange("logoUrl", e.target.value)}
<span className="font-medium"> placeholder="https://example.com/logo.png"
{expiryDays === 0 ? "Never expires" : `${expiryDays} days`} />
</span> </div>
</div>
<Slider <div className="space-y-2">
value={[expiryDays]} <Label htmlFor="title">Dashboard Title</Label>
min={0} <Input
max={30} id="title"
step={1} value={branding.title}
onValueChange={(value) => setExpiryDays(value[0])} onChange={(e) => handleBrandingChange("title", e.target.value)}
placeholder="Dashboard Title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={branding.description}
onChange={(e) => handleBrandingChange("description", e.target.value)}
placeholder="Brief description of this dashboard"
rows={3}
/> />
</div> </div>
</div> </div>
{!shareUrl ? ( <div className="space-y-4">
<Button <div className="space-y-2">
className="w-full" <Label>Primary Color</Label>
onClick={handleGenerateShareLink} <div className="flex items-center gap-2">
disabled={isSharing} <ColorPicker
> value={branding.primaryColor}
{isSharing ? "Generating..." : "Generate Share Link"} onChange={(color) => handleBrandingChange("primaryColor", color)}
</Button> />
) : ( <Input
<> value={branding.primaryColor}
<div className="flex mt-4 space-x-2"> onChange={(e) => handleBrandingChange("primaryColor", e.target.value)}
<Input
value={shareUrl}
readOnly
className="flex-1" className="flex-1"
/> />
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
className="flex-shrink-0"
>
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</Button>
</div> </div>
</div>
<div className="flex flex-col sm:flex-row gap-2 mt-4"> <div className="space-y-2">
<Button <Label>Secondary Color</Label>
variant="outline" <div className="flex items-center gap-2">
<ColorPicker
value={branding.secondaryColor}
onChange={(color) => handleBrandingChange("secondaryColor", color)}
/>
<Input
value={branding.secondaryColor}
onChange={(e) => handleBrandingChange("secondaryColor", e.target.value)}
className="flex-1" className="flex-1"
onClick={handleDownloadSnapshot} />
disabled={isGenerating}
>
<Download className="h-4 w-4 mr-2" />
{isGenerating ? "Generating..." : "Download Snapshot"}
</Button>
<Button
variant="default"
className="flex-1"
onClick={handleGenerateShareLink}
disabled={isSharing}
>
<Share2 className="h-4 w-4 mr-2" />
{isSharing ? "Regenerating..." : "Regenerate Link"}
</Button>
</div> </div>
</> </div>
)}
</div> <div className="space-y-2">
</TabsContent> <Label htmlFor="expiresInDays">Expires In</Label>
<Select
<TabsContent value="branding" className="space-y-4 mt-4"> value={branding.expiresInDays.toString()}
<div className="grid gap-4 md:grid-cols-2"> onValueChange={(value) => handleBrandingChange("expiresInDays", parseInt(value))}
<div className="space-y-2"> >
<Label htmlFor="company-logo">Company Logo</Label> <SelectTrigger>
<Input <SelectValue placeholder="Select expiration period" />
id="company-logo" </SelectTrigger>
type="file" <SelectContent>
accept="image/*" <SelectItem value="0">Never Expires</SelectItem>
onChange={handleLogoUpload} <SelectItem value="1">1 Day</SelectItem>
/> <SelectItem value="7">7 Days</SelectItem>
{logoUrl && ( <SelectItem value="30">30 Days</SelectItem>
<div className="mt-2 border rounded-md p-2 flex justify-center"> <SelectItem value="90">90 Days</SelectItem>
<img </SelectContent>
src={logoUrl} </Select>
alt="Company Logo" </div>
className="max-h-20 object-contain"
<div className="flex items-center space-x-2 pt-4">
<Switch
id="showDate"
checked={branding.showDate}
onCheckedChange={(checked) => handleBrandingChange("showDate", checked)}
/>
<Label htmlFor="showDate">Show Date</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="showFooter"
checked={branding.showFooter}
onCheckedChange={(checked) => handleBrandingChange("showFooter", checked)}
/>
<Label htmlFor="showFooter">Show Footer</Label>
</div>
{branding.showFooter && (
<div className="space-y-2">
<Label htmlFor="footerText">Footer Text</Label>
<Input
id="footerText"
value={branding.footerText}
onChange={(e) => handleBrandingChange("footerText", e.target.value)}
placeholder="Footer Text"
/> />
</div> </div>
)} )}
</div> </div>
<div className="space-y-2">
<Label htmlFor="company-name">Company Name</Label>
<Input
id="company-name"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your Company Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom-title">Dashboard Title</Label>
<Input
id="custom-title"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="Dashboard Title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Dashboard Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for this dashboard"
rows={3}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 mt-4">
<div className="space-y-2">
<Label>Primary Color</Label>
<div className="flex items-center space-x-2">
<div
className="w-8 h-8 rounded-md border"
style={{ backgroundColor: primaryColor }}
/>
<Input
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Secondary Color</Label>
<div className="flex items-center space-x-2">
<div
className="w-8 h-8 rounded-md border"
style={{ backgroundColor: secondaryColor }}
/>
<Input
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Switch
checked={showDate}
onCheckedChange={setShowDate}
id="show-date"
/>
<Label htmlFor="show-date">Show date in snapshot</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={showFooter}
onCheckedChange={setShowFooter}
id="show-footer"
/>
<Label htmlFor="show-footer">Show footer in snapshot</Label>
</div>
{showFooter && (
<div className="space-y-2">
<Label htmlFor="footer-text">Footer Text</Label>
<Input
id="footer-text"
value={footerText}
onChange={(e) => setFooterText(e.target.value)}
placeholder="Footer text"
/>
</div>
)}
</div> </div>
</TabsContent> </TabsContent>
{/* Share Tab */}
<TabsContent value="share" className="space-y-4 py-4">
{snapshotUrl && (
<div className="space-y-4">
<div className="p-4 border rounded-md bg-secondary">
<p className="font-medium mb-2">Snapshot URL:</p>
<div className="flex items-center gap-2">
<Input value={snapshotUrl} readOnly />
<Button onClick={copyToClipboard} className="shrink-0">
Copy
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Share this URL to give access to this dashboard snapshot.
{branding.expiresInDays > 0
? ` This link will expire in ${branding.expiresInDays} days.`
: " This link will never expire."}
</p>
</div>
</div>
)}
</TabsContent>
</Tabs> </Tabs>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2"> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)}>
variant="outline" Cancel
onClick={() => onOpenChange(false)}
>
Close
</Button> </Button>
{activeTab === "branding" ? (
<Button
onClick={generateDashboardSnapshot}
disabled={isGenerating}
>
{isGenerating ? "Generating..." : "Generate Snapshot"}
</Button>
) : (
<Button
onClick={() => {
setActiveTab("branding");
setSnapshotUrl(null);
}}
>
Create New Snapshot
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+268 -78
View File
@@ -61,6 +61,15 @@ const widgetSchema = z.object({
showLegend: z.boolean().default(true), showLegend: z.boolean().default(true),
stacked: z.boolean().default(false), stacked: z.boolean().default(false),
precision: z.number().min(0).max(10).default(2), precision: z.number().min(0).max(10).default(2),
// New chart options
colors: z.array(z.string()).optional(),
showGrid: z.boolean().default(true),
showTooltip: z.boolean().default(true),
enableAnimation: z.boolean().default(true),
valueFormatter: z.enum(["none", "number", "percent", "currency"]).default("none"),
currencySymbol: z.string().default("$"),
minValue: z.number().nullable().default(null),
maxValue: z.number().nullable().default(null),
}), }),
}); });
@@ -100,6 +109,15 @@ export function WidgetEditor({
showLegend: editWidget?.config?.showLegend ?? true, showLegend: editWidget?.config?.showLegend ?? true,
stacked: editWidget?.config?.stacked ?? false, stacked: editWidget?.config?.stacked ?? false,
precision: editWidget?.config?.precision ?? 2, precision: editWidget?.config?.precision ?? 2,
// New chart options with defaults
colors: editWidget?.config?.colors || [],
showGrid: editWidget?.config?.showGrid ?? true,
showTooltip: editWidget?.config?.showTooltip ?? true,
enableAnimation: editWidget?.config?.enableAnimation ?? true,
valueFormatter: editWidget?.config?.valueFormatter || "none",
currencySymbol: editWidget?.config?.currencySymbol || "$",
minValue: editWidget?.config?.minValue ?? null,
maxValue: editWidget?.config?.maxValue ?? null,
}, },
}, },
}); });
@@ -406,89 +424,261 @@ export function WidgetEditor({
<AccordionTrigger>Chart Options</AccordionTrigger> <AccordionTrigger>Chart Options</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
{/* Data Configuration section */}
{["bar", "line", "area"].includes(watchType) && ( {["bar", "line", "area"].includes(watchType) && (
<div className="space-y-2"> <div className="space-y-4">
<FormField <h3 className="text-sm font-medium">Data Configuration</h3>
control={form.control} <div className="space-y-2 border-l-2 pl-3">
name="config.xAxis" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="config.xAxis"
<FormLabel>X-Axis Field</FormLabel> render={({ field }) => (
<Select <FormItem>
value={field.value} <FormLabel>X-Axis Field</FormLabel>
onValueChange={field.onChange} <Select
> value={field.value}
<FormControl> onValueChange={field.onChange}
<SelectTrigger>
<SelectValue placeholder="Select X-Axis field" />
</SelectTrigger>
</FormControl>
<SelectContent>
{(form.getValues('config.dimensions') || []).map((dimension) => (
<SelectItem key={dimension} value={dimension}>
{dimension}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose a dimension field for the X-Axis
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Y-Axis Fields</FormLabel>
<div className="space-y-2 mt-2">
{(form.getValues('config.metrics') || []).map((metric) => (
<div key={metric} className="flex items-center space-x-2">
<Checkbox
checked={(form.getValues('config.yAxis') || []).includes(metric)}
onCheckedChange={(checked) => {
if (checked) {
toggleAsYAxis(metric);
} else {
toggleAsYAxis(metric);
}
}}
id={`y-axis-${metric}`}
/>
<label
htmlFor={`y-axis-${metric}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
{metric} <FormControl>
</label> <SelectTrigger>
</div> <SelectValue placeholder="Select X-Axis field" />
))} </SelectTrigger>
</div> </FormControl>
<FormDescription> <SelectContent>
Select metric fields to display on the Y-Axis {(form.getValues('config.dimensions') || []).map((dimension) => (
</FormDescription> <SelectItem key={dimension} value={dimension}>
</div> {dimension}
</SelectItem>
<FormField ))}
control={form.control} </SelectContent>
name="config.stacked" </Select>
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Stacked Chart</FormLabel>
<FormDescription> <FormDescription>
Stack the values on top of each other Choose a dimension field for the X-Axis
</FormDescription> </FormDescription>
</div> <FormMessage />
<FormControl> </FormItem>
<Switch )}
checked={field.value} />
onCheckedChange={field.onChange}
/> <div>
</FormControl> <FormLabel>Y-Axis Fields</FormLabel>
</FormItem> <div className="space-y-2 mt-2">
{(form.getValues('config.metrics') || []).map((metric) => (
<div key={metric} className="flex items-center space-x-2">
<Checkbox
checked={(form.getValues('config.yAxis') || []).includes(metric)}
onCheckedChange={(checked) => {
if (checked) {
toggleAsYAxis(metric);
} else {
toggleAsYAxis(metric);
}
}}
id={`y-axis-${metric}`}
/>
<label
htmlFor={`y-axis-${metric}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{metric}
</label>
</div>
))}
</div>
<FormDescription>
Select metric fields to display on the Y-Axis
</FormDescription>
</div>
<FormField
control={form.control}
name="config.stacked"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Stacked Chart</FormLabel>
<FormDescription>
Stack the values on top of each other
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Display Options section */}
<h3 className="text-sm font-medium">Display Options</h3>
<div className="space-y-4 border-l-2 pl-3">
{/* Show/Hide Grid Lines */}
<FormField
control={form.control}
name="config.showGrid"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Show Grid Lines</FormLabel>
<FormDescription>
Display grid lines in the chart background
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Show/Hide Tooltip */}
<FormField
control={form.control}
name="config.showTooltip"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Show Tooltip</FormLabel>
<FormDescription>
Display tooltip when hovering over data points
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Enable Animation */}
<FormField
control={form.control}
name="config.enableAnimation"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<FormLabel className="text-sm">Enable Animation</FormLabel>
<FormDescription>
Animate chart when loading data
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Value Formatter */}
<FormField
control={form.control}
name="config.valueFormatter"
render={({ field }) => (
<FormItem>
<FormLabel>Value Format</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select value format" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">No formatting</SelectItem>
<SelectItem value="number">Number with commas</SelectItem>
<SelectItem value="percent">Percentage</SelectItem>
<SelectItem value="currency">Currency</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Format for displaying values in the chart
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Currency Symbol (only if valueFormatter is 'currency') */}
{form.watch('config.valueFormatter') === 'currency' && (
<FormField
control={form.control}
name="config.currencySymbol"
render={({ field }) => (
<FormItem>
<FormLabel>Currency Symbol</FormLabel>
<FormControl>
<Input placeholder="$" {...field} />
</FormControl>
<FormDescription>
Symbol to display before values (e.g., $, , £)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)} )}
/> </div>
{/* Axis Limits */}
<h3 className="text-sm font-medium">Axis Limits</h3>
<div className="grid grid-cols-2 gap-3 border-l-2 pl-3">
<FormField
control={form.control}
name="config.minValue"
render={({ field }) => (
<FormItem>
<FormLabel>Min Value</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
value={field.value !== null ? field.value : ''}
onChange={(e) => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Minimum value for Y-axis (optional)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.maxValue"
render={({ field }) => (
<FormItem>
<FormLabel>Max Value</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
value={field.value !== null ? field.value : ''}
onChange={(e) => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Maximum value for Y-axis (optional)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div> </div>
)} )}
+50 -87
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import {
import { Button } from "@/components/ui/button"; Popover,
import { Input } from "@/components/ui/input"; PopoverContent,
import { Check, Droplet } from "lucide-react"; PopoverTrigger
} from "@/components/ui/popover";
interface ColorPickerProps { interface ColorPickerProps {
value: string; value: string;
@@ -10,107 +11,69 @@ interface ColorPickerProps {
className?: string; className?: string;
} }
const PRESET_COLORS = [
"#000000", "#ffffff", "#f44336", "#e91e63", "#9c27b0", "#673ab7",
"#3f51b5", "#2196f3", "#03a9f4", "#00bcd4", "#009688", "#4caf50",
"#8bc34a", "#cddc39", "#ffeb3b", "#ffc107", "#ff9800", "#ff5722",
"#795548", "#607d8b", "#9e9e9e", "#f5f5f5", "#1a237e", "#004d40",
];
export function ColorPicker({ value, onChange, className = "" }: ColorPickerProps) { export function ColorPicker({ value, onChange, className = "" }: ColorPickerProps) {
const [color, setColor] = useState(value || "#000000");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentColor, setCurrentColor] = useState(value || "#000000");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setColor(value); setCurrentColor(value);
}, [value]); }, [value]);
const handleChangeComplete = (newColor: string) => { const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setColor(newColor); const newColor = e.target.value;
setCurrentColor(newColor);
onChange(newColor); onChange(newColor);
}; };
const isValidColor = (color: string) => { const presetColors = [
const s = new Option().style; "#1E40AF", // Blue
s.color = color; "#047857", // Green
return s.color !== ''; "#B91C1C", // Red
}; "#C2410C", // Orange
"#7E22CE", // Purple
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { "#0369A1", // Sky Blue
const newColor = e.target.value; "#4D7C0F", // Lime
setColor(newColor); "#A16207", // Amber
"#0F172A", // Slate
// Validate color format before updating "#64748B", // Gray
if (isValidColor(newColor)) { ];
onChange(newColor);
}
};
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <button
variant="outline" type="button"
className={`w-10 h-10 p-0 border-2 ${className}`} className={`w-9 h-9 rounded-md border border-input focus:outline-none focus:ring-2 focus:ring-ring ${className}`}
style={{ backgroundColor: color }} style={{ backgroundColor: currentColor }}
onClick={() => setIsOpen(true)}
aria-label="Pick a color" aria-label="Pick a color"
> />
<span className="sr-only">Pick a color</span>
</Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64"> <PopoverContent className="w-64 p-3">
<div className="space-y-2"> <div className="space-y-3">
<div <div>
className="w-full h-8 rounded cursor-pointer border" <input
style={{ backgroundColor: color }}
onClick={() => inputRef.current?.click()}
/>
<div className="flex gap-2">
<Input
ref={inputRef} ref={inputRef}
value={color} type="color"
onChange={handleInputChange} value={currentColor}
className="flex-1" onChange={handleColorChange}
className="w-full h-8 cursor-pointer"
/> />
<Button
size="icon"
variant="outline"
onClick={() => {
if (typeof window !== 'undefined' && 'EyeDropper' in window) {
// @ts-ignore
const eyeDropper = new window.EyeDropper();
eyeDropper.open()
.then((result: { sRGBHex: string }) => {
handleChangeComplete(result.sRGBHex);
})
.catch(() => {
// User canceled the eyedropper
});
}
}}
>
<Droplet className="h-4 w-4" />
</Button>
</div> </div>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-8 gap-1"> {presetColors.map((color) => (
{PRESET_COLORS.map((presetColor) => ( <button
<Button key={color}
key={presetColor} type="button"
style={{ backgroundColor: presetColor }} className="w-8 h-8 rounded-md border border-input focus:outline-none focus:ring-2 focus:ring-ring"
className="w-6 h-6 p-0 rounded-sm" style={{ backgroundColor: color }}
onClick={() => { onClick={() => {
handleChangeComplete(presetColor); setCurrentColor(color);
setIsOpen(false); onChange(color);
}} }}
> aria-label={`Select color ${color}`}
{color.toLowerCase() === presetColor.toLowerCase() && ( />
<Check className="h-3 w-3 text-white" />
)}
<span className="sr-only">Select color {presetColor}</span>
</Button>
))} ))}
</div> </div>
</div> </div>