mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 22:04:56 +00:00
Merge pull request #224 from johndoe6345789/codex/add-dropdownconfigform-and-previewpane
Refactor dropdown config manager into modular components
This commit is contained in:
@@ -1,26 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card } from '@/components/ui'
|
||||
import { Database } from '@/lib/database'
|
||||
import { Plus, X, FloppyDisk, Trash, Pencil } from '@phosphor-icons/react'
|
||||
import { Plus } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import type { DropdownConfig } from '@/lib/database'
|
||||
import { DropdownConfigForm } from './dropdown/DropdownConfigForm'
|
||||
import { PreviewPane } from './dropdown/PreviewPane'
|
||||
|
||||
export function DropdownConfigManager() {
|
||||
const [dropdowns, setDropdowns] = useState<DropdownConfig[]>([])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingDropdown, setEditingDropdown] = useState<DropdownConfig | null>(null)
|
||||
const [dropdownName, setDropdownName] = useState('')
|
||||
const [dropdownLabel, setDropdownLabel] = useState('')
|
||||
const [options, setOptions] = useState<Array<{ value: string; label: string }>>([])
|
||||
const [newOptionValue, setNewOptionValue] = useState('')
|
||||
const [newOptionLabel, setNewOptionLabel] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadDropdowns()
|
||||
@@ -31,63 +21,34 @@ export function DropdownConfigManager() {
|
||||
setDropdowns(configs)
|
||||
}
|
||||
|
||||
const startEdit = (dropdown?: DropdownConfig) => {
|
||||
if (dropdown) {
|
||||
setEditingDropdown(dropdown)
|
||||
setDropdownName(dropdown.name)
|
||||
setDropdownLabel(dropdown.label)
|
||||
setOptions(dropdown.options)
|
||||
} else {
|
||||
setEditingDropdown(null)
|
||||
setDropdownName('')
|
||||
setDropdownLabel('')
|
||||
setOptions([])
|
||||
}
|
||||
const openEditor = (dropdown?: DropdownConfig) => {
|
||||
setEditingDropdown(dropdown ?? null)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const addOption = () => {
|
||||
if (newOptionValue && newOptionLabel) {
|
||||
setOptions(current => [...current, { value: newOptionValue, label: newOptionLabel }])
|
||||
setNewOptionValue('')
|
||||
setNewOptionLabel('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setOptions(current => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!dropdownName || !dropdownLabel || options.length === 0) {
|
||||
toast.error('Please fill all fields and add at least one option')
|
||||
return
|
||||
}
|
||||
|
||||
const newDropdown: DropdownConfig = {
|
||||
id: editingDropdown?.id || `dropdown_${Date.now()}`,
|
||||
name: dropdownName,
|
||||
label: dropdownLabel,
|
||||
options,
|
||||
}
|
||||
|
||||
if (editingDropdown) {
|
||||
await Database.updateDropdownConfig(newDropdown.id, newDropdown)
|
||||
const handleSave = async (config: DropdownConfig, isEdit: boolean) => {
|
||||
if (isEdit) {
|
||||
await Database.updateDropdownConfig(config.id, config)
|
||||
toast.success('Dropdown updated successfully')
|
||||
} else {
|
||||
await Database.addDropdownConfig(newDropdown)
|
||||
await Database.addDropdownConfig(config)
|
||||
toast.success('Dropdown created successfully')
|
||||
}
|
||||
|
||||
setIsEditing(false)
|
||||
loadDropdowns()
|
||||
await loadDropdowns()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this dropdown configuration?')) {
|
||||
await Database.deleteDropdownConfig(id)
|
||||
toast.success('Dropdown deleted')
|
||||
loadDropdowns()
|
||||
await Database.deleteDropdownConfig(id)
|
||||
toast.success('Dropdown deleted')
|
||||
await loadDropdowns()
|
||||
}
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
setIsEditing(open)
|
||||
if (!open) {
|
||||
setEditingDropdown(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +59,7 @@ export function DropdownConfigManager() {
|
||||
<h2 className="text-2xl font-bold">Dropdown Configurations</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage dynamic dropdown options for properties</p>
|
||||
</div>
|
||||
<Button onClick={() => startEdit()}>
|
||||
<Button onClick={() => openEditor()}>
|
||||
<Plus className="mr-2" />
|
||||
Create Dropdown
|
||||
</Button>
|
||||
@@ -106,30 +67,12 @@ export function DropdownConfigManager() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{dropdowns.map(dropdown => (
|
||||
<Card key={dropdown.id} className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{dropdown.label}</h3>
|
||||
<p className="text-xs text-muted-foreground font-mono">{dropdown.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => startEdit(dropdown)}>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(dropdown.id)}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dropdown.options.map((opt, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">
|
||||
{opt.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<PreviewPane
|
||||
key={dropdown.id}
|
||||
dropdown={dropdown}
|
||||
onEdit={openEditor}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -139,88 +82,12 @@ export function DropdownConfigManager() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={isEditing} onOpenChange={setIsEditing}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingDropdown ? 'Edit' : 'Create'} Dropdown Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Dropdown Name (ID)</Label>
|
||||
<Input
|
||||
placeholder="e.g., status_options"
|
||||
value={dropdownName}
|
||||
onChange={(e) => setDropdownName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Unique identifier for this dropdown</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Display Label</Label>
|
||||
<Input
|
||||
placeholder="e.g., Status"
|
||||
value={dropdownLabel}
|
||||
onChange={(e) => setDropdownLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
/>
|
||||
<Button onClick={addOption} type="button">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 && (
|
||||
<ScrollArea className="h-[200px] border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 border rounded bg-muted/50">
|
||||
<div className="flex-1">
|
||||
<span className="font-mono text-sm">{opt.value}</span>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(i)}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<FloppyDisk className="mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DropdownConfigForm
|
||||
open={isEditing}
|
||||
editingDropdown={editingDropdown}
|
||||
onOpenChange={handleDialogChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Badge, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, ScrollArea, Separator } from '@/components/ui'
|
||||
import { FloppyDisk, Plus, X } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import type { DropdownConfig } from '@/lib/database'
|
||||
|
||||
interface DropdownConfigFormProps {
|
||||
open: boolean
|
||||
editingDropdown: DropdownConfig | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (config: DropdownConfig, isEdit: boolean) => Promise<void> | void
|
||||
}
|
||||
|
||||
const getDefaultOptions = (dropdown?: DropdownConfig | null) => dropdown?.options ?? []
|
||||
|
||||
const buildDropdownConfig = (
|
||||
dropdown: DropdownConfig | null,
|
||||
name: string,
|
||||
label: string,
|
||||
options: Array<{ value: string; label: string }>
|
||||
): DropdownConfig => ({
|
||||
id: dropdown?.id ?? `dropdown_${Date.now()}`,
|
||||
name: name.trim(),
|
||||
label: label.trim(),
|
||||
options,
|
||||
})
|
||||
|
||||
export function DropdownConfigForm({ open, editingDropdown, onOpenChange, onSave }: DropdownConfigFormProps) {
|
||||
const [dropdownName, setDropdownName] = useState('')
|
||||
const [dropdownLabel, setDropdownLabel] = useState('')
|
||||
const [options, setOptions] = useState<Array<{ value: string; label: string }>>([])
|
||||
const [newOptionValue, setNewOptionValue] = useState('')
|
||||
const [newOptionLabel, setNewOptionLabel] = useState('')
|
||||
|
||||
const isEditMode = useMemo(() => Boolean(editingDropdown), [editingDropdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDropdownName(editingDropdown?.name ?? '')
|
||||
setDropdownLabel(editingDropdown?.label ?? '')
|
||||
setOptions(getDefaultOptions(editingDropdown))
|
||||
} else {
|
||||
setDropdownName('')
|
||||
setDropdownLabel('')
|
||||
setOptions([])
|
||||
setNewOptionValue('')
|
||||
setNewOptionLabel('')
|
||||
}
|
||||
}, [open, editingDropdown])
|
||||
|
||||
const addOption = () => {
|
||||
if (!newOptionValue.trim() || !newOptionLabel.trim()) {
|
||||
toast.error('Please provide both a value and label for the option')
|
||||
return
|
||||
}
|
||||
|
||||
const duplicate = options.some(
|
||||
(opt) => opt.value.toLowerCase() === newOptionValue.trim().toLowerCase()
|
||||
)
|
||||
|
||||
if (duplicate) {
|
||||
toast.error('An option with this value already exists')
|
||||
return
|
||||
}
|
||||
|
||||
setOptions((current) => [
|
||||
...current,
|
||||
{ value: newOptionValue.trim(), label: newOptionLabel.trim() },
|
||||
])
|
||||
setNewOptionValue('')
|
||||
setNewOptionLabel('')
|
||||
}
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setOptions((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!dropdownName.trim() || !dropdownLabel.trim() || options.length === 0) {
|
||||
toast.error('Please fill all fields and add at least one option')
|
||||
return
|
||||
}
|
||||
|
||||
const config = buildDropdownConfig(editingDropdown, dropdownName, dropdownLabel, options)
|
||||
await onSave(config, isEditMode)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? 'Edit' : 'Create'} Dropdown Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dropdownName">Dropdown Name (ID)</Label>
|
||||
<Input
|
||||
id="dropdownName"
|
||||
placeholder="e.g., status_options"
|
||||
value={dropdownName}
|
||||
onChange={(e) => setDropdownName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Unique identifier for this dropdown</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dropdownLabel">Display Label</Label>
|
||||
<Input
|
||||
id="dropdownLabel"
|
||||
placeholder="e.g., Status"
|
||||
value={dropdownLabel}
|
||||
onChange={(e) => setDropdownLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
/>
|
||||
<Button onClick={addOption} type="button">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 && (
|
||||
<ScrollArea className="h-[200px] border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 border rounded bg-muted/50">
|
||||
<div className="flex-1">
|
||||
<span className="font-mono text-sm">{opt.value}</span>
|
||||
<span className="mx-2 text-muted-foreground">→</span>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(i)}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length === 0 && (
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">Tip</Badge>
|
||||
Add at least one option to save this dropdown configuration.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<FloppyDisk className="mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge, Button, Card, Separator } from '@/components/ui'
|
||||
import { Pencil, Trash } from '@phosphor-icons/react'
|
||||
import type { DropdownConfig } from '@/lib/database'
|
||||
|
||||
interface PreviewPaneProps {
|
||||
dropdown: DropdownConfig
|
||||
onEdit: (dropdown: DropdownConfig) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function PreviewPane({ dropdown, onEdit, onDelete }: PreviewPaneProps) {
|
||||
const handleDelete = () => {
|
||||
if (confirm('Are you sure you want to delete this dropdown configuration?')) {
|
||||
onDelete(dropdown.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{dropdown.label}</h3>
|
||||
<p className="text-xs text-muted-foreground font-mono">{dropdown.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => onEdit(dropdown)}>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dropdown.options.map((opt, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">
|
||||
{opt.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user