309 lines
8.6 KiB
TypeScript
309 lines
8.6 KiB
TypeScript
|
|
/**
|
||
|
|
* 컴포넌트 설정 공통 스키마 및 병합 유틸리티
|
||
|
|
*
|
||
|
|
* 모든 컴포넌트가 공통으로 사용
|
||
|
|
* - 기본값: 각 컴포넌트의 defaultConfig에서 가져옴
|
||
|
|
* - 커스텀: DB custom_config에서 가져옴
|
||
|
|
* - 최종 설정 = 기본값 + 커스텀 (깊은 병합)
|
||
|
|
*/
|
||
|
|
import { z } from "zod";
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 공통 스키마 (모든 구조 허용)
|
||
|
|
// ============================================
|
||
|
|
export const customConfigSchema = z.record(z.any());
|
||
|
|
|
||
|
|
export type CustomConfig = z.infer<typeof customConfigSchema>;
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 깊은 병합 함수
|
||
|
|
// ============================================
|
||
|
|
export function deepMerge<T extends Record<string, any>>(
|
||
|
|
target: T,
|
||
|
|
source: Record<string, any>
|
||
|
|
): T {
|
||
|
|
const result = { ...target };
|
||
|
|
|
||
|
|
for (const key of Object.keys(source)) {
|
||
|
|
const sourceValue = source[key];
|
||
|
|
const targetValue = result[key as keyof T];
|
||
|
|
|
||
|
|
// 둘 다 객체이고 배열이 아니면 깊은 병합
|
||
|
|
if (
|
||
|
|
isPlainObject(sourceValue) &&
|
||
|
|
isPlainObject(targetValue)
|
||
|
|
) {
|
||
|
|
result[key as keyof T] = deepMerge(targetValue, sourceValue);
|
||
|
|
} else if (sourceValue !== undefined) {
|
||
|
|
// source 값이 있으면 덮어쓰기
|
||
|
|
result[key as keyof T] = sourceValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
||
|
|
return (
|
||
|
|
typeof value === "object" &&
|
||
|
|
value !== null &&
|
||
|
|
!Array.isArray(value) &&
|
||
|
|
Object.prototype.toString.call(value) === "[object Object]"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 설정 병합 함수 (렌더링 시 사용)
|
||
|
|
// ============================================
|
||
|
|
export function mergeComponentConfig(
|
||
|
|
defaultConfig: Record<string, any>,
|
||
|
|
customConfig: Record<string, any> | null | undefined
|
||
|
|
): Record<string, any> {
|
||
|
|
if (!customConfig || Object.keys(customConfig).length === 0) {
|
||
|
|
return { ...defaultConfig };
|
||
|
|
}
|
||
|
|
|
||
|
|
return deepMerge(defaultConfig, customConfig);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 커스텀 설정 추출 함수 (저장 시 사용)
|
||
|
|
// ============================================
|
||
|
|
export function extractCustomConfig(
|
||
|
|
fullConfig: Record<string, any>,
|
||
|
|
defaultConfig: Record<string, any>
|
||
|
|
): Record<string, any> {
|
||
|
|
const customConfig: Record<string, any> = {};
|
||
|
|
|
||
|
|
for (const key of Object.keys(fullConfig)) {
|
||
|
|
const fullValue = fullConfig[key];
|
||
|
|
const defaultValue = defaultConfig[key];
|
||
|
|
|
||
|
|
// 기본값과 다른 경우만 커스텀으로 추출
|
||
|
|
if (!isDeepEqual(fullValue, defaultValue)) {
|
||
|
|
customConfig[key] = fullValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return customConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 깊은 비교 함수
|
||
|
|
// ============================================
|
||
|
|
export function isDeepEqual(a: unknown, b: unknown): boolean {
|
||
|
|
if (a === b) return true;
|
||
|
|
if (a == null || b == null) return a === b;
|
||
|
|
if (typeof a !== typeof b) return false;
|
||
|
|
if (typeof a !== "object") return a === b;
|
||
|
|
|
||
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||
|
|
if (a.length !== b.length) return false;
|
||
|
|
for (let i = 0; i < a.length; i++) {
|
||
|
|
if (!isDeepEqual(a[i], b[i])) return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const objA = a as Record<string, unknown>;
|
||
|
|
const objB = b as Record<string, unknown>;
|
||
|
|
const keysA = Object.keys(objA);
|
||
|
|
const keysB = Object.keys(objB);
|
||
|
|
|
||
|
|
if (keysA.length !== keysB.length) return false;
|
||
|
|
|
||
|
|
for (const key of keysA) {
|
||
|
|
if (!keysB.includes(key)) return false;
|
||
|
|
if (!isDeepEqual(objA[key], objB[key])) return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 컴포넌트 URL 생성 함수
|
||
|
|
// ============================================
|
||
|
|
export function getComponentUrl(componentType: string): string {
|
||
|
|
return `@/lib/registry/components/${componentType}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 컴포넌트 타입 추출 함수 (URL에서)
|
||
|
|
// ============================================
|
||
|
|
export function getComponentTypeFromUrl(componentUrl: string): string {
|
||
|
|
// "@/lib/registry/components/split-panel-layout" → "split-panel-layout"
|
||
|
|
const parts = componentUrl.split("/");
|
||
|
|
return parts[parts.length - 1];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// V2 레이아웃 스키마
|
||
|
|
// ============================================
|
||
|
|
export const componentV2Schema = z.object({
|
||
|
|
id: z.string(),
|
||
|
|
url: z.string(),
|
||
|
|
position: z.object({
|
||
|
|
x: z.number().default(0),
|
||
|
|
y: z.number().default(0),
|
||
|
|
}),
|
||
|
|
size: z.object({
|
||
|
|
width: z.number().default(100),
|
||
|
|
height: z.number().default(100),
|
||
|
|
}),
|
||
|
|
displayOrder: z.number().default(0),
|
||
|
|
overrides: z.record(z.any()).default({}),
|
||
|
|
});
|
||
|
|
|
||
|
|
export const layoutV2Schema = z.object({
|
||
|
|
version: z.string().default("2.0"),
|
||
|
|
components: z.array(componentV2Schema).default([]),
|
||
|
|
updatedAt: z.string().optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
export type ComponentV2 = z.infer<typeof componentV2Schema>;
|
||
|
|
export type LayoutV2 = z.infer<typeof layoutV2Schema>;
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 컴포넌트별 기본값 레지스트리
|
||
|
|
// ============================================
|
||
|
|
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||
|
|
"table-list": {
|
||
|
|
pagination: true,
|
||
|
|
pageSize: 20,
|
||
|
|
selectable: true,
|
||
|
|
showHeader: true,
|
||
|
|
},
|
||
|
|
"button-primary": {
|
||
|
|
label: "버튼",
|
||
|
|
variant: "default",
|
||
|
|
size: "default",
|
||
|
|
},
|
||
|
|
"text-input": {
|
||
|
|
placeholder: "",
|
||
|
|
multiline: false,
|
||
|
|
},
|
||
|
|
"select-basic": {
|
||
|
|
placeholder: "선택하세요",
|
||
|
|
options: [],
|
||
|
|
},
|
||
|
|
"date-input": {
|
||
|
|
format: "YYYY-MM-DD",
|
||
|
|
},
|
||
|
|
"split-panel-layout": {
|
||
|
|
splitRatio: 50,
|
||
|
|
direction: "horizontal",
|
||
|
|
resizable: true,
|
||
|
|
},
|
||
|
|
"tabs-widget": {
|
||
|
|
tabs: [],
|
||
|
|
defaultTab: 0,
|
||
|
|
},
|
||
|
|
"card-display": {
|
||
|
|
title: "",
|
||
|
|
bordered: true,
|
||
|
|
},
|
||
|
|
"flow-widget": {
|
||
|
|
flowId: null,
|
||
|
|
},
|
||
|
|
"category-management": {
|
||
|
|
categoryType: "",
|
||
|
|
},
|
||
|
|
"pivot-table": {
|
||
|
|
rows: [],
|
||
|
|
columns: [],
|
||
|
|
values: [],
|
||
|
|
},
|
||
|
|
"unified-grid": {
|
||
|
|
columns: [],
|
||
|
|
},
|
||
|
|
"checkbox-basic": {
|
||
|
|
label: "",
|
||
|
|
defaultChecked: false,
|
||
|
|
},
|
||
|
|
"radio-basic": {
|
||
|
|
options: [],
|
||
|
|
},
|
||
|
|
"file-upload": {
|
||
|
|
accept: "*",
|
||
|
|
multiple: false,
|
||
|
|
},
|
||
|
|
"repeat-container": {
|
||
|
|
children: [],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 컴포넌트 기본값 조회
|
||
|
|
// ============================================
|
||
|
|
export function getComponentDefaults(componentType: string): Record<string, any> {
|
||
|
|
return componentDefaultsRegistry[componentType] || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// URL에서 기본값 조회
|
||
|
|
// ============================================
|
||
|
|
export function getDefaultsByUrl(url: string): Record<string, any> {
|
||
|
|
const componentType = getComponentTypeFromUrl(url);
|
||
|
|
return getComponentDefaults(componentType);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// V2 컴포넌트 로드 (기본값 + overrides 병합)
|
||
|
|
// ============================================
|
||
|
|
export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record<string, any> } {
|
||
|
|
const defaults = getDefaultsByUrl(component.url);
|
||
|
|
const config = mergeComponentConfig(defaults, component.overrides);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...component,
|
||
|
|
config,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// V2 컴포넌트 저장 (차이값 추출)
|
||
|
|
// ============================================
|
||
|
|
export function saveComponentV2(
|
||
|
|
component: ComponentV2 & { config?: Record<string, any> }
|
||
|
|
): ComponentV2 {
|
||
|
|
const defaults = getDefaultsByUrl(component.url);
|
||
|
|
const overrides = component.config
|
||
|
|
? extractCustomConfig(component.config, defaults)
|
||
|
|
: component.overrides;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: component.id,
|
||
|
|
url: component.url,
|
||
|
|
position: component.position,
|
||
|
|
size: component.size,
|
||
|
|
displayOrder: component.displayOrder,
|
||
|
|
overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
|
||
|
|
// ============================================
|
||
|
|
export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
|
||
|
|
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
|
||
|
|
|
||
|
|
return {
|
||
|
|
...parsed,
|
||
|
|
components: parsed.components.map(loadComponentV2),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
|
||
|
|
// ============================================
|
||
|
|
export function saveLayoutV2(
|
||
|
|
components: Array<ComponentV2 & { config?: Record<string, any> }>
|
||
|
|
): LayoutV2 {
|
||
|
|
return {
|
||
|
|
version: "2.0",
|
||
|
|
components: components.map(saveComponentV2),
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
}
|