feat: Update screen reference handling in V2 layouts
- Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table. - Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data. - Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process. - This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system.
This commit is contained in:
264
frontend/lib/registry/components/common/ConfigField.tsx
Normal file
264
frontend/lib/registry/components/common/ConfigField.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
|
||||
|
||||
interface ConfigFieldProps<T = any> {
|
||||
field: ConfigFieldDefinition<T>;
|
||||
value: any;
|
||||
onChange: (key: string, value: any) => void;
|
||||
tableColumns?: ConfigOption[];
|
||||
}
|
||||
|
||||
export function ConfigField<T>({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
tableColumns,
|
||||
}: ConfigFieldProps<T>) {
|
||||
const handleChange = (newValue: any) => {
|
||||
onChange(field.key, newValue);
|
||||
};
|
||||
|
||||
const renderField = () => {
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={field.placeholder || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? "#000000"}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="h-8 w-8 cursor-pointer rounded border"
|
||||
/>
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "slider":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? field.min ?? 0}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{field.min ?? 0} ~ {field.max ?? 100}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "multi-select":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{(field.options || []).map((opt) => {
|
||||
const selected = Array.isArray(value) && value.includes(opt.value);
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => {
|
||||
const current = Array.isArray(value) ? [...value] : [];
|
||||
if (selected) {
|
||||
handleChange(current.filter((v: string) => v !== opt.value));
|
||||
} else {
|
||||
handleChange([...current, opt.value]);
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs">{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "key-value": {
|
||||
const entries: Array<[string, string]> = Object.entries(
|
||||
(value as Record<string, string>) || {},
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, v], idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={k}
|
||||
onChange={(e) => {
|
||||
const newObj = { ...(value || {}) };
|
||||
delete newObj[k];
|
||||
newObj[e.target.value] = v;
|
||||
handleChange(newObj);
|
||||
}}
|
||||
placeholder="키"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={v}
|
||||
onChange={(e) => {
|
||||
handleChange({ ...(value || {}), [k]: e.target.value });
|
||||
}}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
const newObj = { ...(value || {}) };
|
||||
delete newObj[k];
|
||||
handleChange(newObj);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => {
|
||||
handleChange({ ...(value || {}), "": "" });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "column-picker": {
|
||||
const options = tableColumns || field.options || [];
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">{field.label}</Label>
|
||||
{field.type === "switch" && renderField()}
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-muted-foreground text-[10px]">{field.description}</p>
|
||||
)}
|
||||
{field.type !== "switch" && renderField()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ConfigPanelBuilderProps } from "./ConfigPanelTypes";
|
||||
import { ConfigSection } from "./ConfigSection";
|
||||
import { ConfigField } from "./ConfigField";
|
||||
|
||||
export function ConfigPanelBuilder<T extends Record<string, any>>({
|
||||
config,
|
||||
onChange,
|
||||
sections,
|
||||
presets,
|
||||
tableColumns,
|
||||
children,
|
||||
}: ConfigPanelBuilderProps<T>) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 프리셋 버튼 */}
|
||||
{presets && presets.length > 0 && (
|
||||
<div className="border-b pb-3">
|
||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
빠른 설정
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{presets.map((preset, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
Object.entries(preset.values).forEach(([key, value]) => {
|
||||
onChange(key, value);
|
||||
});
|
||||
}}
|
||||
className="rounded-full bg-muted px-2.5 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
title={preset.description}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 섹션 렌더링 */}
|
||||
{sections.map((section) => {
|
||||
if (section.condition && !section.condition(config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibleFields = section.fields.filter(
|
||||
(field) => !field.condition || field.condition(config),
|
||||
);
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigSection key={section.id} section={section}>
|
||||
{visibleFields.map((field) => (
|
||||
<ConfigField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={(config as any)[field.key]}
|
||||
onChange={onChange}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
))}
|
||||
</ConfigSection>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 커스텀 children */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/lib/registry/components/common/ConfigPanelTypes.ts
Normal file
56
frontend/lib/registry/components/common/ConfigPanelTypes.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
|
||||
export type ConfigFieldType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "switch"
|
||||
| "select"
|
||||
| "textarea"
|
||||
| "color"
|
||||
| "slider"
|
||||
| "multi-select"
|
||||
| "key-value"
|
||||
| "column-picker";
|
||||
|
||||
export interface ConfigOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ConfigFieldDefinition<T = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ConfigFieldType;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
options?: ConfigOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
condition?: (config: T) => boolean;
|
||||
disabled?: (config: T) => boolean;
|
||||
}
|
||||
|
||||
export interface ConfigSectionDefinition<T = any> {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
fields: ConfigFieldDefinition<T>[];
|
||||
condition?: (config: T) => boolean;
|
||||
}
|
||||
|
||||
export interface ConfigPanelBuilderProps<T = any> {
|
||||
config: T;
|
||||
onChange: (key: string, value: any) => void;
|
||||
sections: ConfigSectionDefinition<T>[];
|
||||
presets?: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
values: Partial<T>;
|
||||
}>;
|
||||
tableColumns?: ConfigOption[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
52
frontend/lib/registry/components/common/ConfigSection.tsx
Normal file
52
frontend/lib/registry/components/common/ConfigSection.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ConfigSectionDefinition } from "./ConfigPanelTypes";
|
||||
|
||||
interface ConfigSectionProps {
|
||||
section: ConfigSectionDefinition;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfigSection({ section, children }: ConfigSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(section.defaultOpen ?? true);
|
||||
|
||||
if (section.collapsible) {
|
||||
return (
|
||||
<div className="border-b pb-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center gap-1.5 py-1 text-left"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{section.title}</span>
|
||||
{section.description && (
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{section.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && <div className="mt-2 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2">
|
||||
<h4 className="text-sm font-medium">{section.title}</h4>
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user