Skip to content
Open
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
46 changes: 45 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
AddTableModal,
CardinalityModal,
AnnotationsModal,
InferredRelationshipsModal,
DiffModal,
EditEdgeModal,
EditTableModal,
Expand All @@ -36,6 +37,7 @@ import {
deleteAnnotation,
deleteView,
diffSnapshots,
fetchInferredRelationships,
getSnapshot,
getView,
listAnnotations,
Expand Down Expand Up @@ -71,7 +73,7 @@ import {
} from "./erd/export";
import { exportMermaid } from "./erd/mermaid";
import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants";
import type { Connection, ConnectionTestResult, DiagramView, Project, SchemaDiff, Snapshot, SnapshotDetail, TableAnnotation } from "./types";
import type { Connection, ConnectionTestResult, DiagramView, InferredRelationship, Project, SchemaDiff, Snapshot, SnapshotDetail, TableAnnotation } from "./types";

const TERMINAL_SNAPSHOT_STATUSES = new Set([
"succeeded",
Expand Down Expand Up @@ -184,6 +186,11 @@ export default function App() {
const [connTestResult, setConnTestResult] = useState<ConnectionTestResult | null>(null);
const [isTestingConn, setIsTestingConn] = useState(false);

const [isInferredModalOpen, setIsInferredModalOpen] = useState(false);
const [inferredRelationships, setInferredRelationships] = useState<InferredRelationship[]>([]);
const [isInferredLoading, setIsInferredLoading] = useState(false);
const [inferredError, setInferredError] = useState<string | null>(null);

const [editingEdge, setEditingEdge] = useState<Edge | null>(null);
const [editingNode, setEditingNode] = useState<Node<TableNodeData> | null>(null);
const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false);
Expand Down Expand Up @@ -699,6 +706,22 @@ export default function App() {
.finally(() => setIsTestingConn(false));
}

function onOpenInferredRelationships() {
setInferredError(null);
setInferredRelationships([]);
setIsInferredModalOpen(true);
if (!snapshotId) return;
setIsInferredLoading(true);
fetchInferredRelationships(snapshotId)
.then(setInferredRelationships)
.catch(() => setInferredError("추론된 관계를 불러오지 못했습니다."))
.finally(() => setIsInferredLoading(false));
}

function onCloseInferredRelationships() {
setIsInferredModalOpen(false);
}

function onSelectDiffBase(baseSnapshotId: string) {
setDiffBaseId(baseSnapshotId);
if (!snapshotId) return;
Expand Down Expand Up @@ -1664,6 +1687,19 @@ export default function App() {
>
🗒
</button>
<button
type="button"
onClick={onOpenInferredRelationships}
disabled={!snapshotId}
title={
!snapshotId
? "스냅샷을 먼저 생성하세요"
: "추론된 관계 (선언되지 않은 외래키)"
}
aria-label="추론된 관계"
>
🔗
</button>
<button
type="button"
onClick={onDownloadSvg}
Expand Down Expand Up @@ -1802,6 +1838,14 @@ export default function App() {
onClose={onCloseViews}
/>

<InferredRelationshipsModal
isOpen={isInferredModalOpen}
relationships={inferredRelationships}
isLoading={isInferredLoading}
error={inferredError}
onClose={onCloseInferredRelationships}
/>

<AnnotationsModal
isOpen={isAnnotationsModalOpen}
annotations={annotations}
Expand Down
20 changes: 19 additions & 1 deletion frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { snapshotDetailFromResponse } from './types'
import type { Connection, ConnectionTestResult, DiagramView, DiagramViewDetail, Project, SchemaDiff, ShareLink, Snapshot, SnapshotDetail, SnapshotDetailResponse, SnapshotDiffResult, SnapshotJson, TableAnnotation, ViewLayout } from './types'
import type { Connection, ConnectionTestResult, DiagramView, DiagramViewDetail, InferredRelationship, Project, SchemaDiff, ShareLink, Snapshot, SnapshotDetail, SnapshotDetailResponse, SnapshotDiffResult, SnapshotJson, TableAnnotation, ViewLayout } from './types'

// Default to same-origin in production; set VITE_API_BASE_URL for dev.
const API_BASE: string = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ''
Expand Down Expand Up @@ -401,6 +401,24 @@ export async function deleteView(viewId: string): Promise<void> {
if (!r.ok) throw new Error(`deleteView failed: ${r.status}`)
}

export async function fetchInferredRelationships(snapshotId: string): Promise<InferredRelationship[]> {
if (DEMO_MODE) {
return [
{
child_schema: 'public', child_table: 'orders', child_column: 'member_id',
parent_schema: 'public', parent_table: 'member', parent_column: 'member_id',
confidence: 'high', reason: "column 'member_id' matches table 'member'"
}
]
}
const r = await fetch(
`${API_BASE}/api/snapshots/${encodeURIComponent(snapshotId)}/inferred-relationships`,
{ credentials: 'include' }
)
if (!r.ok) throw new Error(`fetchInferredRelationships failed: ${r.status}`)
return r.json()
}

export async function testConnection(connectionId: string): Promise<ConnectionTestResult> {
if (DEMO_MODE) {
return { ok: true, server_version: 'PostgreSQL 16.2 (demo)', error: null }
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/components/modals/InferredRelationshipsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { InferredRelationshipsModal } from './InferredRelationshipsModal';
import type { InferredRelationship } from '../../types';

const relationships: InferredRelationship[] = [
{
child_schema: 'public', child_table: 'orders', child_column: 'member_id',
parent_schema: 'public', parent_table: 'member', parent_column: 'member_id',
confidence: 'high', reason: "column 'member_id' matches table 'member'",
},
{
child_schema: 'public', child_table: 'orders', child_column: 'coupon_id',
parent_schema: 'public', parent_table: 'coupon', parent_column: 'id',
confidence: 'medium', reason: "column 'coupon_id' matches table 'coupon' (type differs)",
},
];

const baseProps = {
isOpen: true,
relationships,
isLoading: false,
error: null,
onClose: vi.fn(),
};

afterEach(() => cleanup());

describe('InferredRelationshipsModal', () => {
it('lists inferred relationships with confidence labels', () => {
render(<InferredRelationshipsModal {...baseProps} />);
expect(screen.getByText('orders.member_id → member.member_id')).toBeInTheDocument();
expect(screen.getByText('높음')).toBeInTheDocument();
expect(screen.getByText('orders.coupon_id → coupon.id')).toBeInTheDocument();
expect(screen.getByText('보통')).toBeInTheDocument();
});

it('shows a loading status while analyzing', () => {
render(<InferredRelationshipsModal {...baseProps} isLoading relationships={[]} />);
expect(screen.getByRole('status')).toHaveTextContent('분석 중');
});

it('shows an empty-state hint when nothing is inferred', () => {
render(<InferredRelationshipsModal {...baseProps} relationships={[]} />);
expect(screen.getByText('추론된 관계가 없습니다.')).toBeInTheDocument();
});

it('renders nothing when closed', () => {
render(<InferredRelationshipsModal {...baseProps} isOpen={false} />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
84 changes: 84 additions & 0 deletions frontend/src/components/modals/InferredRelationshipsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import type { InferredRelationship } from '../../types';
import { useDialogAccessibility } from './useDialogAccessibility';

interface InferredRelationshipsModalProps {
isOpen: boolean;
relationships: InferredRelationship[];
isLoading: boolean;
error: string | null;
onClose: () => void;
}

export function InferredRelationshipsModal({
isOpen,
relationships,
isLoading,
error,
onClose,
}: InferredRelationshipsModalProps) {
const dialogRef = useDialogAccessibility(isOpen, onClose);
if (!isOpen) return null;

return (
<div className="modalOverlay">
<div
className="modalContent inferredModal"
role="dialog"
aria-modal="true"
aria-labelledby="inferred-title"
ref={dialogRef}
tabIndex={-1}
>
<div className="modalHeader">
<div>
<h3 id="inferred-title">추론된 관계</h3>
<p className="modalLead">
선언되지 않은 외래키를 이름 규칙으로 추론합니다. 참고용이며 실제 제약이 아닙니다.
</p>
</div>
<button type="button" onClick={onClose}>닫기</button>
</div>

{isLoading ? (
<span className="field-hint" role="status">분석 중…</span>
) : null}
{error ? (
<div className="error" role="alert">{error}</div>
) : null}

{!isLoading && !error ? (
<section className="inferredModal__list" aria-label="추론된 관계 목록">
{relationships.length === 0 ? (
<span className="field-hint">추론된 관계가 없습니다.</span>
) : (
<ul>
{relationships.map((r, i) => (
<li
key={`${r.child_schema}.${r.child_table}.${r.child_column}-${i}`}
className="inferredModal__item"
title={r.reason}
>
<span className="inferredModal__rel">
{r.child_table}.{r.child_column} → {r.parent_table}.
{r.parent_column}
</span>
<span
className={
r.confidence === 'high'
? 'statusPill statusPill--succeeded'
: 'statusPill'
}
>
{r.confidence === 'high' ? '높음' : '보통'}
</span>
</li>
))}
</ul>
)}
</section>
) : null}
</div>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/modals/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { AddTableModal } from './AddTableModal';
export { AnnotationsModal } from './AnnotationsModal';
export { InferredRelationshipsModal } from './InferredRelationshipsModal';
export { CardinalityModal } from './CardinalityModal';
export { DiffModal } from './DiffModal';
export { EditEdgeModal } from './EditEdgeModal';
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ export type TableAnnotation = {
updated_at: string
}

export type InferredRelationship = {
child_schema: string
child_table: string
child_column: string
parent_schema: string
parent_table: string
parent_column: string
confidence: string
reason: string
}

export type SnapshotDetailResponse = Omit<SnapshotDetail, 'error_message'> & {
error_message: unknown
}
Expand Down