From 3893e260b0960ddcff953057b15bfaaf3cc13efd Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:09:00 +0000 Subject: [PATCH 01/43] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20form=20wr?= =?UTF-8?q?appers=20for=20native=20keyboard=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 ++ frontend/src/App.tsx | 65 ++++++++++++++++++++++---------------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index bd0f73248..0762fd310 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -57,3 +57,6 @@ ## 2026-07-30 - Add window.confirm for destructive actions **Learning:** Destructive actions like deleting groups and edge relationships previously occurred immediately without user confirmation. **Action:** Always wrap delete operations with window.confirm() dialogs and ensure corresponding tests successfully mock window.confirm. +## 2025-08-01 - Add form wrappers for native keyboard submission +**Learning:** In React input elements combined with submit buttons, wrapping them in a `
` element handles 'Enter' key presses implicitly on interactive UI parts (like inline creators or search bars) and increases accessibility for keyboard users compared to standalone input/button pairs enclosed in `
`s. +**Action:** Always wrap data submission input and button groups in a `` element instead of a plain `
`. Set the primary action button to `type="submit"` and handle `onSubmit` (preventing default) rather than only utilizing `onClick` on the button. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0fa62ede3..11018040b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1080,15 +1080,14 @@ export default function App() {
-
+ { e.preventDefault(); onCreateProject(); }}> setProjectName(e.target.value)} /> -
+ {createProjectHint ? ( {createProjectHint} @@ -1126,7 +1125,7 @@ export default function App() {
-
+
{ e.preventDefault(); onCreateConnection(); }}>
+
@@ -1309,20 +1307,19 @@ export default function App() {

프로젝트

프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.

-
+
{ e.preventDefault(); onCreateProject(); }}> setProjectName(event.currentTarget.value)} /> -
+
@@ -1363,16 +1360,18 @@ export default function App() { 편집기 열기
- +
e.preventDefault()}> + +
- +
e.preventDefault()}> + +
- ) + ); } return { @@ -85,55 +132,97 @@ vi.mock('@xyflow/react', async () => { Controls: () => , MiniMap: () => , Handle: () => , - Position: { Top: 'top', Left: 'left', Right: 'right', Bottom: 'bottom' }, + Position: { Top: "top", Left: "left", Right: "right", Bottom: "bottom" }, ReactFlow: ReactFlowMock, - ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), addEdge: (next: unknown, current: unknown[]) => [...current, next], useNodesState: (initial: unknown[]) => { - const [value, setValue] = React.useState(initial) - return [value, setValue, vi.fn()] + const [value, setValue] = React.useState(initial); + return [value, setValue, vi.fn()]; }, useEdgesState: (initial: unknown[]) => { - const [value, setValue] = React.useState(initial) - return [value, setValue, vi.fn()] + const [value, setValue] = React.useState(initial); + return [value, setValue, vi.fn()]; }, __fixtures: { initialNode, otherNode, edge }, - } -}) + }; +}); -vi.mock('./erd/convert', async () => { - const flow = (await import('@xyflow/react')) as any +vi.mock("./erd/convert", async () => { + const flow = (await import("@xyflow/react")) as any; return { snapshotToGraph: vi.fn(() => ({ nodes: [flow.__fixtures.initialNode, flow.__fixtures.otherNode], edges: [flow.__fixtures.edge], })), - } -}) + }; +}); -vi.mock('./components/modals', () => ({ +vi.mock("./components/modals", () => ({ AddTableModal: (props: any) => (
-
), EditEdgeModal: (props: any) => (
-
@@ -142,87 +231,267 @@ vi.mock('./components/modals', () => ({
{props.shareLinkUrl} {props.shareLinkError} -
), GroupModal: (props: any) => (
-
), CardinalityModal: (props: any) => { const recommendation = { - index_name: 'idx_users_email', - columns: ['email'], - access_method: 'btree', + index_name: "idx_users_email", + columns: ["email"], + access_method: "btree", estimated_distinct: 50, cardinality_ratio: 0.5, - strength: 'recommended', - reason: 'selective', - source: 'cardinality-wizard', - } + strength: "recommended", + reason: "selective", + source: "cardinality-wizard", + }; return (
{props.formatPercent(0.5)} - {props.strengthLabel('recommended')} - {props.strengthLabel('consider')} - {props.strengthLabel('skip')} -
- ) + ); }, EditTableModal: (props: any) => (
- -
+
-
), -})) +})); -import App, { DiagramTable } from './App' -import { snapshotToGraph } from './erd/convert' +import App, { DiagramTable } from "./App"; +import { snapshotToGraph } from "./erd/convert"; const projects = [ - { project_space_uuid: 'p1', project_name: '' }, - { project_space_uuid: 'p2', project_name: 'HR' }, -] -const connections = [{ db_connection_uuid: 'c1', conn_name: 'Warehouse' }] + { project_space_uuid: "p1", project_name: "" }, + { project_space_uuid: "p2", project_name: "HR" }, +]; +const connections = [{ db_connection_uuid: "c1", conn_name: "Warehouse" }]; const snapshots = [ - { schema_snapshot_uuid: 's1', status: 'succeeded', schema_filter: 'billing' }, - { schema_snapshot_uuid: 's2', status: 'failed', schema_filter: null }, -] + { schema_snapshot_uuid: "s1", status: "succeeded", schema_filter: "billing" }, + { schema_snapshot_uuid: "s2", status: "failed", schema_filter: null }, +]; beforeEach(() => { - vi.clearAllMocks() - api.getMe.mockResolvedValue({ subject: 'user', display_name: 'User', user_account_uuid: 'u' }) - api.listProjects.mockResolvedValue(projects) - api.listConnections.mockResolvedValue(connections) - api.listSnapshots.mockResolvedValue(snapshots) - api.createProject.mockResolvedValue({ project_space_uuid: 'p3', project_name: 'New' }) - api.createConnection.mockResolvedValue({ db_connection_uuid: 'c2', conn_name: 'New DB' }) - api.createSnapshot.mockResolvedValue({ schema_snapshot_uuid: 's3', status: 'queued', schema_filter: 'public' }) + vi.clearAllMocks(); + api.getMe.mockResolvedValue({ + subject: "user", + display_name: "User", + user_account_uuid: "u", + }); + api.listProjects.mockResolvedValue(projects); + api.listConnections.mockResolvedValue(connections); + api.listSnapshots.mockResolvedValue(snapshots); + api.createProject.mockResolvedValue({ + project_space_uuid: "p3", + project_name: "New", + }); + api.createConnection.mockResolvedValue({ + db_connection_uuid: "c2", + conn_name: "New DB", + }); + api.createSnapshot.mockResolvedValue({ + schema_snapshot_uuid: "s3", + status: "queued", + schema_filter: "public", + }); api.getSnapshot.mockResolvedValue({ - schema_snapshot_uuid: 's3', - status: 'succeeded', - schema_filter: 'public', + schema_snapshot_uuid: "s3", + status: "succeeded", + schema_filter: "public", error_message: null, snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] }, - }) - api.createShareLink.mockResolvedValue({ url: 'http://localhost/api/share/one' }) + }); + api.createShareLink.mockResolvedValue({ + url: "http://localhost/api/share/one", + }); exports.inferRelationships.mockReturnValue([ - { id: 'inferred', source: 'table-1', target: 'table-2', label: 'fk_inferred' }, - ]) - vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - Object.defineProperty(navigator, 'clipboard', { + { + id: "inferred", + source: "table-1", + target: "table-2", + label: "fk_inferred", + }, + ]); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, - }) -}) + }); +}); afterEach(() => { - cleanup() - vi.useRealTimers() - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); async function renderReadyApp() { - render() - await screen.findByRole('heading', { name: '대시보드' }) -} - -function buildTestDsn(scheme: string, authority: string, path = '') { - const pathSuffix = path ? `/${path}` : '' - return [scheme, ':', '/', '/', authority, pathSuffix].join('') + render(); + await screen.findByRole("heading", { name: "대시보드" }); } function forceClick(button: HTMLButtonElement) { - button.disabled = false - button.removeAttribute('disabled') - button.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + button.disabled = false; + button.removeAttribute("disabled"); + button.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); } -describe('App orchestration coverage', () => { - it('shows loading and explicit authentication failure', async () => { - let rejectMe!: (reason?: unknown) => void - api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject })) - render() - expect(screen.getByText('Authenticating…')).toBeInTheDocument() - await act(async () => rejectMe(new Error('denied'))) - expect(await screen.findByRole('heading', { name: 'Authentication required' })).toBeInTheDocument() - expect(screen.getByRole('alert')).toHaveTextContent('denied') - }) - - it('navigates dashboard, project, and diagram states including empty/search branches', async () => { - const user = userEvent.setup() - await renderReadyApp() - expect(screen.getAllByText('<Billing & Core>').length).toBeGreaterThan(0) - fireEvent.click(screen.getByRole('button', { name: '전체 보기' })) - expect(screen.getByRole('heading', { name: '프로젝트' })).toBeInTheDocument() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[1]!) - expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() - - const searchInput = screen.getByLabelText('다이어그램 검색') - // Wait for the snapshots to load before searching. - await waitFor(() => expect(screen.queryByText('아직 다이어그램 스냅샷이 없습니다. 편집기에서 데이터베이스를 역공학해 시작하세요.')).not.toBeInTheDocument()) - - const diagramSearchCallCounts = { - listProjects: api.listProjects.mock.calls.length, - listConnections: api.listConnections.mock.calls.length, - listSnapshots: api.listSnapshots.mock.calls.length, - getSnapshot: api.getSnapshot.mock.calls.length, - } - const diagramSearchLocation = window.location.href - - await user.clear(searchInput) - await user.type(searchInput, 'no-match{Enter}') - - const searchForm = searchInput.closest('form') - const searchSubmitEvent = new Event('submit', { cancelable: true, bubbles: true }) - if (searchForm) searchForm.dispatchEvent(searchSubmitEvent) - expect(searchSubmitEvent.defaultPrevented).toBe(true) - - expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument() - expect(api.listProjects).toHaveBeenCalledTimes(diagramSearchCallCounts.listProjects) - expect(api.listConnections).toHaveBeenCalledTimes(diagramSearchCallCounts.listConnections) - expect(api.listSnapshots).toHaveBeenCalledTimes(diagramSearchCallCounts.listSnapshots) - expect(api.getSnapshot).toHaveBeenCalledTimes(diagramSearchCallCounts.getSnapshot) - expect(window.location.href).toBe(diagramSearchLocation) - - await user.clear(searchInput) - await user.type(searchInput, 'failed{Enter}') - expect(screen.getByText('ERD_all_2')).toBeInTheDocument() - expect(api.listProjects).toHaveBeenCalledTimes(diagramSearchCallCounts.listProjects) - expect(api.listConnections).toHaveBeenCalledTimes(diagramSearchCallCounts.listConnections) - expect(api.listSnapshots).toHaveBeenCalledTimes(diagramSearchCallCounts.listSnapshots) - expect(api.getSnapshot).toHaveBeenCalledTimes(diagramSearchCallCounts.getSnapshot) - expect(window.location.href).toBe(diagramSearchLocation) - fireEvent.click(screen.getByRole('button', { name: '편집기 열기' })) - expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() - - cleanup() - api.listProjects.mockResolvedValueOnce([]) - await act(async () => render()) - await screen.findByText('아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.') - fireEvent.click(screen.getByRole('button', { name: '전체 보기' })) - expect(screen.getByText('프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - expect(screen.getByText('프로젝트를 선택하세요.')).toBeInTheDocument() - }) - - it('creates projects, validates and creates connections, and starts a snapshot', async () => { - const user = userEvent.setup() - await renderReadyApp() - - // 1. Projects view inline-create via Enter - await user.click(screen.getByRole('button', { name: '프로젝트' })) - const inlineInput = screen.getByLabelText('새 프로젝트 이름') - await user.clear(inlineInput) - // Assert invalid (whitespace only) does not submit - vi.mocked(api.createProject).mockClear() - await user.type(inlineInput, ' {Enter}') - expect(api.createProject).not.toHaveBeenCalled() - // Assert valid submits exactly once - await user.clear(inlineInput) - await user.type(inlineInput, 'InlineProject{Enter}') - await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1)) - expect(api.createProject).toHaveBeenCalledWith('InlineProject') - - // Navigate back to Editor view - await user.click(screen.getByRole('button', { name: '편집기' })) - - // 2. Editor view New Project via Enter - const newProjectInput = screen.getByLabelText('New project') - await user.clear(newProjectInput) - vi.mocked(api.createProject).mockClear() - vi.mocked(api.createProject).mockResolvedValueOnce({ project_space_uuid: 'p4', project_name: 'New' }) - await user.type(newProjectInput, ' {Enter}') - expect(api.createProject).not.toHaveBeenCalled() - await user.clear(newProjectInput) - await user.type(newProjectInput, 'New{Enter}') - await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1)) - expect(api.createProject).toHaveBeenCalledWith('New') - - const projectSelect = screen.getByLabelText('Project') - const connectionName = screen.getByLabelText('New connection (DSN)') - const dsn = screen.getByLabelText('Connection DSN') - const saveConnection = screen.getByRole('button', { name: 'Save connection' }) - vi.mocked(api.createConnection).mockClear() - - fireEvent.change(projectSelect, { target: { value: '' } }) - expect(saveConnection).toBeDisabled() - await user.type(dsn, '{Enter}') - expect(api.createConnection).not.toHaveBeenCalled() - - fireEvent.change(projectSelect, { target: { value: 'p4' } }) - fireEvent.change(connectionName, { target: { value: ' ' } }) - fireEvent.change(dsn, { target: { value: ' ' } }) - expect(saveConnection).toBeDisabled() - await user.type(dsn, '{Enter}') - expect(api.createConnection).not.toHaveBeenCalled() - - fireEvent.change(connectionName, { target: { value: 'target-db' } }) - await user.clear(dsn) - expect(saveConnection).toBeDisabled() - await user.type(dsn, '{Enter}') - expect(api.createConnection).not.toHaveBeenCalled() - - fireEvent.change(dsn, { target: { value: buildTestDsn('postgresql', '[') } }) - await user.type(dsn, '{Enter}') - expect(screen.getByRole('alert')).toHaveTextContent('Connection DSN must use') - expect(api.createConnection).not.toHaveBeenCalled() - - fireEvent.change(dsn, { target: { value: buildTestDsn('http', 'bad.example', 'db') } }) - await user.type(dsn, '{Enter}') - expect(screen.getByRole('alert')).toHaveTextContent('Connection DSN must use') - expect(api.createConnection).not.toHaveBeenCalled() - expect(dsn).toHaveValue('') - - // 3. Editor view New Connection via Enter - const validDsn = buildTestDsn('postgresql', 'db.example', 'test') - vi.mocked(api.createConnection).mockClear() - fireEvent.change(dsn, { target: { value: validDsn } }) - await user.type(dsn, '{Enter}') - await waitFor(() => expect(api.createConnection).toHaveBeenCalledTimes(1)) - expect(api.createConnection).toHaveBeenCalledWith('p4', 'target-db', validDsn) - - fireEvent.change(screen.getByLabelText('Schema filter (optional)'), { target: { value: ' public ' } }) - fireEvent.click(screen.getByRole('button', { name: 'Reverse engineer → snapshot' })) - await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledWith('p4', 'c2', 'public')) - expect(screen.getByText('스냅샷 생성 중...')).toBeInTheDocument() - }) - - it('submits canvas search with Enter without navigation or API calls', async () => { - const user = userEvent.setup() - await renderReadyApp() - await user.click(screen.getByRole('button', { name: '편집기' })) - - const canvasSearchInput = screen.getByLabelText('테이블 또는 컬럼 검색') - const callCounts = { - listProjects: api.listProjects.mock.calls.length, - listConnections: api.listConnections.mock.calls.length, - listSnapshots: api.listSnapshots.mock.calls.length, - getSnapshot: api.getSnapshot.mock.calls.length, - } - const locationBeforeSubmit = window.location.href - - await user.type(canvasSearchInput, 'users{Enter}') - - expect(canvasSearchInput).toHaveValue('users') - expect(screen.getByText('0개 테이블 일치', { exact: false })).toBeInTheDocument() - expect(api.listProjects).toHaveBeenCalledTimes(callCounts.listProjects) - expect(api.listConnections).toHaveBeenCalledTimes(callCounts.listConnections) - expect(api.listSnapshots).toHaveBeenCalledTimes(callCounts.listSnapshots) - expect(api.getSnapshot).toHaveBeenCalledTimes(callCounts.getSnapshot) - expect(window.location.href).toBe(locationBeforeSubmit) - }) - - it('polls a terminal snapshot, builds graph state, and exercises editor handlers', async () => { - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - const openButtons = await screen.findAllByRole('button', { name: '열기' }) - vi.useFakeTimers() - fireEvent.click(openButtons[0]!) +describe("App orchestration coverage", () => { + it("shows loading and explicit authentication failure", async () => { + let rejectMe!: (reason?: unknown) => void; + api.getMe.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectMe = reject; + }), + ); + render(); + expect(screen.getByText("Authenticating…")).toBeInTheDocument(); + await act(async () => rejectMe(new Error("denied"))); + expect( + await screen.findByRole("heading", { name: "Authentication required" }), + ).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("denied"); + }); + + it("navigates dashboard, project, and diagram states including empty/search branches", async () => { + await renderReadyApp(); + expect( + screen.getAllByText("<Billing & Core>").length, + ).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: "전체 보기" })); + expect( + screen.getByRole("heading", { name: "프로젝트" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[1]!); + expect( + screen.getByRole("heading", { name: "다이어그램" }), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("다이어그램 검색"), { + target: { value: "no-match" }, + }); + expect(screen.getByText("검색 결과가 없습니다.")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("다이어그램 검색"), { + target: { value: "failed" }, + }); + expect(screen.getByText("ERD_all_2")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "편집기 열기" })); + expect( + screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }), + ).toBeInTheDocument(); + + cleanup(); + api.listProjects.mockResolvedValueOnce([]); + await act(async () => render()); + await screen.findByText( + "아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.", + ); + fireEvent.click(screen.getByRole("button", { name: "전체 보기" })); + expect( + screen.getByText( + "프로젝트가 없습니다. 이름을 입력해 새 프로젝트를 만드세요.", + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + expect(screen.getByText("프로젝트를 선택하세요.")).toBeInTheDocument(); + }); + + it("creates projects, validates and creates connections, and starts a snapshot", async () => { + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + + fireEvent.change(screen.getByLabelText("New project"), { + target: { value: " New " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => expect(api.createProject).toHaveBeenCalledWith("New")); + + const dsn = screen.getByLabelText("Connection DSN"); + fireEvent.change(dsn, { target: { value: "postgresql://[" } }); + fireEvent.click(screen.getByRole("button", { name: "Save connection" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Connection DSN must use", + ); + fireEvent.change(dsn, { target: { value: "http://bad.example/db" } }); + fireEvent.click(screen.getByRole("button", { name: "Save connection" })); + expect(screen.getByRole("alert")).toHaveTextContent( + "Connection DSN must use", + ); + expect(dsn).toHaveValue(""); + + fireEvent.change(dsn, { + target: { value: "postgresql://db.example/test" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save connection" })); + await waitFor(() => + expect(api.createConnection).toHaveBeenCalledWith( + "p3", + "target-db", + "postgresql://db.example/test", + ), + ); + + fireEvent.change(screen.getByLabelText("Schema filter (optional)"), { + target: { value: " public " }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Reverse engineer → snapshot" }), + ); + await waitFor(() => + expect(api.createSnapshot).toHaveBeenCalledWith("p3", "c2", "public"), + ); + expect(screen.getByText("스냅샷 생성 중...")).toBeInTheDocument(); + }); + + it("polls a terminal snapshot, builds graph state, and exercises editor handlers", async () => { + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + const openButtons = await screen.findAllByRole("button", { name: "열기" }); + vi.useFakeTimers(); + fireEvent.click(openButtons[0]!); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - expect(api.getSnapshot).toHaveBeenCalledWith('s1') - expect(screen.getByTestId('node-count')).toHaveTextContent('2') - - const canvasSearchInput = screen.getByLabelText('테이블 또는 컬럼 검색') - fireEvent.change(canvasSearchInput, { target: { value: 'users' } }) - const canvasSearchForm = canvasSearchInput.closest('form') - const canvasSubmitEvent = new Event('submit', { cancelable: true, bubbles: true }) - if (canvasSearchForm) canvasSearchForm.dispatchEvent(canvasSubmitEvent) - expect(canvasSubmitEvent.defaultPrevented).toBe(true) - - expect(screen.getByText('1개 테이블 일치', { exact: false })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' })) + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(api.getSnapshot).toHaveBeenCalledWith("s1"); + expect(screen.getByTestId("node-count")).toHaveTextContent("2"); + + const canvasSearchInput = screen.getByLabelText("테이블 또는 컬럼 검색"); + fireEvent.change(canvasSearchInput, { target: { value: "users" } }); + const canvasSearchForm = canvasSearchInput.closest("form"); + const canvasSubmitEvent = new Event("submit", { + cancelable: true, + bubbles: true, + }); + if (canvasSearchForm) canvasSearchForm.dispatchEvent(canvasSubmitEvent); + expect(canvasSubmitEvent.defaultPrevented).toBe(true); + expect( + screen.getByText("1개 테이블 일치", { exact: false }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" })); await act(async () => { - vi.runOnlyPendingTimers() - await Promise.resolve() - }) - expect(screen.getByText('정렬 완료', { exact: false })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '정렬 되돌리기' })) - expect(screen.getByText('되돌렸습니다', { exact: false })).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('flow-connect')) - fireEvent.click(screen.getByTestId('edge-label')) - fireEvent.click(screen.getByTestId('edge-submit')) - fireEvent.click(screen.getByTestId('flow-edge')) - fireEvent.click(screen.getByTestId('edge-cancel')) - fireEvent.click(screen.getByTestId('flow-edge-unlabeled')) - fireEvent.click(screen.getByTestId('edge-cancel')) - fireEvent.click(screen.getByTestId('flow-edge')) - vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true) - fireEvent.click(screen.getByTestId('edge-delete')) - fireEvent.click(screen.getByTestId('edge-delete')) - - fireEvent.doubleClick(screen.getByTestId('flow-node')) - fireEvent.submit(screen.getByTestId('table-empty-form')) - fireEvent.click(screen.getByTestId('table-cancel')) - fireEvent.doubleClick(screen.getByTestId('flow-node')) - fireEvent.submit(screen.getByTestId('table-form')) - fireEvent.doubleClick(screen.getByTestId('flow-node')) - fireEvent.click(screen.getByTestId('table-cancel')) - fireEvent.doubleClick(screen.getByTestId('flow-node')) - vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true) - fireEvent.click(screen.getByTestId('table-delete')) - fireEvent.click(screen.getByTestId('table-delete')) - }) - - it('adds nodes and exercises groups, cardinality, exports, inference, and clearing', async () => { - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!) - fireEvent.click(screen.getByTestId('add-name')) - fireEvent.click(screen.getByTestId('add-submit')) - expect(screen.getByTestId('node-count')).toHaveTextContent('1') - - fireEvent.click(screen.getByRole('button', { name: '업무 그룹' })) - fireEvent.click(screen.getByTestId('group-create-guard')) - fireEvent.click(screen.getByTestId('group-name')) - fireEvent.click(screen.getByTestId('group-create')) - fireEvent.click(screen.getByTestId('group-assign-missing')) - fireEvent.click(screen.getByTestId('group-assign')) - vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true) - fireEvent.click(screen.getByTestId('group-delete')) - fireEvent.click(screen.getByTestId('group-delete')) - fireEvent.click(screen.getByTestId('group-close')) - - fireEvent.click(screen.getByRole('button', { name: '인덱스 카디널리티 계산' })) - expect(screen.getByTestId('card-format')).toHaveTextContent('50%') - expect(screen.getByTestId('card-strength-recommended')).toHaveTextContent('추천') - expect(screen.getByTestId('card-strength-consider')).toHaveTextContent('검토') - expect(screen.getByTestId('card-strength-skip')).toHaveTextContent('보류') - fireEvent.click(screen.getByTestId('card-table-missing')) - fireEvent.click(screen.getByTestId('card-table')) - fireEvent.click(screen.getByTestId('card-toggle')) - fireEvent.click(screen.getByTestId('card-distinct-invalid')) - fireEvent.click(screen.getByTestId('card-distinct')) - fireEvent.click(screen.getByTestId('card-apply')) - fireEvent.click(screen.getByTestId('card-apply-duplicate')) - fireEvent.click(screen.getByTestId('card-apply-no-columns')) - fireEvent.click(screen.getByTestId('card-apply-empty')) - fireEvent.click(screen.getByTestId('card-apply-second')) - fireEvent.click(screen.getByTestId('card-close')) - - fireEvent.click(screen.getByRole('button', { name: 'DDL 내보내기' })) - for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-csv', 'export-md']) { - fireEvent.click(screen.getByTestId(id)) + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + expect(screen.getByText("정렬 완료", { exact: false })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "정렬 되돌리기" })); + expect( + screen.getByText("되돌렸습니다", { exact: false }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("flow-connect")); + fireEvent.click(screen.getByTestId("edge-label")); + fireEvent.click(screen.getByTestId("edge-submit")); + fireEvent.click(screen.getByTestId("flow-edge")); + fireEvent.click(screen.getByTestId("edge-cancel")); + fireEvent.click(screen.getByTestId("flow-edge-unlabeled")); + fireEvent.click(screen.getByTestId("edge-cancel")); + fireEvent.click(screen.getByTestId("flow-edge")); + vi.spyOn(window, "confirm") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + fireEvent.click(screen.getByTestId("edge-delete")); + fireEvent.click(screen.getByTestId("edge-delete")); + + fireEvent.doubleClick(screen.getByTestId("flow-node")); + fireEvent.submit(screen.getByTestId("table-empty-form")); + fireEvent.click(screen.getByTestId("table-cancel")); + fireEvent.doubleClick(screen.getByTestId("flow-node")); + fireEvent.submit(screen.getByTestId("table-form")); + fireEvent.doubleClick(screen.getByTestId("flow-node")); + fireEvent.click(screen.getByTestId("table-cancel")); + fireEvent.doubleClick(screen.getByTestId("flow-node")); + vi.spyOn(window, "confirm") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + fireEvent.click(screen.getByTestId("table-delete")); + fireEvent.click(screen.getByTestId("table-delete")); + }); + + it("adds nodes and exercises groups, cardinality, exports, inference, and clearing", async () => { + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!); + fireEvent.click(screen.getByTestId("add-name")); + fireEvent.click(screen.getByTestId("add-submit")); + expect(screen.getByTestId("node-count")).toHaveTextContent("1"); + + fireEvent.click(screen.getByRole("button", { name: "업무 그룹" })); + fireEvent.click(screen.getByTestId("group-create-guard")); + fireEvent.click(screen.getByTestId("group-name")); + fireEvent.click(screen.getByTestId("group-create")); + fireEvent.click(screen.getByTestId("group-assign-missing")); + fireEvent.click(screen.getByTestId("group-assign")); + vi.spyOn(window, "confirm") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + fireEvent.click(screen.getByTestId("group-delete")); + fireEvent.click(screen.getByTestId("group-delete")); + fireEvent.click(screen.getByTestId("group-close")); + + fireEvent.click( + screen.getByRole("button", { name: "인덱스 카디널리티 계산" }), + ); + expect(screen.getByTestId("card-format")).toHaveTextContent("50%"); + expect(screen.getByTestId("card-strength-recommended")).toHaveTextContent( + "추천", + ); + expect(screen.getByTestId("card-strength-consider")).toHaveTextContent( + "검토", + ); + expect(screen.getByTestId("card-strength-skip")).toHaveTextContent("보류"); + fireEvent.click(screen.getByTestId("card-table-missing")); + fireEvent.click(screen.getByTestId("card-table")); + fireEvent.click(screen.getByTestId("card-toggle")); + fireEvent.click(screen.getByTestId("card-distinct-invalid")); + fireEvent.click(screen.getByTestId("card-distinct")); + fireEvent.click(screen.getByTestId("card-apply")); + fireEvent.click(screen.getByTestId("card-apply-duplicate")); + fireEvent.click(screen.getByTestId("card-apply-no-columns")); + fireEvent.click(screen.getByTestId("card-apply-empty")); + fireEvent.click(screen.getByTestId("card-apply-second")); + fireEvent.click(screen.getByTestId("card-close")); + + fireEvent.click(screen.getByRole("button", { name: "DDL 내보내기" })); + for (const id of [ + "export-copy-ddl", + "export-svg", + "export-uml", + "export-mermaid", + "export-dbml", + "export-csv", + "export-md", + ]) { + fireEvent.click(screen.getByTestId(id)); } - fireEvent.click(screen.getByTestId('share-create')) - await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('/api/share/one')) - fireEvent.click(screen.getByTestId('share-copy')) - fireEvent.click(screen.getByTestId('export-close')) - expect(exports.downloadText).toHaveBeenCalledTimes(6) - - fireEvent.click(screen.getByRole('button', { name: '관계 자동 추론' })) - expect(exports.inferRelationships).toHaveBeenCalled() - exports.inferRelationships.mockReturnValueOnce([]) - fireEvent.click(screen.getByRole('button', { name: '관계 자동 추론' })) - vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true) - fireEvent.click(screen.getByRole('button', { name: '모든 노드 지우기' })) - fireEvent.click(screen.getByRole('button', { name: '모든 노드 지우기' })) - expect(screen.getByText('ERD 캔버스가 비어 있습니다')).toBeInTheDocument() - }) - - it('covers guarded editor actions, navigation callbacks, and form selectors', async () => { - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - - vi.spyOn(window, 'confirm').mockReturnValue(true) + fireEvent.click(screen.getByTestId("share-create")); + await waitFor(() => + expect(screen.getByTestId("share-url")).toHaveTextContent( + "/api/share/one", + ), + ); + fireEvent.click(screen.getByTestId("share-copy")); + fireEvent.click(screen.getByTestId("export-close")); + expect(exports.downloadText).toHaveBeenCalledTimes(6); + + fireEvent.click(screen.getByRole("button", { name: "관계 자동 추론" })); + expect(exports.inferRelationships).toHaveBeenCalled(); + exports.inferRelationships.mockReturnValueOnce([]); + fireEvent.click(screen.getByRole("button", { name: "관계 자동 추론" })); + vi.spyOn(window, "confirm") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + fireEvent.click(screen.getByRole("button", { name: "모든 노드 지우기" })); + fireEvent.click(screen.getByRole("button", { name: "모든 노드 지우기" })); + expect(screen.getByText("ERD 캔버스가 비어 있습니다")).toBeInTheDocument(); + }); + + it("covers guarded editor actions, navigation callbacks, and form selectors", async () => { + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + + vi.spyOn(window, "confirm").mockReturnValue(true); for (const id of [ - 'edge-guard-submit', - 'edge-guard-delete', - 'share-copy-guard', - 'table-delete-guard', - 'add-guard', - 'group-create-guard', - 'card-skip-guard', + "edge-guard-submit", + "edge-guard-delete", + "share-copy-guard", + "table-delete-guard", + "add-guard", + "group-create-guard", + "card-skip-guard", ]) { - fireEvent.click(screen.getByTestId(id)) + fireEvent.click(screen.getByTestId(id)); } - fireEvent.submit(screen.getByTestId('table-submit-guard')) + fireEvent.submit(screen.getByTestId("table-submit-guard")); for (const name of [ - 'ERD 자동 정렬', - '정렬 되돌리기', - '업무 그룹', - '인덱스 카디널리티 계산', + "ERD 자동 정렬", + "정렬 되돌리기", + "업무 그룹", + "인덱스 카디널리티 계산", ]) { - const button = screen.getByRole('button', { name }) as HTMLButtonElement - forceClick(button) + const button = screen.getByRole("button", { name }) as HTMLButtonElement; + forceClick(button); } - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - fireEvent.click(screen.getByTestId('export-close')) - - fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } }) - fireEvent.change(screen.getByLabelText('Connection'), { target: { value: 'c1' } }) - fireEvent.change(screen.getByLabelText('New connection (DSN)'), { target: { value: 'Analytics' } }) - - fireEvent.change(screen.getByLabelText('New project'), { target: { value: ' ' } }) - forceClick(screen.getByRole('button', { name: 'Create' })) - fireEvent.change(screen.getByLabelText('New project'), { target: { value: 'demo' } }) - - fireEvent.change(screen.getByLabelText('Project'), { target: { value: '' } }) - forceClick(screen.getByRole('button', { name: 'Save connection' })) - fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p1' } }) - fireEvent.change(screen.getByLabelText('New connection (DSN)'), { target: { value: ' ' } }) - forceClick(screen.getByRole('button', { name: 'Save connection' })) - fireEvent.change(screen.getByLabelText('Connection'), { target: { value: '' } }) - forceClick(screen.getByRole('button', { name: 'Reverse engineer → snapshot' })) - fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!) - fireEvent.click(screen.getByTestId('add-cancel')) - - fireEvent.click(screen.getByRole('button', { name: '대시보드' })) - fireEvent.click(screen.getByRole('button', { name: /Billing.*다이어그램 보기/ })) - expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '대시보드' })) - await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0)) - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) - expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '대시보드' })) - fireEvent.click(screen.getByRole('button', { name: '편집기로 이동' })) - expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '대시보드' })) - fireEvent.click(screen.getByRole('button', { name: '새 모델링' })) - expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: '프로젝트' })) - fireEvent.change(screen.getByLabelText('새 프로젝트 이름'), { target: { value: 'Roadmap' } }) - fireEvent.click(screen.getByRole('button', { name: '새 프로젝트' })) - await waitFor(() => expect(api.createProject).toHaveBeenCalledWith('Roadmap')) - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) - expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '대시보드' })) - fireEvent.click(screen.getByRole('button', { name: '목록 보기' })) - }) - - it('clears replacement copy timers and pending timers during close and unmount', async () => { - vi.useFakeTimers() + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + fireEvent.click(screen.getByTestId("export-close")); + + fireEvent.change(screen.getByLabelText("Project"), { + target: { value: "p2" }, + }); + fireEvent.change(screen.getByLabelText("Connection"), { + target: { value: "c1" }, + }); + fireEvent.change(screen.getByLabelText("New connection (DSN)"), { + target: { value: "Analytics" }, + }); + + fireEvent.change(screen.getByLabelText("New project"), { + target: { value: " " }, + }); + forceClick(screen.getByRole("button", { name: "Create" })); + fireEvent.change(screen.getByLabelText("New project"), { + target: { value: "demo" }, + }); + + fireEvent.change(screen.getByLabelText("Project"), { + target: { value: "" }, + }); + forceClick(screen.getByRole("button", { name: "Save connection" })); + fireEvent.change(screen.getByLabelText("Project"), { + target: { value: "p1" }, + }); + fireEvent.change(screen.getByLabelText("New connection (DSN)"), { + target: { value: " " }, + }); + forceClick(screen.getByRole("button", { name: "Save connection" })); + fireEvent.change(screen.getByLabelText("Connection"), { + target: { value: "" }, + }); + forceClick( + screen.getByRole("button", { name: "Reverse engineer → snapshot" }), + ); + fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!); + fireEvent.click(screen.getByTestId("add-cancel")); + + fireEvent.click(screen.getByRole("button", { name: "대시보드" })); + fireEvent.click( + screen.getByRole("button", { name: /Billing.*다이어그램 보기/ }), + ); + expect( + screen.getByRole("heading", { name: "다이어그램" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "대시보드" })); + await waitFor(() => + expect( + screen.getAllByRole("button", { name: "열기" }).length, + ).toBeGreaterThan(0), + ); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); + expect( + screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "대시보드" })); + fireEvent.click(screen.getByRole("button", { name: "편집기로 이동" })); + expect( + screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "대시보드" })); + fireEvent.click(screen.getByRole("button", { name: "새 모델링" })); + expect( + screen.getByRole("toolbar", { name: "ERD 캔버스 도구" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "프로젝트" })); + fireEvent.change(screen.getByLabelText("새 프로젝트 이름"), { + target: { value: "Roadmap" }, + }); + fireEvent.click(screen.getByRole("button", { name: "새 프로젝트" })); + await waitFor(() => + expect(api.createProject).toHaveBeenCalledWith("Roadmap"), + ); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); + expect( + screen.getByRole("heading", { name: "다이어그램" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "대시보드" })); + fireEvent.click(screen.getByRole("button", { name: "목록 보기" })); + }); + + it("clears replacement copy timers and pending timers during close and unmount", async () => { + vi.useFakeTimers(); + await act(async () => { + render(); + await Promise.resolve(); + await Promise.resolve(); + }); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!); + fireEvent.click(screen.getByTestId("add-name")); + fireEvent.click(screen.getByTestId("add-submit")); + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + fireEvent.click(screen.getByTestId("export-copy-ddl")); + fireEvent.click(screen.getByTestId("export-copy-ddl")); + fireEvent.click(screen.getByTestId("share-create")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("share-copy")); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("share-copy")); await act(async () => { - render() - await Promise.resolve() - await Promise.resolve() - }) - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!) - fireEvent.click(screen.getByTestId('add-name')) - fireEvent.click(screen.getByTestId('add-submit')) - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - fireEvent.click(screen.getByTestId('export-copy-ddl')) - fireEvent.click(screen.getByTestId('export-copy-ddl')) - fireEvent.click(screen.getByTestId('share-create')) + await Promise.resolve(); + }); await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - fireEvent.click(screen.getByTestId('share-copy')) - await act(async () => { await Promise.resolve() }) - fireEvent.click(screen.getByTestId('share-copy')) - await act(async () => { await Promise.resolve() }) + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("export-close")); + + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + fireEvent.click(screen.getByTestId("export-copy-ddl")); + fireEvent.click(screen.getByTestId("share-create")); await act(async () => { - vi.advanceTimersByTime(2000) - await Promise.resolve() - }) - fireEvent.click(screen.getByTestId('export-close')) - - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - fireEvent.click(screen.getByTestId('export-copy-ddl')) - fireEvent.click(screen.getByTestId('share-create')) - await act(async () => { await Promise.resolve() }) - fireEvent.click(screen.getByTestId('share-copy')) - await act(async () => { await Promise.resolve() }) - fireEvent.click(screen.getByTestId('export-close')) - - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - fireEvent.click(screen.getByTestId('export-copy-ddl')) - fireEvent.click(screen.getByTestId('share-create')) - await act(async () => { await Promise.resolve() }) - fireEvent.click(screen.getByTestId('share-copy')) - await act(async () => { await Promise.resolve() }) - cleanup() - }) - - it('ignores authentication completions after unmount', async () => { - let resolveMe!: (value: any) => void - api.getMe.mockReturnValueOnce(new Promise((resolve) => { resolveMe = resolve })) - render() - cleanup() - await act(async () => resolveMe({ subject: 'late', display_name: 'Late' })) - - let rejectMe!: (reason: unknown) => void - api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject })) - render() - cleanup() - await act(async () => rejectMe(new Error('late failure'))) - }) - - it('logs auto-layout failures and preserves nodes added after the undo snapshot', async () => { - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - const openButtons = await screen.findAllByRole('button', { name: '열기' }) - vi.useFakeTimers() - fireEvent.click(openButtons[0]!) + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("share-copy")); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - vi.useRealTimers() - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.stubGlobal('requestAnimationFrame', () => { throw new Error('frame unavailable') }) - fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' })) - await waitFor(() => expect(screen.getByText('정렬에 실패했습니다. 다시 시도해 주세요.', { exact: false })).toBeInTheDocument()) - expect(consoleError).toHaveBeenCalled() - - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1 }) - fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' })) - await screen.findByText('정렬 완료', { exact: false }) - fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!) - fireEvent.click(screen.getByTestId('add-name')) - fireEvent.click(screen.getByTestId('add-submit')) - fireEvent.click(screen.getByRole('button', { name: '정렬 되돌리기' })) - expect(screen.getByTestId('node-count')).toHaveTextContent('3') - }) - - it('shows terminal refresh failures from the polling loop', async () => { + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("export-close")); + + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + fireEvent.click(screen.getByTestId("export-copy-ddl")); + fireEvent.click(screen.getByTestId("share-create")); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.click(screen.getByTestId("share-copy")); + await act(async () => { + await Promise.resolve(); + }); + cleanup(); + }); + + it("ignores authentication completions after unmount", async () => { + let resolveMe!: (value: any) => void; + api.getMe.mockReturnValueOnce( + new Promise((resolve) => { + resolveMe = resolve; + }), + ); + render(); + cleanup(); + await act(async () => resolveMe({ subject: "late", display_name: "Late" })); + + let rejectMe!: (reason: unknown) => void; + api.getMe.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectMe = reject; + }), + ); + render(); + cleanup(); + await act(async () => rejectMe(new Error("late failure"))); + }); + + it("logs auto-layout failures and preserves nodes added after the undo snapshot", async () => { + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + vi.useFakeTimers(); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + vi.useRealTimers(); + + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + vi.stubGlobal("requestAnimationFrame", () => { + throw new Error("frame unavailable"); + }); + fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" })); + await waitFor(() => + expect( + screen.getByText("정렬에 실패했습니다. 다시 시도해 주세요.", { + exact: false, + }), + ).toBeInTheDocument(), + ); + expect(consoleError).toHaveBeenCalled(); + + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" })); + await screen.findByText("정렬 완료", { exact: false }); + fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!); + fireEvent.click(screen.getByTestId("add-name")); + fireEvent.click(screen.getByTestId("add-submit")); + fireEvent.click(screen.getByRole("button", { name: "정렬 되돌리기" })); + expect(screen.getByTestId("node-count")).toHaveTextContent("3"); + }); + + it("shows terminal refresh failures from the polling loop", async () => { api.listSnapshots .mockResolvedValueOnce(snapshots) - .mockRejectedValueOnce(new Error('terminal refresh down')) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - const openButtons = await screen.findAllByRole('button', { name: '열기' }) - vi.useFakeTimers() - fireEvent.click(openButtons[0]!) + .mockRejectedValueOnce(new Error("terminal refresh down")); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + vi.useFakeTimers(); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - expect(screen.getByRole('alert')).toHaveTextContent('terminal refresh down') - }) - - it('ignores stale project metadata failures after changing projects', async () => { - let rejectConnections!: (reason: unknown) => void - let rejectSnapshots!: (reason: unknown) => void + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "terminal refresh down", + ); + }); + + it("ignores stale project metadata failures after changing projects", async () => { + let rejectConnections!: (reason: unknown) => void; + let rejectSnapshots!: (reason: unknown) => void; api.listConnections - .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectConnections = reject })) - .mockResolvedValueOnce(connections) + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectConnections = reject; + }), + ) + .mockResolvedValueOnce(connections); api.listSnapshots - .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectSnapshots = reject })) - .mockResolvedValueOnce(snapshots) - await renderReadyApp() - - // Clear out initial errors before test acts - await waitFor(() => expect(screen.queryByText(/stale/)).not.toBeInTheDocument()) - - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } }) + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectSnapshots = reject; + }), + ) + .mockResolvedValueOnce(snapshots); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + fireEvent.change(screen.getByLabelText("Project"), { + target: { value: "p2" }, + }); await act(async () => { - // Catching these rejections inside the act stops the unhandled rejection in vitest - rejectConnections(new Error('stale connections')) - rejectSnapshots(new Error('stale snapshots')) - await Promise.resolve() - }) - expect(screen.queryByText(/stale (connections|snapshots)/)).not.toBeInTheDocument() - }) - - it('renders snapshot failures and polls without a selected project', async () => { + rejectConnections(new Error("stale connections")); + rejectSnapshots(new Error("stale snapshots")); + await Promise.resolve(); + }); + expect( + screen.queryByText(/stale (connections|snapshots)/), + ).not.toBeInTheDocument(); + }); + + it("renders snapshot failures and polls without a selected project", async () => { api.getSnapshot.mockResolvedValue({ - schema_snapshot_uuid: 's3', - status: 'failed', + schema_snapshot_uuid: "s3", + status: "failed", schema_filter: null, - error_message: 'database rejected snapshot', + error_message: "database rejected snapshot", snapshot_json: null, - }) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - fireEvent.change(screen.getByLabelText('Connection DSN'), { target: { value: buildTestDsn('postgresql', 'db.example', 'test') } }) - fireEvent.click(screen.getByRole('button', { name: 'Save connection' })) - await waitFor(() => expect(api.createConnection).toHaveBeenCalled()) - fireEvent.click(screen.getByRole('button', { name: 'Reverse engineer → snapshot' })) - await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledWith('p1', 'c2', undefined)) - - vi.useFakeTimers() - fireEvent.change(screen.getByLabelText('Project'), { target: { value: '' } }) + }); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + fireEvent.change(screen.getByLabelText("Connection DSN"), { + target: { value: "postgresql://db.example/test" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save connection" })); + await waitFor(() => expect(api.createConnection).toHaveBeenCalled()); + fireEvent.click( + screen.getByRole("button", { name: "Reverse engineer → snapshot" }), + ); + await waitFor(() => + expect(api.createSnapshot).toHaveBeenCalledWith("p1", "c2", undefined), + ); + + vi.useFakeTimers(); + fireEvent.change(screen.getByLabelText("Project"), { + target: { value: "" }, + }); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - expect(screen.getByRole('alert')).toHaveTextContent('database rejected snapshot') - }) - - it('renders user identity fallbacks and a diagram list without a project label', async () => { - api.getMe.mockResolvedValueOnce({ subject: 'subject-only', display_name: null }) - await renderReadyApp() - expect(screen.getByText('subject-only')).toBeInTheDocument() - - cleanup() - api.getMe.mockResolvedValueOnce({ subject: '', display_name: null }) - await renderReadyApp() - expect(screen.getByText('인증 필요')).toBeInTheDocument() - - cleanup() - const onOpenEditor = vi.fn() + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "database rejected snapshot", + ); + }); + + it("renders user identity fallbacks and a diagram list without a project label", async () => { + api.getMe.mockResolvedValueOnce({ + subject: "subject-only", + display_name: null, + }); + await renderReadyApp(); + expect(screen.getByText("subject-only")).toBeInTheDocument(); + + cleanup(); + api.getMe.mockResolvedValueOnce({ subject: "", display_name: null }); + await renderReadyApp(); + expect(screen.getByText("인증 필요")).toBeInTheDocument(); + + cleanup(); + const onOpenEditor = vi.fn(); render( , - ) - expect(screen.getAllByText('현재 프로젝트')).toHaveLength(2) - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) - expect(onOpenEditor).toHaveBeenCalledWith('s1') - }) - - it('ignores duplicate share creation while a request is pending', async () => { - let resolveShare!: (value: { url: string }) => void - api.createShareLink.mockReturnValueOnce(new Promise((resolve) => { resolveShare = resolve })) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - fireEvent.click(screen.getByTestId('share-create')) - fireEvent.click(screen.getByTestId('share-create')) - expect(api.createShareLink).toHaveBeenCalledTimes(1) - await act(async () => resolveShare({ url: 'http://localhost/api/share/done' })) - }) - - it('preserves positions across graph refresh and applies recommendations with sibling nodes', async () => { - let pollCount = 0 + ); + expect(screen.getAllByText("현재 프로젝트")).toHaveLength(2); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); + expect(onOpenEditor).toHaveBeenCalledWith("s1"); + }); + + it("ignores duplicate share creation while a request is pending", async () => { + let resolveShare!: (value: { url: string }) => void; + api.createShareLink.mockReturnValueOnce( + new Promise((resolve) => { + resolveShare = resolve; + }), + ); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + fireEvent.click(screen.getByTestId("share-create")); + fireEvent.click(screen.getByTestId("share-create")); + expect(api.createShareLink).toHaveBeenCalledTimes(1); + await act(async () => + resolveShare({ url: "http://localhost/api/share/done" }), + ); + }); + + it("preserves positions across graph refresh and applies recommendations with sibling nodes", async () => { + let pollCount = 0; api.getSnapshot.mockImplementation(async () => ({ - schema_snapshot_uuid: 's3', - status: pollCount++ === 0 ? 'running' : 'succeeded', - schema_filter: 'public', + schema_snapshot_uuid: "s3", + status: pollCount++ === 0 ? "running" : "succeeded", + schema_filter: "public", error_message: null, - snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] }, - })) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '전체 보기' })) - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) - vi.useFakeTimers() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + snapshot_json: { + relations: [], + columns: [], + pk_columns: [], + fk_edges: [], + }, + })); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + vi.useFakeTimers(); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - expect(screen.getByTestId('node-count')).toHaveTextContent('2') + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId("node-count")).toHaveTextContent("2"); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - vi.useRealTimers() - expect(screen.getByTestId('node-count')).toHaveTextContent('2') - - fireEvent.click(screen.getByRole('button', { name: '업무 그룹' })) - fireEvent.click(screen.getByTestId('group-name')) - fireEvent.click(screen.getByTestId('group-create')) - fireEvent.click(screen.getByTestId('group-assign')) - vi.spyOn(window, 'confirm').mockReturnValue(true) - fireEvent.click(screen.getByTestId('group-delete')) - fireEvent.click(screen.getByTestId('group-close')) - - fireEvent.click(screen.getByRole('button', { name: '인덱스 카디널리티 계산' })) - fireEvent.click(screen.getByTestId('card-table')) - fireEvent.click(screen.getByTestId('card-apply')) - fireEvent.click(screen.getByTestId('card-clear-apply')) - }) - - it('falls back to node ids when auto-layout receives legacy nodes without titles', async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + vi.useRealTimers(); + expect(screen.getByTestId("node-count")).toHaveTextContent("2"); + + fireEvent.click(screen.getByRole("button", { name: "업무 그룹" })); + fireEvent.click(screen.getByTestId("group-name")); + fireEvent.click(screen.getByTestId("group-create")); + fireEvent.click(screen.getByTestId("group-assign")); + vi.spyOn(window, "confirm").mockReturnValue(true); + fireEvent.click(screen.getByTestId("group-delete")); + fireEvent.click(screen.getByTestId("group-close")); + + fireEvent.click( + screen.getByRole("button", { name: "인덱스 카디널리티 계산" }), + ); + fireEvent.click(screen.getByTestId("card-table")); + fireEvent.click(screen.getByTestId("card-apply")); + fireEvent.click(screen.getByTestId("card-clear-apply")); + }); + + it("falls back to node ids when auto-layout receives legacy nodes without titles", async () => { vi.mocked(snapshotToGraph).mockReturnValueOnce({ nodes: [ - { id: 'z-node', type: 'tableNode', position: { x: 0, y: 0 }, data: { columns: [], badges: { pk: false, fk: false } } }, - { id: 'a-node', type: 'tableNode', position: { x: 1, y: 1 }, data: { columns: [], badges: { pk: false, fk: false } } }, + { + id: "z-node", + type: "tableNode", + position: { x: 0, y: 0 }, + data: { columns: [], badges: { pk: false, fk: false } }, + }, + { + id: "a-node", + type: "tableNode", + position: { x: 1, y: 1 }, + data: { columns: [], badges: { pk: false, fk: false } }, + }, ] as any, edges: [], - }) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - vi.useFakeTimers() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + }); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + vi.useFakeTimers(); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - await Promise.resolve() - }) - vi.useRealTimers() - fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' })) - await screen.findByText('정렬 완료', { exact: false }) - }) - - it('reports API effect failures, snapshot polling failures, share failures, and clipboard failures', async () => { - api.listConnections.mockRejectedValueOnce(new Error('connections down')) - api.listSnapshots.mockRejectedValueOnce(new Error('snapshots down')) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '편집기' })) - expect(await screen.findByRole('alert')).toHaveTextContent(/down/) - fireEvent.click(screen.getAllByRole('button', { name: '테이블 추가' })[0]!) - fireEvent.click(screen.getByTestId('add-name')) - fireEvent.click(screen.getByTestId('add-submit')) - fireEvent.click(screen.getByRole('button', { name: '공유 및 내보내기' })) - api.createShareLink.mockRejectedValueOnce(new Error('share down')) - fireEvent.click(screen.getByTestId('share-create')) - await waitFor(() => expect(screen.getByTestId('share-error')).toHaveTextContent('share down')) - - api.createShareLink.mockResolvedValueOnce({ url: 'http://localhost/api/share/fail-copy' }) - fireEvent.click(screen.getByTestId('share-create')) - await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('fail-copy')) - vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('copy down')) - fireEvent.click(screen.getByTestId('share-copy')) - await waitFor(() => expect(screen.getByTestId('share-error')).toHaveTextContent('복사에 실패')) - - cleanup() - vi.useRealTimers() - api.listConnections.mockResolvedValue(connections) - api.listSnapshots.mockResolvedValue(snapshots) - await renderReadyApp() - fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) - await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0)) - vi.useFakeTimers() - api.getSnapshot.mockRejectedValueOnce(new Error('poll down')) - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: "ERD 자동 정렬" })); + await screen.findByText("정렬 완료", { exact: false }); + }); + + it("reports API effect failures, snapshot polling failures, share failures, and clipboard failures", async () => { + api.listConnections.mockRejectedValueOnce(new Error("connections down")); + api.listSnapshots.mockRejectedValueOnce(new Error("snapshots down")); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "편집기" })); + expect(await screen.findByRole("alert")).toHaveTextContent(/down/); + fireEvent.click(screen.getAllByRole("button", { name: "테이블 추가" })[0]!); + fireEvent.click(screen.getByTestId("add-name")); + fireEvent.click(screen.getByTestId("add-submit")); + fireEvent.click(screen.getByRole("button", { name: "공유 및 내보내기" })); + api.createShareLink.mockRejectedValueOnce(new Error("share down")); + fireEvent.click(screen.getByTestId("share-create")); + await waitFor(() => + expect(screen.getByTestId("share-error")).toHaveTextContent("share down"), + ); + + api.createShareLink.mockResolvedValueOnce({ + url: "http://localhost/api/share/fail-copy", + }); + fireEvent.click(screen.getByTestId("share-create")); + await waitFor(() => + expect(screen.getByTestId("share-url")).toHaveTextContent("fail-copy"), + ); + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce( + new Error("copy down"), + ); + fireEvent.click(screen.getByTestId("share-copy")); + await waitFor(() => + expect(screen.getByTestId("share-error")).toHaveTextContent( + "복사에 실패", + ), + ); + + cleanup(); + vi.useRealTimers(); + api.listConnections.mockResolvedValue(connections); + api.listSnapshots.mockResolvedValue(snapshots); + await renderReadyApp(); + fireEvent.click(screen.getByRole("button", { name: "다이어그램" })); + await waitFor(() => + expect( + screen.getAllByRole("button", { name: "열기" }).length, + ).toBeGreaterThan(0), + ); + vi.useFakeTimers(); + api.getSnapshot.mockRejectedValueOnce(new Error("poll down")); + fireEvent.click(screen.getAllByRole("button", { name: "열기" })[0]!); await act(async () => { - vi.advanceTimersByTime(1000) - await Promise.resolve() - }) - expect(screen.getByRole('alert')).toHaveTextContent('poll down') - }) -}) + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(screen.getByRole("alert")).toHaveTextContent("poll down"); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 549d757da..278282dcd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -72,7 +72,16 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([ "not_found", ]); -const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]); +const SUPPORTED_DSN_PROTOCOLS = new Set([ + "postgres:", + "postgresql:", + "snowflake:", +]); + +function sanitizeCssClass(str: unknown): string { + if (typeof str !== "string") return ""; + return str.replace(/[^a-zA-Z0-9-_]/g, ""); +} function sanitizeHtml(str: string | null | undefined): string { if (!str) return ""; @@ -84,11 +93,6 @@ function sanitizeHtml(str: string | null | undefined): string { .replace(/'/g, "'"); } -function sanitizeCssClass(str: unknown): string { - if (typeof str !== "string") return ""; - return str.replace(/[^a-zA-Z0-9-_]/g, ""); -} - type CurrentUser = { subject: string; display_name: string | null; @@ -173,7 +177,9 @@ export default function App() { const [shareLinkError, setShareLinkError] = useState(null); const [editingEdge, setEditingEdge] = useState(null); - const [editingNode, setEditingNode] = useState | null>(null); + const [editingNode, setEditingNode] = useState | null>( + null, + ); const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false); const [isAddTableModalOpen, setIsAddTableModalOpen] = useState(false); const [newTableName, setNewTableName] = useState(""); @@ -318,7 +324,11 @@ export default function App() { getSnapshot(snapshotId) .then((s) => { setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if ( + s.status === "succeeded" || + s.status === "failed" || + s.status === "not_found" + ) { clearInterval(timer); if (selectedProjectId) { listSnapshots(selectedProjectId) @@ -372,9 +382,7 @@ export default function App() { // ⚡ Bolt: Removed nodesById Map creation inside useMemo which iterates over all nodes and allocates memory. // Using nodes.find() for single lookups is O(N) but avoids Map construction overhead, providing ~10x speedup and reducing GC pressure. const cardinalityNode = useMemo(() => { - return ( - nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null - ); + return nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null; }, [cardinalityTableId, nodes]); const cardinalityColumns = useMemo(() => { if (!cardinalityNode) return []; @@ -385,11 +393,7 @@ export default function App() { cardinalityDistinctCounts[column.column_name] ?? "", ), })); - }, [ - cardinalityColumnSelections, - cardinalityDistinctCounts, - cardinalityNode, - ]); + }, [cardinalityColumnSelections, cardinalityDistinctCounts, cardinalityNode]); const cardinalityRecommendations = useMemo( () => buildIndexRecommendations({ @@ -397,7 +401,11 @@ export default function App() { rowCount: cardinalityRowCountNumber, columns: cardinalityColumns, }), - [cardinalityColumns, cardinalityNode?.data.title, cardinalityRowCountNumber], + [ + cardinalityColumns, + cardinalityNode?.data.title, + cardinalityRowCountNumber, + ], ); const appliedCardinalityIndexes = useMemo( () => cardinalityNode?.data.indexes ?? [], @@ -409,7 +417,8 @@ export default function App() { const columns = new Set(); for (const index of appliedCardinalityIndexes) { if (index.index_name) names.add(index.index_name); - if (index.columns && index.columns.length > 0) columns.add(index.columns.join(",")); + if (index.columns && index.columns.length > 0) + columns.add(index.columns.join(",")); } return { names, columns }; }, [appliedCardinalityIndexes]); @@ -508,11 +517,14 @@ export default function App() { } } - const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => { - event.preventDefault(); - setEditingNode(node as Node); - setIsEditTableModalOpen(true); - }, []); + const onNodeDoubleClick = useCallback( + (event: React.MouseEvent, node: Node) => { + event.preventDefault(); + setEditingNode(node as Node); + setIsEditTableModalOpen(true); + }, + [], + ); const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { event.preventDefault(); @@ -627,7 +639,11 @@ export default function App() { } function onDownloadMermaid() { - downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); + downloadText( + "pg-erd-diagram.mermaid", + exportMermaid(nodes, edges), + "text/plain", + ); } function onDownloadDbml() { @@ -635,7 +651,11 @@ export default function App() { } function onDownloadPrisma() { - downloadText("pg-erd-diagram.prisma", exportPrisma(nodes, edges), "text/plain"); + downloadText( + "pg-erd-diagram.prisma", + exportPrisma(nodes, edges), + "text/plain", + ); } function onExportDictionaryCsv() { @@ -701,10 +721,7 @@ export default function App() { })); } - function onCardinalityDistinctCountChange( - columnName: string, - value: string, - ) { + function onCardinalityDistinctCountChange(columnName: string, value: string) { if (!/^\d*$/.test(value)) return; setCardinalityDistinctCounts((prev) => ({ ...prev, @@ -734,7 +751,8 @@ export default function App() { const recColumns = recommendation.columns?.join(",") ?? ""; if ( - (recommendation.index_name && appliedIndexNames.has(recommendation.index_name)) || + (recommendation.index_name && + appliedIndexNames.has(recommendation.index_name)) || (recColumns && appliedColumns.has(recColumns)) ) { return currentNodes; @@ -811,7 +829,12 @@ export default function App() { } function onDeleteBusinessGroup(groupId: string) { - if (!window.confirm("이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?")) return; + if ( + !window.confirm( + "이 그룹을 삭제하면 포함된 모든 테이블에서 그룹 지정이 해제됩니다. 정말로 삭제하시겠습니까?", + ) + ) + return; setBusinessGroups((groups) => groups.filter((group) => group.id !== groupId), ); @@ -830,7 +853,6 @@ export default function App() { ); } - function onDeleteTable() { if (!editingNode) return; if (!window.confirm("정말로 이 테이블을 삭제하시겠습니까?")) return; @@ -839,7 +861,11 @@ export default function App() { setNodes((nds) => nds.filter((n) => n.id !== editingNode.id)); // Remove connected edges - setEdges((eds) => eds.filter((e) => e.source !== editingNode.id && e.target !== editingNode.id)); + setEdges((eds) => + eds.filter( + (e) => e.source !== editingNode.id && e.target !== editingNode.id, + ), + ); setIsEditTableModalOpen(false); setEditingNode(null); @@ -856,7 +882,9 @@ export default function App() { if (!title.trim()) return; // Parse columns from formData - const updatedColumns: Array["data"]["columns"][number]> = []; + const updatedColumns: Array< + Node["data"]["columns"][number] + > = []; for (let i = 0; i < editingNode.data.columns.length; i++) { const colName = formData.get(`col_name_${i}`) as string; if (colName === null) continue; // Deleted column @@ -886,13 +914,13 @@ export default function App() { columns: updatedColumns, badges: { ...n.data.badges, - pk: updatedColumns.some(c => c.is_pk) - } - } + pk: updatedColumns.some((c) => c.is_pk), + }, + }, }; } return n; - }) + }), ); setIsEditTableModalOpen(false); @@ -904,7 +932,6 @@ export default function App() { setEditingNode(null); } - function onAddTableSubmit() { if (!newTableName.trim()) return; const newId = `new_table_${Date.now()}`; @@ -969,7 +996,9 @@ export default function App() { /* v8 ignore next -- the save control is disabled until both fields are present */ if (!nextConnectionName || !connectionDsn) return; if (!isSupportedConnectionDsn(connectionDsn)) { - setError("Connection DSN must use postgresql://, postgres://, or snowflake:// with a host."); + setError( + "Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.", + ); dsnInput.value = ""; setIsDsnPresent(false); return; @@ -1011,12 +1040,7 @@ export default function App() { if (isAuthLoading) { return ( -
+

pg-erd-cloud

Authenticating…

@@ -1025,7 +1049,8 @@ export default function App() { if (!me) { /* v8 ignore next -- the loaded unauthenticated state always records its rejection */ - const authGateMessage = authError ?? "Sign in before managing database metadata."; + const authGateMessage = + authError ?? "Sign in before managing database metadata."; return (

Authentication required

@@ -1050,7 +1075,11 @@ export default function App() { + + {createProjectHint ? ( + + {createProjectHint} + + ) : null} +
+ +
+ +
+ + -
- + {connections.map((c) => ( + + ))} + + + +
{ + e.preventDefault(); + onCreateConnection(); + }} + > + + setConnName(e.target.value)} + placeholder="name" + /> + + setIsDsnPresent(Boolean(e.currentTarget.value.trim())) + } + placeholder="postgresql://... or snowflake://..." + aria-label="Connection DSN" + /> + + {createConnectionHint ? ( + + {createConnectionHint} + + ) : null} +
+ +
+ + setSchemaFilter(e.target.value)} + placeholder="public" + /> +
-
- -
{ e.preventDefault(); onCreateProject(); }}> - setProjectName(e.target.value)} - /> -
- {createProjectHint ? ( - - {createProjectHint} - - ) : null} -
- -
- -
- - -
- -
{ e.preventDefault(); onCreateConnection(); }}> - - setConnName(e.target.value)} - placeholder="name" - /> - - setIsDsnPresent(Boolean(e.currentTarget.value.trim())) - } - placeholder="postgresql://... or snowflake://..." - aria-label="Connection DSN" - /> - - {createConnectionHint ? ( - - {createConnectionHint} - - ) : null} -
- -
- - setSchemaFilter(e.target.value)} - placeholder="public" - /> -
- - - {createSnapshotHint ? ( - - {createSnapshotHint} - - ) : null} - -
- Snapshot: {snapshot?.status || "—"} - {snapshot?.error_message ? ( -
- {String(snapshot.error_message)} + {createSnapshotHint ? ( + + {createSnapshotHint} + + ) : null} + +
+ Snapshot: {snapshot?.status || "—"} + {snapshot?.error_message ? ( +
+ {String(snapshot.error_message)} +
+ ) : null}
- ) : null} -
- {error ? ( -
- {error} -
- ) : null} + {error ? ( +
+ {error} +
+ ) : null} ) : (
@@ -1231,7 +1279,10 @@ export default function App() {
{activeView === "dashboard" ? ( -
+

대시보드

@@ -1257,7 +1308,10 @@ export default function App() {
-
+

최근 프로젝트

) : ( -
아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요.
+
+ 아직 프로젝트가 없습니다. 편집기에서 프로젝트를 생성하세요. +
)}
-
+

최근 다이어그램

{ setSnapshotId(id); setSnapshot(null); @@ -1310,13 +1371,23 @@ export default function App() {

프로젝트

-

프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.

+

+ 프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다. +

-
{ e.preventDefault(); onCreateProject(); }}> + { + e.preventDefault(); + onCreateProject(); + }} + > setProjectName(event.currentTarget.value)} + onChange={(event) => + setProjectName(event.currentTarget.value) + } />
-
+
이름 연결 동작
{projects.map((project) => ( -
- {sanitizeHtml(project.project_name)} - {project.project_space_uuid === selectedProjectId ? connections.length : "선택 후 표시"} +
+ + {sanitizeHtml(project.project_name)} + + + {project.project_space_uuid === selectedProjectId + ? connections.length + : "선택 후 표시"} +
@@ -1359,7 +1445,11 @@ export default function App() {

다이어그램

-

{selectedProject ? `${selectedProject.project_name} 프로젝트의 스냅샷` : "프로젝트를 선택하세요."}

+

+ {selectedProject + ? `${selectedProject.project_name} 프로젝트의 스냅샷` + : "프로젝트를 선택하세요."} +

) : (
-
-
e.preventDefault()}> - -
- - - - - - - - - - + + + + + + + + + + + +
+ {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} +
+
+ + { + reactFlowRef.current = instance; + }} > - IMG - - + + )} +
+ )} + + 0} + hasDictionaryExport={nodes.length > 0} + hasDiagramExport={nodes.length > 0} + shareLinkUrl={shareLinkUrl} + isCreatingShareLink={isCreatingShareLink} + isShareLinkCopied={isShareLinkCopied} + shareLinkError={shareLinkError} + canCreateShareLink={Boolean(selectedProjectId)} + onCloseExport={onCloseExport} + onCopyExportDdl={onCopyExportDdl} + onDownloadSvg={onDownloadSvg} + onDownloadUml={onDownloadUml} + onDownloadMermaid={onDownloadMermaid} + onExportDictionaryCsv={onExportDictionaryCsv} + onExportDictionaryMarkdown={onExportDictionaryMarkdown} + onDownloadDbml={onDownloadDbml} + onDownloadPrisma={onDownloadPrisma} + onCreateShareLink={onCreateShareLink} + onCopyShareLink={onCopyShareLink} + /> + + + + + + - UML - - -
- {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} -
-
+ parsePositiveInteger={parsePositiveInteger} + calculateCardinalityRatio={calculateCardinalityRatio} + formatPercent={formatPercent} + strengthLabel={strengthLabel} + /> - { - reactFlowRef.current = instance; - }} - > - - - - - - {nodes.length === 0 && ( -
- {isSnapshotPending ? ( - <> - + + + +
)}
@@ -1740,11 +1850,17 @@ export function DiagramTable({ 동작 {rows.map(({ item, name }) => ( -
+
{name} {selectedProjectName || "현재 프로젝트"} - + {sanitizeHtml(item.status)} From b48be1f8df946e400fad574086654a2a4aa5e29f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:54:19 +0000 Subject: [PATCH 41/43] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20form=20wr?= =?UTF-8?q?appers=20for=20native=20keyboard=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 22 ++++++++++++++++------ frontend/package.json | 3 ++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5bb22defc..96e871fdd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@xyflow/react": "^12.11.1", "react": "^19.2.4", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "undici": "^8.10.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -1605,6 +1606,16 @@ } } }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2383,13 +2394,12 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/use-sync-external-store": { diff --git a/frontend/package.json b/frontend/package.json index adbea9fd0..a4f89cd14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,8 @@ "dependencies": { "@xyflow/react": "^12.11.1", "react": "^19.2.4", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "undici": "^8.10.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", From 19bab91c2d72e1f43fea99f96636ea4fcd64cab7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:50:01 +0000 Subject: [PATCH 42/43] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20form=20wr?= =?UTF-8?q?appers=20for=20native=20keyboard=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index a4f89cd14..ba14cd262 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,7 @@ "dependencies": { "@xyflow/react": "^12.11.1", "react": "^19.2.4", - "react-dom": "^19.2.7", - "undici": "^8.10.0" + "react-dom": "^19.2.7" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -33,7 +32,8 @@ "jsdom": "^29.1.1", "typescript": "^6.0.3", "vite": "^8.1.0", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "undici": "^8.10.0" }, "overrides": { "esbuild": "^0.25.0", From cf597e69a255d0497b1f233baeaa9b47422bd412 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:32:58 +0000 Subject: [PATCH 43/43] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20form=20wr?= =?UTF-8?q?appers=20for=20native=20keyboard=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit