feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* pop-string-list 런타임 컴포넌트
|
|
|
|
|
*
|
|
|
|
|
* 리스트 모드: 엑셀형 행/열 (CSS Grid)
|
|
|
|
|
* 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan)
|
|
|
|
|
* 오버플로우: visibleRows 제한 + "전체보기" 확장
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
2026-02-13 11:27:40 +09:00
|
|
|
import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-02-13 11:27:40 +09:00
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
import { dataApi } from "@/lib/api/data";
|
|
|
|
|
import type {
|
|
|
|
|
PopStringListConfig,
|
|
|
|
|
CardGridConfig,
|
|
|
|
|
ListColumnConfig,
|
|
|
|
|
CardCellDefinition,
|
|
|
|
|
} from "./types";
|
|
|
|
|
|
2026-02-13 11:27:40 +09:00
|
|
|
// ===== 유틸리티 =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼명에서 실제 데이터 키를 추출
|
|
|
|
|
* 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출
|
|
|
|
|
* 일반 컬럼은 그대로 반환
|
|
|
|
|
*/
|
|
|
|
|
function resolveColumnName(name: string): string {
|
|
|
|
|
if (!name) return name;
|
|
|
|
|
const dotIdx = name.lastIndexOf(".");
|
|
|
|
|
return dotIdx >= 0 ? name.substring(dotIdx + 1) : name;
|
|
|
|
|
}
|
|
|
|
|
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
// ===== Props =====
|
|
|
|
|
|
|
|
|
|
interface PopStringListComponentProps {
|
|
|
|
|
config?: PopStringListConfig;
|
|
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블 행 데이터 타입
|
|
|
|
|
type RowData = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
|
|
|
|
|
|
export function PopStringListComponent({
|
|
|
|
|
config,
|
|
|
|
|
className,
|
|
|
|
|
}: PopStringListComponentProps) {
|
|
|
|
|
const displayMode = config?.displayMode || "list";
|
|
|
|
|
const header = config?.header;
|
|
|
|
|
const overflow = config?.overflow;
|
|
|
|
|
const dataSource = config?.dataSource;
|
|
|
|
|
const listColumns = config?.listColumns || [];
|
|
|
|
|
const cardGrid = config?.cardGrid;
|
|
|
|
|
|
|
|
|
|
// 데이터 상태
|
|
|
|
|
const [rows, setRows] = useState<RowData[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 오버플로우 계산 (JSON 복원 시 string 유입 방어)
|
|
|
|
|
const visibleRows = Number(overflow?.visibleRows) || 5;
|
|
|
|
|
const maxExpandRows = Number(overflow?.maxExpandRows) || 20;
|
|
|
|
|
const showExpandButton = overflow?.showExpandButton ?? true;
|
|
|
|
|
|
|
|
|
|
// 표시할 데이터 슬라이스
|
|
|
|
|
const visibleData = expanded
|
|
|
|
|
? rows.slice(0, maxExpandRows)
|
|
|
|
|
: rows.slice(0, visibleRows);
|
|
|
|
|
const hasMore = rows.length > visibleRows;
|
|
|
|
|
|
|
|
|
|
// 확장/축소 토글
|
|
|
|
|
const toggleExpanded = useCallback(() => {
|
|
|
|
|
setExpanded((prev) => !prev);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 데이터 조회
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!dataSource?.tableName) {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
setRows([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 필터 조건 구성
|
|
|
|
|
const filters: Record<string, unknown> = {};
|
|
|
|
|
if (dataSource.filters && dataSource.filters.length > 0) {
|
|
|
|
|
dataSource.filters.forEach((f) => {
|
|
|
|
|
if (f.column && f.value) {
|
|
|
|
|
filters[f.column] = f.value;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 정렬 조건
|
|
|
|
|
const sortBy = dataSource.sort?.column;
|
|
|
|
|
const sortOrder = dataSource.sort?.direction;
|
|
|
|
|
|
|
|
|
|
// 개수 제한 (string 유입 방어: Number 캐스팅)
|
|
|
|
|
const size =
|
|
|
|
|
dataSource.limit?.mode === "limited" && dataSource.limit?.count
|
|
|
|
|
? Number(dataSource.limit.count)
|
|
|
|
|
: maxExpandRows;
|
|
|
|
|
|
|
|
|
|
const result = await dataApi.getTableData(dataSource.tableName, {
|
|
|
|
|
page: 1,
|
|
|
|
|
size,
|
|
|
|
|
sortBy: sortOrder ? sortBy : undefined,
|
|
|
|
|
sortOrder,
|
|
|
|
|
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setRows(result.data || []);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message =
|
|
|
|
|
err instanceof Error ? err.message : "데이터 조회 실패";
|
|
|
|
|
setError(message);
|
|
|
|
|
setRows([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fetchData();
|
|
|
|
|
}, [dataSource, maxExpandRows]);
|
|
|
|
|
|
|
|
|
|
// 로딩 상태
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
|
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 에러 상태
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
|
|
|
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
|
|
|
|
<span className="text-xs text-destructive">{error}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블 미선택
|
|
|
|
|
if (!dataSource?.tableName) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center p-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
테이블을 선택하세요
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터 없음
|
|
|
|
|
if (rows.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center p-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
데이터가 없습니다
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`flex h-full w-full flex-col overflow-hidden ${className || ""}`}>
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
{header?.enabled && header.label && (
|
|
|
|
|
<div className="shrink-0 border-b px-3 py-2">
|
|
|
|
|
<span className="text-sm font-medium">{header.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 컨텐츠 */}
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
{displayMode === "list" ? (
|
|
|
|
|
<ListModeView
|
|
|
|
|
columns={listColumns}
|
|
|
|
|
data={visibleData}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<CardModeView
|
|
|
|
|
cardGrid={cardGrid}
|
|
|
|
|
data={visibleData}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 전체보기 버튼 */}
|
|
|
|
|
{showExpandButton && hasMore && (
|
|
|
|
|
<div className="shrink-0 border-t px-3 py-1.5">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={toggleExpanded}
|
|
|
|
|
className="h-7 w-full text-xs text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
{expanded ? (
|
|
|
|
|
<>
|
|
|
|
|
<ChevronUp className="mr-1 h-3 w-3" />
|
|
|
|
|
접기
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<ChevronDown className="mr-1 h-3 w-3" />
|
|
|
|
|
전체보기 ({rows.length}건)
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 리스트 모드 =====
|
|
|
|
|
|
|
|
|
|
interface ListModeViewProps {
|
|
|
|
|
columns: ListColumnConfig[];
|
|
|
|
|
data: RowData[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ListModeView({ columns, data }: ListModeViewProps) {
|
2026-02-13 11:27:40 +09:00
|
|
|
// 런타임 컬럼 전환 상태
|
|
|
|
|
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
|
|
|
|
|
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
|
|
|
|
|
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
if (columns.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
컬럼을 설정하세요
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gridCols = columns.map((c) => c.width || "1fr").join(" ");
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full">
|
|
|
|
|
{/* 헤더 행 */}
|
|
|
|
|
<div
|
|
|
|
|
className="border-b bg-muted/50"
|
|
|
|
|
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
|
|
|
|
>
|
2026-02-13 11:27:40 +09:00
|
|
|
{columns.map((col, colIdx) => {
|
|
|
|
|
const hasAlternates = (col.alternateColumns || []).length > 0;
|
|
|
|
|
const currentColName = activeColumns[colIdx] || col.columnName;
|
|
|
|
|
// 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시
|
|
|
|
|
const currentLabel =
|
|
|
|
|
currentColName === col.columnName
|
|
|
|
|
? col.label
|
|
|
|
|
: resolveColumnName(currentColName);
|
|
|
|
|
|
|
|
|
|
if (hasAlternates) {
|
|
|
|
|
// 전환 가능한 헤더: Popover 드롭다운
|
|
|
|
|
return (
|
|
|
|
|
<Popover key={col.columnName}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 transition-colors"
|
|
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{currentLabel}</span>
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-auto min-w-[120px] p-1" align="start">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
{/* 원래 컬럼 */}
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded px-2 py-1 text-left text-xs transition-colors",
|
|
|
|
|
currentColName === col.columnName
|
|
|
|
|
? "bg-primary/10 text-primary font-medium"
|
|
|
|
|
: "hover:bg-muted"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setActiveColumns((prev) => {
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
delete next[colIdx];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{col.label} (기본)
|
|
|
|
|
</button>
|
|
|
|
|
{/* 대체 컬럼들 */}
|
|
|
|
|
{(col.alternateColumns || []).map((altCol) => {
|
|
|
|
|
const altLabel = resolveColumnName(altCol);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={altCol}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded px-2 py-1 text-left text-xs transition-colors",
|
|
|
|
|
currentColName === altCol
|
|
|
|
|
? "bg-primary/10 text-primary font-medium"
|
|
|
|
|
: "hover:bg-muted"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setActiveColumns((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[colIdx]: altCol,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{altLabel}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 전환 없는 일반 헤더
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={col.columnName}
|
|
|
|
|
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
|
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
|
|
|
|
{col.label}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 행 */}
|
|
|
|
|
{data.map((row, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
|
|
|
|
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
|
|
|
|
>
|
2026-02-13 11:27:40 +09:00
|
|
|
{columns.map((col, colIdx) => {
|
|
|
|
|
const currentColName = activeColumns[colIdx] || col.columnName;
|
|
|
|
|
const resolvedKey = resolveColumnName(currentColName);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={`${col.columnName}-${colIdx}`}
|
|
|
|
|
className="px-2 py-1.5 text-xs truncate"
|
|
|
|
|
style={{ textAlign: col.align || "left" }}
|
|
|
|
|
>
|
|
|
|
|
{String(row[resolvedKey] ?? "")}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 카드 모드 =====
|
|
|
|
|
|
|
|
|
|
interface CardModeViewProps {
|
|
|
|
|
cardGrid?: CardGridConfig;
|
|
|
|
|
data: RowData[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CardModeView({ cardGrid, data }: CardModeViewProps) {
|
|
|
|
|
if (!cardGrid || (cardGrid.cells || []).length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
카드 레이아웃을 설정하세요
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 p-2">
|
|
|
|
|
{data.map((row, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="rounded-md border"
|
|
|
|
|
style={{
|
|
|
|
|
display: "grid",
|
|
|
|
|
gridTemplateColumns:
|
|
|
|
|
cardGrid.colWidths && cardGrid.colWidths.length > 0
|
|
|
|
|
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
|
|
|
|
: "1fr",
|
|
|
|
|
gridTemplateRows:
|
|
|
|
|
cardGrid.rowHeights && cardGrid.rowHeights.length > 0
|
|
|
|
|
? cardGrid.rowHeights
|
|
|
|
|
.map((h) => {
|
2026-02-13 11:27:40 +09:00
|
|
|
if (!h) return "minmax(32px, auto)";
|
|
|
|
|
// px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장
|
|
|
|
|
if (h.endsWith("px")) {
|
|
|
|
|
return `minmax(${h}, auto)`;
|
|
|
|
|
}
|
|
|
|
|
// fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용
|
|
|
|
|
const px = Math.round(parseFloat(h) * 32) || 32;
|
|
|
|
|
return `minmax(${px}px, auto)`;
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
})
|
|
|
|
|
.join(" ")
|
2026-02-13 11:27:40 +09:00
|
|
|
: `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`,
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
gap: `${Number(cardGrid.gap) || 0}px`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{(cardGrid.cells || []).map((cell) => {
|
|
|
|
|
// 가로 정렬 매핑
|
|
|
|
|
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
|
|
|
|
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={cell.id}
|
|
|
|
|
className="overflow-hidden p-1.5"
|
|
|
|
|
style={{
|
|
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
|
|
|
|
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
|
|
|
|
alignItems: justifyMap[cell.align || "left"],
|
|
|
|
|
gridColumn: `${Number(cell.col) || 1} / span ${Number(cell.colSpan) || 1}`,
|
|
|
|
|
gridRow: `${Number(cell.row) || 1} / span ${Number(cell.rowSpan) || 1}`,
|
|
|
|
|
border: cardGrid.showBorder
|
|
|
|
|
? "1px solid hsl(var(--border))"
|
|
|
|
|
: "none",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{renderCellContent(cell, row)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 셀 컨텐츠 렌더링 =====
|
|
|
|
|
|
|
|
|
|
function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode {
|
|
|
|
|
const value = row[cell.columnName];
|
|
|
|
|
const displayValue = value != null ? String(value) : "";
|
|
|
|
|
|
|
|
|
|
switch (cell.type) {
|
|
|
|
|
case "image":
|
|
|
|
|
return displayValue ? (
|
|
|
|
|
<img
|
|
|
|
|
src={displayValue}
|
|
|
|
|
alt={cell.label || cell.columnName}
|
2026-02-13 11:27:40 +09:00
|
|
|
className="h-full max-h-[200px] w-full object-cover rounded"
|
feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현
- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:03:52 +09:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full items-center justify-center bg-muted rounded">
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">No Image</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "badge":
|
|
|
|
|
return (
|
|
|
|
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
|
|
|
|
{displayValue}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "button":
|
|
|
|
|
return (
|
|
|
|
|
<Button variant="outline" size="sm" className="h-6 text-[10px]">
|
|
|
|
|
{cell.label || displayValue}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "text":
|
|
|
|
|
default: {
|
|
|
|
|
// 글자 크기 매핑
|
|
|
|
|
const fontSizeClass =
|
|
|
|
|
cell.fontSize === "sm"
|
|
|
|
|
? "text-[10px]"
|
|
|
|
|
: cell.fontSize === "lg"
|
|
|
|
|
? "text-sm"
|
|
|
|
|
: "text-xs"; // md (기본)
|
|
|
|
|
const isLabelLeft = cell.labelPosition === "left";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}>
|
|
|
|
|
{cell.label && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
|
|
|
{cell.label}{isLabelLeft ? ":" : ""}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className={`${fontSizeClass} truncate`}>{displayValue}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|