From 3d7d94857f6ba64a73965373b04a6769c55c34a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 22:31:56 +0900 Subject: [PATCH 1/7] fix(a11y): use native form submission paths --- CHANGELOG.md | 1 + docs/doctoring/native-form-submission.md | 62 +++++++++++++++ frontend/src/App.coverage.test.tsx | 73 +++++++++++++++++- frontend/src/App.tsx | 96 ++++++++++++++++-------- 4 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 docs/doctoring/native-form-submission.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a62024..ece5eb406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] ♿ **네이티브 폼 키보드 제출**: 편집기·프로젝트 목록의 프로젝트 생성과 연결 생성은 한 개의 guarded `submit` 경로를 사용해 Enter와 클릭이 정확히 한 번 동작합니다. 다이어그램·캔버스 검색은 `role="search"` 폼에서 Enter 내비게이션만 막고 실시간 필터를 유지합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. - [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다. - [Docs] README를 상용 기준 기능 설명으로 갱신 (MVP skeleton 표현 제거, share redaction·diff/export 반영). diff --git a/docs/doctoring/native-form-submission.md b/docs/doctoring/native-form-submission.md new file mode 100644 index 000000000..8416952ed --- /dev/null +++ b/docs/doctoring/native-form-submission.md @@ -0,0 +1,62 @@ +# 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"`. +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/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index 0b9a20aa8..3b211618e 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,75 @@ 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('') + + await user.type(dsnInput, 'postgresql://db.example/second{enter}') + 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.getByLabelText('다이어그램 검색') + + await user.type(diagramSearch, 'failed{enter}') + expect(screen.getByText('ERD_all_2')).toBeInTheDocument() + expect(diagramSearch.closest('form')).toHaveAttribute('role', 'search') + + fireEvent.click(screen.getByRole('button', { name: '편집기 열기' })) + const canvasSearch = screen.getByLabelText('테이블 또는 컬럼 검색') + await user.type(canvasSearch, 'users{enter}') + expect(canvasSearch).toHaveValue('users') + expect(canvasSearch.closest('form')).toHaveAttribute('role', 'search') + }) + it('shows loading and explicit authentication failure', async () => { let rejectMe!: (reason?: unknown) => void api.getMe.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectMe = reject })) @@ -744,8 +814,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 0fa62ede3..2a76652ba 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, @@ -954,6 +961,11 @@ 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; @@ -986,6 +998,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; @@ -1078,7 +1099,7 @@ export default function App() { -
+
setProjectName(e.target.value)} />
+

@@ -1126,7 +1146,7 @@ export default function App() {
-
+
+
@@ -1309,20 +1328,19 @@ export default function App() {

프로젝트

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

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