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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
## 2024-05-24 - Fix Keyboard Accessibility on Forms
**Learning:** Users cannot use the 'Enter' key to submit forms if inputs and action buttons are wrapped in generic `<div>` tags instead of native `<form>` elements with an `onSubmit` handler.
**Action:** Ensure inputs and their corresponding action buttons are wrapped in native `<form>` tags with an `onSubmit={(e) => e.preventDefault(); ...}` handler and `type="submit"` buttons to provide native keyboard accessibility.
110 changes: 110 additions & 0 deletions frontend/src/App.nativeFormSubmission.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import '@testing-library/jest-dom/vitest'
import userEvent from '@testing-library/user-event'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const api = vi.hoisted(() => ({
getMe: vi.fn(),
listProjects: vi.fn(),
listConnections: vi.fn(),
listSnapshots: vi.fn(),
createProject: vi.fn(),
createConnection: vi.fn(),
createSnapshot: vi.fn(),
getSnapshot: vi.fn(),
createShareLink: vi.fn(),
}))

vi.mock('./api', () => api)

globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}

import App from './App'

beforeEach(() => {
vi.clearAllMocks()
api.getMe.mockResolvedValue({ subject: 'test-user', display_name: 'Test User' })
api.listProjects.mockResolvedValue([
{ project_space_uuid: 'project-1', project_name: 'Billing' },
])
api.listConnections.mockResolvedValue([
{ db_connection_uuid: 'connection-1', conn_name: 'Warehouse' },
])
api.listSnapshots.mockResolvedValue([])
api.createProject.mockResolvedValue({
project_space_uuid: 'project-created',
project_name: 'Keyboard project',
})
api.createConnection.mockResolvedValue({
db_connection_uuid: 'connection-created',
conn_name: 'Keyboard DB',
})
api.createSnapshot.mockResolvedValue({
schema_snapshot_uuid: 'snapshot-created',
status: 'queued',
schema_filter: 'audit',
})
api.getSnapshot.mockResolvedValue({
schema_snapshot_uuid: 'snapshot-created',
status: 'succeeded',
schema_filter: 'audit',
error_message: null,
snapshot_json: { relations: [], columns: [], pk_columns: [], fk_edges: [] },
})
api.createShareLink.mockResolvedValue({ url: 'http://localhost/share/example' })
})

afterEach(() => {
cleanup()
vi.restoreAllMocks()
})

describe('native form keyboard submission', () => {
it('submits the sidebar create actions with Enter without duplicate activation', async () => {
const user = userEvent.setup()
render(<App />)
await screen.findByRole('heading', { name: '대시보드' })

await user.click(screen.getByRole('button', { name: '편집기' }))

const projectName = await screen.findByLabelText('New project')
await user.clear(projectName)
await user.type(projectName, 'Keyboard project{Enter}')
await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1))
expect(api.createProject).toHaveBeenCalledWith('Keyboard project')

await waitFor(() => {
expect(api.listConnections).toHaveBeenCalledWith('project-created')
})

const connectionName = screen.getByLabelText('New connection (DSN)')
await user.clear(connectionName)
await user.type(connectionName, 'Keyboard DB')
const dsn = screen.getByLabelText('Connection DSN')
await user.clear(dsn)
await user.type(dsn, 'postgresql://db.example.test/app{Enter}')
await waitFor(() => expect(api.createConnection).toHaveBeenCalledTimes(1))

const schemaFilter = screen.getByLabelText('Schema filter (optional)')
await user.type(schemaFilter, 'audit{Enter}')
await waitFor(() => expect(api.createSnapshot).toHaveBeenCalledTimes(1))
})

it('submits the projects-page inline create form with Enter', async () => {
const user = userEvent.setup()
render(<App />)
await screen.findByRole('heading', { name: '대시보드' })

await user.click(screen.getByRole('button', { name: '프로젝트' }))
const projectName = await screen.findByLabelText('새 프로젝트 이름')
await user.clear(projectName)
await user.type(projectName, 'Keyboard project{Enter}')

await waitFor(() => expect(api.createProject).toHaveBeenCalledTimes(1))
expect(api.createProject).toHaveBeenCalledWith('Keyboard project')
})
})
48 changes: 22 additions & 26 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1114,15 +1114,14 @@ export default function App() {

<div className="field">
<label htmlFor="project-name">New project</label>
<div className="row">
<form className="row" onSubmit={(e) => { e.preventDefault(); onCreateProject(); }}>
<input
id="project-name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
/>
<button
type="button"
onClick={onCreateProject}
type="submit"
disabled={!projectName.trim() || isCreatingProject}
aria-busy={isCreatingProject}
aria-describedby={
Expand All @@ -1131,7 +1130,7 @@ export default function App() {
>
{isCreatingProject ? "Creating…" : "Create"}
</button>
</div>
</form>
{createProjectHint ? (
<span id="create-project-hint" className="field-hint">
{createProjectHint}
Expand Down Expand Up @@ -1160,7 +1159,7 @@ export default function App() {
</select>
</div>

<div className="field">
<form className="field" onSubmit={(e) => { e.preventDefault(); onCreateConnection(); }}>
<label htmlFor="conn-name">New connection (DSN)</label>
<input
id="conn-name"
Expand All @@ -1179,8 +1178,7 @@ export default function App() {
aria-label="Connection DSN"
/>
<button
type="button"
onClick={onCreateConnection}
type="submit"
disabled={
!selectedProjectId ||
!connName.trim() ||
Expand All @@ -1199,29 +1197,28 @@ export default function App() {
{createConnectionHint}
</span>
) : null}
</div>
</form>

<div className="field">
<form className="field" onSubmit={(e) => { e.preventDefault(); onCreateSnapshot(); }}>
<label htmlFor="schema-filter">Schema filter (optional)</label>
<input
id="schema-filter"
value={schemaFilter}
onChange={(e) => setSchemaFilter(e.target.value)}
placeholder="public"
/>
</div>

<button
type="button"
onClick={onCreateSnapshot}
disabled={!selectedProjectId || !selectedConnId || isCreatingSnapshot}
aria-busy={isCreatingSnapshot}
aria-describedby={
createSnapshotHint ? "create-snapshot-hint" : undefined
}
>
{isCreatingSnapshot ? "Starting…" : "Reverse engineer → snapshot"}
</button>
<button
type="submit"
disabled={!selectedProjectId || !selectedConnId || isCreatingSnapshot}
aria-busy={isCreatingSnapshot}
aria-describedby={
createSnapshotHint ? "create-snapshot-hint" : undefined
}
style={{ marginTop: 12 }}
>
{isCreatingSnapshot ? "Starting…" : "Reverse engineer → snapshot"}
</button>
</form>
{createSnapshotHint ? (
<span id="create-snapshot-hint" className="field-hint">
{createSnapshotHint}
Expand Down Expand Up @@ -1343,20 +1340,19 @@ export default function App() {
<h1 id="projects-title">프로젝트</h1>
<p>프로젝트를 선택하면 해당 다이어그램 목록을 볼 수 있습니다.</p>
</div>
<div className="inlineCreate">
<form className="inlineCreate" onSubmit={(e) => { e.preventDefault(); onCreateProject(); }}>
<input
aria-label="새 프로젝트 이름"
value={projectName}
onChange={(event) => setProjectName(event.currentTarget.value)}
/>
<button
type="button"
onClick={onCreateProject}
type="submit"
disabled={!projectName.trim() || isCreatingProject}
>
{isCreatingProject ? "생성 중" : "새 프로젝트"}
</button>
</div>
</form>
</div>
<div className="dataTable" role="table" aria-label="프로젝트 목록">
<div className="dataTable__row dataTable__row--projects dataTable__row--head" role="row">
Expand Down
Loading