diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..ca479b8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] ♿ **네이티브 폼 키보드 제출**: 편집기·프로젝트 목록의 프로젝트 생성과 연결 생성은 한 개의 guarded `submit` 경로를 사용해 Enter와 클릭이 정확히 한 번 동작합니다. 다이어그램·캔버스 검색은 `role="search"` 폼에서 Enter 내비게이션만 막고 실시간 필터를 유지합니다. - [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다. - [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. diff --git a/docs/doctoring/native-form-submission.md b/docs/doctoring/native-form-submission.md new file mode 100644 index 000000000..ab6e971b3 --- /dev/null +++ b/docs/doctoring/native-form-submission.md @@ -0,0 +1,65 @@ +# Native form submission and search behavior + +## Status + +Implemented in the authenticated React application. This is an accessibility +and interaction contract, not a conformance certification. + +## Creation forms + +The editor project creator, project-list inline creator, and editor connection +creator are semantic HTML `form` elements. Their action buttons use +`type="submit"`; click and Enter therefore converge on the same handler rather +than dispatching duplicate request paths. + +Each submit handler prevents browser navigation and calls the existing async +operation. Production guards remain authoritative: + +- trimmed empty project names never call the API; +- a connection requires a selected project, non-empty name and secret DSN; +- the DSN must satisfy the existing PostgreSQL/Postgres/Snowflake URL policy; +- the password field is cleared before any connection request and after an + invalid DSN, so the secret is not retained in React state; and +- `isCreatingProject` and `isCreatingConnection` prevent a second Enter or + click while the first request is active. + +Native required-field attributes were not introduced because the product's +existing disabled-state, contextual-hint, trimming, and DSN policy provide the +current validation contract. This change does not alter DSN authority, +authentication, API payloads, polling, status rendering, or dependencies. + +## Search forms + +Diagram and canvas search controls are semantic forms with `role="search"`. +The landmarks have distinct accessible names (`다이어그램 검색` and +`ERD 캔버스 검색`) so assistive-technology users can identify the intended +search region rather than encountering anonymous search landmarks. +Their submit handler only calls `preventDefault()`. Enter therefore cannot +navigate or reload the page, while the controlled input and existing live +filter remain unchanged. The accessible labels and visible layout are +preserved. + +## Failure and recovery + +API failures continue through the existing application error boundary. A +failed operation re-enables its submit control in `finally`, allowing a +corrected retry. Invalid connection input is rejected before API access. Search +submission has no durable side effect and needs no recovery procedure. + +## Acceptance evidence + +`frontend/src/App.coverage.test.tsx` uses real keyboard events against rendered +application forms. It proves whitespace-only project rejection, exact-once +editor and project-list Enter submission, invalid DSN rejection and secret +clearing, exact-once connection submission, async re-entry blocking, semantic +search roles, and preserved live filtering. Existing click tests prove that +pointer activation uses the same path. + +## References + +Web Hypertext Application Technology Working Group. (2026). *HTML living +standard: Forms*. https://html.spec.whatwg.org/multipage/forms.html + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines +(WCAG) 2.2: Guideline 2.1 keyboard accessible*. +https://www.w3.org/TR/WCAG22/#keyboard-accessible diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index c78944c31..78ea7d5ff 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Added +- **네이티브 폼 제출 및 검색 접근성**: 프로젝트·연결 생성 폼의 Enter 제출 경로를 네이티브 form 이벤트로 통합하고 검색 폼에 `role="search"`를 부여해 키보드 사용자가 예측 가능한 제출과 탐색을 할 수 있도록 개선했습니다. - **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가. - **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등). - `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완. diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index 0b9a20aa8..2b5de1052 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const api = vi.hoisted(() => ({ @@ -308,6 +309,84 @@ function forceClick(button: HTMLButtonElement) { } describe('App orchestration coverage', () => { + it('submits the editor project form exactly once with Enter', async () => { + const user = userEvent.setup() + await renderReadyApp() + fireEvent.click(screen.getByRole('button', { name: '편집기' })) + + const projectInput = screen.getByLabelText('New project') + await user.clear(projectInput) + await user.type(projectInput, ' Native Enter {enter}') + + await waitFor(() => expect(api.createProject).toHaveBeenCalledWith('Native Enter')) + expect(api.createProject).toHaveBeenCalledTimes(1) + }) + + it('uses the same guarded native form path for project-list creation', async () => { + const user = userEvent.setup() + await renderReadyApp() + fireEvent.click(screen.getByRole('button', { name: '프로젝트' })) + const projectInput = screen.getByLabelText('새 프로젝트 이름') + + await user.clear(projectInput) + await user.type(projectInput, ' {enter}') + expect(api.createProject).not.toHaveBeenCalled() + + await user.clear(projectInput) + await user.type(projectInput, 'Roadmap{enter}') + await waitFor(() => expect(api.createProject).toHaveBeenCalledWith('Roadmap')) + expect(api.createProject).toHaveBeenCalledTimes(1) + }) + + it('validates and submits a connection once with Enter while blocking re-entry', async () => { + const user = userEvent.setup() + await renderReadyApp() + fireEvent.click(screen.getByRole('button', { name: '편집기' })) + const dsnInput = screen.getByLabelText('Connection DSN') + + await user.type(dsnInput, 'http://bad.example/db{enter}') + expect(api.createConnection).not.toHaveBeenCalled() + expect(dsnInput).toHaveValue('') + + let resolveConnection!: (value: { db_connection_uuid: string; conn_name: string }) => void + api.createConnection.mockReturnValueOnce(new Promise((resolve) => { + resolveConnection = resolve + })) + await user.type(dsnInput, 'postgresql://db.example/test{enter}') + await waitFor(() => expect(api.createConnection).toHaveBeenCalledTimes(1)) + expect(dsnInput).toHaveValue('') + + const connectionForm = dsnInput.closest('form') + expect(connectionForm).not.toBeNull() + fireEvent.change(dsnInput, { target: { value: 'postgresql://db.example/second' } }) + fireEvent.submit(connectionForm!) + expect(api.createConnection).toHaveBeenCalledTimes(1) + await act(async () => resolveConnection({ db_connection_uuid: 'c3', conn_name: 'New DB' })) + }) + + it('keeps live diagram and canvas filtering inside non-navigating search forms', async () => { + const user = userEvent.setup() + await renderReadyApp() + fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + const diagramSearch = screen.getByRole('searchbox', { name: '다이어그램 검색' }) + + await user.type(diagramSearch, 'failed{enter}') + expect(screen.getByText('ERD_all_2')).toBeInTheDocument() + const diagramSearchForm = diagramSearch.closest('form') + expect(diagramSearchForm).toHaveAttribute('role', 'search') + expect(fireEvent.submit(diagramSearchForm!)).toBe(false) + expect(screen.getByRole('search', { name: '다이어그램 검색' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '편집기 열기' })) + const canvasSearch = screen.getByRole('searchbox', { name: '테이블 또는 컬럼 검색' }) + await user.type(canvasSearch, 'users{enter}') + expect(canvasSearch).toHaveValue('users') + const canvasSearchForm = canvasSearch.closest('form') + expect(canvasSearchForm).toHaveAttribute('role', 'search') + expect(fireEvent.submit(canvasSearchForm!)).toBe(false) + expect(screen.getByRole('search', { name: 'ERD 캔버스 검색' })).toBeInTheDocument() + }) + it('shows loading and explicit authentication failure', async () => { let rejectMe!: (reason?: unknown) => void api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject })) @@ -325,10 +404,10 @@ describe('App orchestration coverage', () => { 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.change(screen.getByRole('searchbox', { name: '다이어그램 검색' }), { target: { value: 'no-match' } }) + await waitFor(() => expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument()) + fireEvent.change(screen.getByRole('searchbox', { name: '다이어그램 검색' }), { target: { value: 'failed' } }) + await waitFor(() => expect(screen.getByText('ERD_all_2')).toBeInTheDocument()) fireEvent.click(screen.getByRole('button', { name: '편집기 열기' })) expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument() @@ -383,7 +462,7 @@ describe('App orchestration coverage', () => { expect(api.getSnapshot).toHaveBeenCalledWith('s1') expect(screen.getByTestId('node-count')).toHaveTextContent('2') - fireEvent.change(screen.getByLabelText('테이블 또는 컬럼 검색'), { target: { value: 'users' } }) + fireEvent.change(screen.getByRole('searchbox', { name: '테이블 또는 컬럼 검색' }), { target: { value: 'users' } }) expect(screen.getByText('1개 테이블 일치', { exact: false })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'ERD 자동 정렬' })) await act(async () => { @@ -610,8 +689,9 @@ describe('App orchestration coverage', () => { 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(screen.getAllByRole('button', { name: '열기' })[0]!) + fireEvent.click(openButtons[0]!) await act(async () => { vi.advanceTimersByTime(1000) await Promise.resolve() @@ -641,8 +721,9 @@ describe('App orchestration coverage', () => { .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(screen.getAllByRole('button', { name: '열기' })[0]!) + fireEvent.click(openButtons[0]!) await act(async () => { vi.advanceTimersByTime(1000) await Promise.resolve() @@ -744,8 +825,9 @@ describe('App orchestration coverage', () => { })) await renderReadyApp() fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + const openButtons = await screen.findAllByRole('button', { name: '열기' }) vi.useFakeTimers() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + fireEvent.click(openButtons[0]!) await act(async () => { vi.advanceTimersByTime(1000) await Promise.resolve() @@ -784,8 +866,9 @@ describe('App orchestration coverage', () => { }) await renderReadyApp() fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + const openButtons = await screen.findAllByRole('button', { name: '열기' }) vi.useFakeTimers() - fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + fireEvent.click(openButtons[0]!) await act(async () => { vi.advanceTimersByTime(1000) await Promise.resolve() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49812e448..4c446a982 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,14 @@ import { addEdge, type Connection as FlowConnection, } from "@xyflow/react"; -import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + useCallback, + type FormEvent, +} from "react"; import { AddTableModal, @@ -988,8 +995,12 @@ export default function App() { } } + function onCreateProjectSubmit(event: FormEvent) { + event.preventDefault(); + void onCreateProject(); + } + async function onCreateConnection() { - /* v8 ignore next -- the save control is disabled without a project or while saving */ if (!selectedProjectId || isCreatingConnection) return; const nextConnectionName = connName.trim(); // The handler is mounted beside this input, so the ref is established first. @@ -1020,6 +1031,15 @@ export default function App() { } } + function onCreateConnectionSubmit(event: FormEvent) { + event.preventDefault(); + void onCreateConnection(); + } + + function onSearchSubmit(event: FormEvent) { + event.preventDefault(); + } + async function onCreateSnapshot() { /* v8 ignore next -- the snapshot control is disabled for every guard state */ if (!selectedProjectId || !selectedConnId || isCreatingSnapshot) return; @@ -1112,7 +1132,7 @@ export default function App() { -
+
setProjectName(e.target.value)} />
+

@@ -1160,7 +1179,7 @@ export default function App() {
-
+
+
@@ -1343,20 +1361,19 @@ export default function App() {

프로젝트

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

-
+
setProjectName(event.currentTarget.value)} /> -
+
@@ -1397,16 +1414,23 @@ export default function App() { 편집기 열기
- +
+ +
- +
+ +