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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- [FE] ♿ **네이티브 폼 키보드 제출**: 편집기·프로젝트 목록의 프로젝트 생성과 연결 생성은 한 개의 guarded `submit` 경로를 사용해 Enter와 클릭이 정확히 한 번 동작합니다. 다이어그램·캔버스 검색은 `role="search"` 폼에서 Enter 내비게이션만 막고 실시간 필터를 유지합니다.
Comment thread
seonghobae marked this conversation as resolved.
Resolved
- [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`를 제거합니다. 단위 테스트로 누출을 차단합니다.
Expand Down
65 changes: 65 additions & 0 deletions docs/doctoring/native-form-submission.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
### Added
- **네이티브 폼 제출 및 검색 접근성**: 프로젝트·연결 생성 폼의 Enter 제출 경로를 네이티브 form 이벤트로 통합하고 검색 폼에 `role="search"`를 부여해 키보드 사용자가 예측 가능한 제출과 탐색을 할 수 있도록 개선했습니다.
- **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가.
- **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등).
- `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완.
Expand Down
101 changes: 92 additions & 9 deletions frontend/src/App.coverage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -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 }))
Expand All @@ -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()

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading