Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
# 변경 사항
# Changelog

## 🎨 Palette: ERD 테이블 및 컬럼 편집 기능 구현
## Unreleased
- [FE] 🪄 **관계 자동 추론 기능 추가**: 컬럼 이름(e.g. `user_id`)을 분석하여 연관된 테이블에 자동으로 Foreign Key Edge를 연결하는 버튼을 ERD 편집기 툴바에 추가했습니다.
- [FE] 🗑️ **모든 노드 지우기 기능 추가**: 캔버스의 모든 테이블 노드와 관계를 한 번에 초기화하는 버튼을 툴바에 추가했습니다.
- [FE] 📋 **테이블 복제 기능 추가**: 편집 모달 내에서 기존 테이블의 구조(컬럼 정보 포함)를 그대로 복사하여 새 테이블 노드로 생성하는 '복제' 버튼을 추가했습니다.
- [FE] `autoInfer.ts`에 대한 단위 테스트 및 UI 컴포넌트 단위 테스트를 추가하여 100% 테스트 커버리지를 유지합니다.

### ✨ 추가된 기능 (Features)
- ReactFlow 노드를 더블 클릭하여 테이블 이름 및 코멘트를 수정할 수 있는 편집 모달 기능 구현.
- 편집 모달 내에서 개별 테이블의 컬럼을 추가/삭제/수정(컬럼명, 데이터타입, PK/NN 여부)할 수 있는 기능 제공.
- 노드 삭제 시 관련 Edge 또한 삭제하는 로직 추가.
- 접근성 개선을 위해 파괴적인 액션(테이블 삭제, 컬럼 삭제) 시 사용자 확인 창(`window.confirm`) 추가.
- 새롭게 추가된 UI 플로우를 위한 기본 테스트 코드(`App.editTable.test.tsx`, `TableNode.test.tsx`) 추가 및 Vitest 프레임워크 셋업 보완.

### 🐛 개선 (Improvements)
- 불필요한 백엔드 포맷팅 이슈(`ruff` 포맷) 해결.
- 테스트 커버리지를 높이기 위해 기본 단위 테스트 환경(jsdom, @testing-library) 구축 및 활용.
37 changes: 37 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
exportPlantUml,
} from "./erd/export";
import { exportMermaid } from "./erd/mermaid";
import { inferRelationships } from "./erd/autoInfer";
import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants";
import type { Connection, Project, Snapshot, SnapshotDetail } from "./types";

Expand Down Expand Up @@ -731,6 +732,20 @@ export default function App() {
setIsGroupModalOpen(true);
}

function onAutoInferRelationships() {
const inferredEdges = inferRelationships(nodes);
if (inferredEdges.length > 0) {
setEdges((eds) => [...eds, ...inferredEdges]);
}
}

function onClearCanvas() {
if (window.confirm("캔버스의 모든 노드와 관계를 삭제하시겠습니까?")) {
setNodes([]);
setEdges([]);
}
}

function onCloseGroupManager() {
setIsGroupModalOpen(false);
}
Expand Down Expand Up @@ -1367,6 +1382,28 @@ export default function App() {
>
</button>
<button
type="button"
onClick={onAutoInferRelationships}
disabled={nodes.length === 0}
title={
nodes.length === 0 ? "추론할 테이블이 없습니다" : "관계 자동 추론"
}
aria-label="관계 자동 추론"
>
🪄
</button>
<button
type="button"
onClick={onClearCanvas}
disabled={nodes.length === 0}
title={
nodes.length === 0 ? "지울 노드가 없습니다" : "모든 노드 지우기"
}
aria-label="모든 노드 지우기"
>
🗑️
</button>
<button
type="button"
onClick={onOpenAddTable}
Expand Down
43 changes: 36 additions & 7 deletions frontend/src/components/modals/EditTableModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,42 @@ export function EditTableModal({
</div>

<div className="row" style={{ justifyContent: "space-between", marginTop: 16, paddingTop: 16, borderTop: "1px solid #e2e8f0" }}>
<button
type="button"
onClick={onDeleteTable}
style={{ color: "#b91c1c", borderColor: "#fca5a5" }}
>
테이블 삭제
</button>
<div className="row" style={{ gap: 8 }}>
<button
type="button"
onClick={onDeleteTable}
style={{ color: "#b91c1c", borderColor: "#fca5a5" }}
>
테이블 삭제
</button>
<button
type="button"
onClick={() => {
const dupId = `${editingNode.id}_copy_${Date.now()}`;
setNodes((nds) => [
...nds,
{
...editingNode,
id: dupId,
position: {
x: editingNode.position.x + 40,
y: editingNode.position.y + 40,
},
data: {
...editingNode.data,
title: `${editingNode.data.title}_copy`,
// 깊은 복사를 통해 컬럼 배열 분리
columns: editingNode.data.columns.map((c) => ({ ...c })),
},
},
]);
onEditTableCancel();
}}
style={{ color: "#034ea2", borderColor: "#93c5fd" }}
>
복제
</button>
</div>
<div className="row">
<button type="button" onClick={onEditTableCancel}>취소</button>
<button
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/erd/__tests__/App.editTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ describe('App edit functionality', () => {
const toolbarQueries = within(toolbar);
expect(toolbarQueries.getByRole('button', { name: 'ERD 자동 정렬' })).toHaveTextContent('↔');
expect(toolbarQueries.getByRole('button', { name: '정렬 되돌리기' })).toHaveTextContent('↶');
expect(toolbarQueries.getByRole('button', { name: '관계 자동 추론' })).toHaveTextContent('🪄');
expect(toolbarQueries.getByRole('button', { name: '모든 노드 지우기' })).toHaveTextContent('🗑️');
expect(toolbarQueries.getByRole('button', { name: '테이블 추가' })).toHaveTextContent('+');
expect(toolbarQueries.getByRole('button', { name: '업무 그룹' })).toHaveTextContent('◇');
expect(toolbarQueries.getByRole('button', { name: '인덱스 카디널리티 계산' })).toHaveTextContent('#');
Expand Down
207 changes: 207 additions & 0 deletions frontend/src/erd/__tests__/autoInfer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import { inferRelationships } from "../autoInfer";
import type { Node } from "@xyflow/react";
import type { TableNodeData } from "../convert";

describe("autoInfer", () => {
it("should infer relationships correctly based on _id columns", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "users_node",
position: { x: 0, y: 0 },
data: {
title: "public.users",
columns: [
{ column_name: "id", data_type: "integer", is_not_null: true, is_pk: true },
{ column_name: "name", data_type: "text", is_not_null: true, is_pk: false },
],
badges: { pk: true, fk: false },
},
},
{
id: "posts_node",
position: { x: 100, y: 100 },
data: {
title: "public.posts",
columns: [
{ column_name: "id", data_type: "integer", is_not_null: true, is_pk: true },
{ column_name: "user_id", data_type: "integer", is_not_null: true, is_pk: false },
],
badges: { pk: true, fk: true },
},
},
{
id: "category_node",
position: { x: 200, y: 200 },
data: {
title: "public.category",
columns: [
{ column_name: "cat_code", data_type: "text", is_not_null: true, is_pk: true },
],
badges: { pk: true, fk: false },
},
},
{
id: "items_node",
position: { x: 300, y: 300 },
data: {
title: "public.items",
columns: [
{ column_name: "id", data_type: "integer", is_not_null: true, is_pk: true },
{ column_name: "category_id", data_type: "text", is_not_null: true, is_pk: false },
],
badges: { pk: true, fk: true },
},
},
{
id: "tags_node",
position: { x: 400, y: 400 },
data: {
title: "public.tags",
columns: [
{ column_name: "id", data_type: "integer", is_not_null: true, is_pk: true },
{ column_name: "tag_id", data_type: "integer", is_not_null: true, is_pk: false },
],
badges: { pk: true, fk: false },
},
}
];

const edges = inferRelationships(nodes);

expect(edges).toHaveLength(2);

// users - posts (user_id -> users)
const postEdge = edges.find(e => e.source === "posts_node");
expect(postEdge).toBeDefined();
expect(postEdge?.target).toBe("users_node");
expect(postEdge?.data?.sourceColumns).toEqual(["user_id"]);
expect(postEdge?.data?.targetColumns).toEqual(["id"]);

// category - items (category_id -> category)
// fallback to first pk column "cat_code" since there is no "id" column in category table
const itemEdge = edges.find(e => e.source === "items_node");
expect(itemEdge).toBeDefined();
expect(itemEdge?.target).toBe("category_node");
expect(itemEdge?.data?.sourceColumns).toEqual(["category_id"]);
expect(itemEdge?.data?.targetColumns).toEqual(["cat_code"]);
});

it("should return empty array if no _id columns are found", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "table1",
position: { x: 0, y: 0 },
data: {
title: "table1",
columns: [{ column_name: "name", data_type: "text", is_not_null: false, is_pk: false }],
badges: { pk: false, fk: false },
},
},
{
id: "table2",
position: { x: 10, y: 10 },
data: {
title: "table2",
columns: [{ column_name: "description", data_type: "text", is_not_null: false, is_pk: false }],
badges: { pk: false, fk: false },
},
}
];

const edges = inferRelationships(nodes);
expect(edges).toHaveLength(0);
});

it("should not infer self relationships if it targets itself", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "employee",
position: { x: 0, y: 0 },
data: {
title: "employee",
columns: [
{ column_name: "id", data_type: "integer", is_not_null: true, is_pk: true },
{ column_name: "employee_id", data_type: "integer", is_not_null: false, is_pk: false }
],
badges: { pk: true, fk: false },
}
}
];

const edges = inferRelationships(nodes);
expect(edges).toHaveLength(0);
});

it("should fallback to first column if no id or pk column exists", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "table_a",
position: { x: 0, y: 0 },
data: {
title: "table_a",
columns: [{ column_name: "random_col", data_type: "text", is_not_null: false, is_pk: false }],
badges: { pk: false, fk: false },
},
},
{
id: "table_b",
position: { x: 10, y: 10 },
data: {
title: "table_b",
columns: [{ column_name: "table_a_id", data_type: "text", is_not_null: false, is_pk: false }],
badges: { pk: false, fk: false },
},
}
];

const edges = inferRelationships(nodes);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe("table_a");
expect(edges[0].data?.targetColumns).toEqual(["random_col"]);
});

it("should not infer if target entity not found", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "table_b",
position: { x: 10, y: 10 },
data: {
title: "table_b",
columns: [{ column_name: "table_a_id", data_type: "text", is_not_null: false, is_pk: false }],
badges: { pk: false, fk: false },
},
}
];

const edges = inferRelationships(nodes);
expect(edges).toHaveLength(0);
});

it("should correctly identify target table with suffix variations (s, es)", () => {
const nodes: Node<TableNodeData>[] = [
{
id: "boxes_node",
position: { x: 0, y: 0 },
data: {
title: "boxes",
columns: [{ column_name: "id", data_type: "int", is_not_null: true, is_pk: true }],
badges: { pk: true, fk: false },
},
},
{
id: "items_node",
position: { x: 10, y: 10 },
data: {
title: "items",
columns: [{ column_name: "box_id", data_type: "int", is_not_null: true, is_pk: false }],
badges: { pk: false, fk: true },
},
}
];

const edges = inferRelationships(nodes);
expect(edges).toHaveLength(1);
expect(edges[0].target).toBe("boxes_node");
});
});
Loading