diff --git a/.jules/palette.md b/.jules/palette.md index bd0f73248..d271f45aa 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. +## 2026-08-30 - Fix RTL DOM Bleed Causing Extraneous Rendered Components +**Learning:** In frontend Vitest tests, the guidance now treats "Found multiple elements" as a duplicate-match symptom rather than proof of failed auto-cleanup, requires inspecting the rendered DOM/query scope first, and uses explicit cleanup only when cross-test bleed is actually verified. +**Action:** Always inspect the rendered DOM and query scope when debugging multiple-elements errors, applying explicit cleanup only as a verified last resort for actual state bleed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..1b52c47bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] ♿ **필수 모달 입력 검증 정합화**: 필수 입력 표시를 공유 `--color-danger` 토큰 기반 컴포넌트로 통일하고, 관계 제약조건/업무 그룹 이름의 공백-only 입력을 저장하지 않으며 고객이 다음에 입력해야 할 값을 브라우저 검증 메시지로 안내합니다. - [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/required-modal-field-validation.md b/docs/doctoring/required-modal-field-validation.md new file mode 100644 index 000000000..b0b8cbb96 --- /dev/null +++ b/docs/doctoring/required-modal-field-validation.md @@ -0,0 +1,35 @@ +# Required modal field validation + +## Buyer problem + +Several modal forms displayed a visual required marker while the executable constraint did not match it. In particular, relationship labels and business-group names could contain only whitespace: HTML `required` treats the empty string as missing, but whitespace is still a non-empty value. That mismatch made the UI promise stricter validation than the form actually enforced. + +## Product contract + +- Native `required` remains the semantic baseline for every mandatory text input in this slice. +- Relationship labels and business-group names additionally reject whitespace-only values before the mutation callback runs. +- Empty relationship and group names receive the same actionable Korean guidance through the input's native invalid event; whitespace-only values receive equivalent guidance through the explicit trim guard. +- The business-group form also renders linked guidance while its Add action is disabled, so the customer can see the next action without needing to trigger an unavailable submit control. +- Changing either value clears the stale custom validity message immediately. +- The visual `*` is centralized in `RequiredIndicator`, uses the existing `--color-danger` token, and is `aria-hidden="true"` because the input itself carries the required semantics. +- No API, database, persistence, authorization, or deployment contract changes. + +## Design and accessibility authority + +Fresh Figma evidence on 2026-08-30 confirms file `csnpEEJfmqFWB0vNUoTkWA`, page `190:2` (`Design System — Storybook Contract`), frame `190:40` (`Design Token Authority`), including `--color-danger = #b91c1c` and the instruction to change runtime source first, keep Storybook exact-value tests green, then resync Figma. The paired executable Storybook token inventory is currently owned by PR #944 at exact head `ffcf653e78306563797262678a4313c8215a5932` and renders exact token values as text while marking decorative previews `aria-hidden`. + +This PR reuses that shared token contract rather than adding a local color. It does not claim a current Storybook component-state story for required-field validation: the protected base does not yet contain #944. Therefore design acceptance remains fail-closed until the required-field state is represented against the then-current paired Figma and Storybook authority before merge. + +## Verification + +Focused tests pin semantic `required` state for add-table, relationship, group, table-title, column-name, and column-type inputs; empty and whitespace-only validation guidance; whitespace-only mutation blocking; linked visible group guidance; clearing stale custom validity; and the decorative marker's exclusion from the accessibility tree. Existing modal coverage continues to exercise valid submit/cancel/delete flows. + +## Standards traceability + +The HTML Standard defines `required` as a boolean constraint and treats a required mutable input whose value is the empty string as missing. The same standard exposes `setCustomValidity()` and `reportValidity()` for constraints beyond the native empty-string rule. WAI-ARIA 1.2 specifies that elements with `aria-hidden="true"` should not be included in the accessibility tree; the marker is non-focusable decoration, not the semantic source of required state. + +### References + +WHATWG. (2026). *HTML Standard*. Retrieved August 30, 2026, from https://html.spec.whatwg.org/ + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation, June 6, 2023). https://www.w3.org/TR/wai-aria-1.2/ diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index c78944c31..0846fc3cd 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -6,5 +6,8 @@ - **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등). - `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완. +### Fixed +- 필수 모달 필드의 시각적 표시와 실제 `required`/공백 검증을 정합화하고, 유효하지 않은 관계·그룹 이름에는 고객이 다음에 입력할 값을 설명하는 검증 메시지를 제공합니다. + ### Security - `postcss` 트랜지티브 의존성을 `overrides`로 `^8.5.18`(8.5.25로 해석)에 고정해 GHSA-r28c-9q8g-f849(High, source-map 자동 로딩의 경로 순회를 통한 임의 `.map` 노출)을 제거했습니다. postcss는 Vite 빌드 툴체인 전용 트랜지티브 의존성으로 배포 런타임 경로에 없으며, 조치 후 `npm audit --audit-level=high` → High 이상 취약점 0건. 변경 범위는 `frontend/package.json`의 `overrides` 맵(`postcss` 항목 추가)과 재생성된 `frontend/package-lock.json`이며, 애플리케이션 소스는 수정하지 않았습니다. diff --git a/frontend/src/components/modals/AddTableModal.tsx b/frontend/src/components/modals/AddTableModal.tsx index 024bde03f..ffacd364c 100644 --- a/frontend/src/components/modals/AddTableModal.tsx +++ b/frontend/src/components/modals/AddTableModal.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { RequiredIndicator } from './RequiredIndicator'; import { useDialogAccessibility } from './useDialogAccessibility'; interface AddTableModalProps { @@ -9,6 +10,7 @@ interface AddTableModalProps { onAddTableSubmit: () => void; } +/** Renders the add-table dialog with native required-name semantics. */ export function AddTableModal({ isOpen, newTableName, @@ -61,7 +63,9 @@ export function AddTableModal({ >

테이블 추가

- + { const opener = screen.getByRole('button', { name: 'Open group manager' }); await user.click(opener); - await waitFor(() => expect(screen.getByLabelText('그룹 이름')).toHaveFocus()); + await waitFor(() => expect(screen.getByLabelText(/그룹 이름/)).toHaveFocus()); await user.keyboard('{Escape}'); expect(onCloseGroupManager).toHaveBeenCalledOnce(); @@ -77,7 +77,7 @@ describe('modal dialog accessibility', () => { />, ); - const tableNameInput = screen.getByLabelText('테이블 이름'); + const tableNameInput = screen.getByLabelText(/테이블 이름/); const saveButton = screen.getByRole('button', { name: '저장' }); await waitFor(() => expect(tableNameInput).toHaveFocus()); diff --git a/frontend/src/components/modals/EditEdgeModal.tsx b/frontend/src/components/modals/EditEdgeModal.tsx index ccd06f195..596e172f2 100644 --- a/frontend/src/components/modals/EditEdgeModal.tsx +++ b/frontend/src/components/modals/EditEdgeModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { Edge } from "@xyflow/react"; +import { RequiredIndicator } from './RequiredIndicator'; import { useDialogAccessibility } from './useDialogAccessibility'; interface EditEdgeModalProps { @@ -11,6 +12,7 @@ interface EditEdgeModalProps { onRelSubmit: () => void; } +/** Renders relationship editing and blocks blank constraint names before mutation. */ export function EditEdgeModal({ editingEdge, relLabel, @@ -20,6 +22,7 @@ export function EditEdgeModal({ onRelSubmit, }: EditEdgeModalProps) { const dialogRef = useDialogAccessibility(Boolean(editingEdge), onRelCancel); + const relLabelInputRef = React.useRef(null); if (!editingEdge) return null; @@ -48,6 +51,13 @@ export function EditEdgeModal({ tabIndex={-1} onSubmit={(e) => { e.preventDefault(); + const input = relLabelInputRef.current; + if (!relLabel.trim()) { + input?.setCustomValidity("제약조건 이름을 입력하세요."); + input?.reportValidity(); + return; + } + input?.setCustomValidity(""); onRelSubmit(); }} style={{ @@ -66,13 +76,25 @@ export function EditEdgeModal({ To: {editingEdge.target}
- + setRelLabel(e.target.value)} + onInvalid={(e) => { + if (!e.currentTarget.value.trim()) { + e.currentTarget.setCustomValidity("제약조건 이름을 입력하세요."); + } + }} + onChange={(e) => { + e.currentTarget.setCustomValidity(""); + setRelLabel(e.target.value); + }} placeholder="fk_constraint_name" autoFocus + required />
void; } +/** Renders table and column editing with native required-field semantics. */ export function EditTableModal({ isOpen, editingNode, @@ -36,12 +38,15 @@ export function EditTableModal({
- +
@@ -114,7 +119,8 @@ export function EditTableModal({ type="text" name={`col_name_${idx}`} defaultValue={col.column_name} - placeholder="컬럼명" + placeholder="컬럼명 (필수)" + required style={{ flex: 2 }} aria-label={`${col.column_name} 컬럼명`} /> @@ -122,7 +128,8 @@ export function EditTableModal({ type="text" name={`col_type_${idx}`} defaultValue={col.data_type} - placeholder="데이터 타입" + placeholder="데이터 타입 (필수)" + required style={{ flex: 1.5 }} aria-label={`${col.column_name} 데이터 타입`} /> diff --git a/frontend/src/components/modals/GroupModal.tsx b/frontend/src/components/modals/GroupModal.tsx index bd96c21b1..64fd4a58c 100644 --- a/frontend/src/components/modals/GroupModal.tsx +++ b/frontend/src/components/modals/GroupModal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type { Node } from "@xyflow/react"; import type { TableNodeData } from "../../erd/convert"; import { BUSINESS_GROUP_COLORS, type BusinessGroup } from "../../erd/businessGroups"; +import { RequiredIndicator } from './RequiredIndicator'; import { useDialogAccessibility } from './useDialogAccessibility'; interface GroupModalProps { @@ -18,6 +19,7 @@ interface GroupModalProps { onAssignBusinessGroup: (nodeId: string, groupId: string) => void; } +/** Renders business-group management and prevents blank group creation. */ export function GroupModal({ isOpen, businessGroups, @@ -32,6 +34,11 @@ export function GroupModal({ onAssignBusinessGroup, }: GroupModalProps) { const dialogRef = useDialogAccessibility(isOpen, onCloseGroupManager); + const groupNameInputRef = React.useRef(null); + const hasMissingGroupName = !newGroupName.trim(); + const groupNameGuidance = newGroupName.length === 0 + ? "그룹 이름을 입력하세요." + : "공백이 아닌 그룹 이름을 입력하세요."; if (!isOpen) return null; @@ -56,16 +63,48 @@ export function GroupModal({
- { e.preventDefault(); if (newGroupName.trim()) { onCreateBusinessGroup(); } }}> + { + e.preventDefault(); + const input = groupNameInputRef.current; + if (!newGroupName.trim()) { + input?.setCustomValidity("그룹 이름을 입력하세요."); + input?.reportValidity(); + return; + } + input?.setCustomValidity(""); + onCreateBusinessGroup(); + }} + >
- + setNewGroupName(event.target.value)} + onInvalid={(event) => { + if (!event.currentTarget.value.trim()) { + event.currentTarget.setCustomValidity("그룹 이름을 입력하세요."); + } + }} + onChange={(event) => { + event.currentTarget.setCustomValidity(""); + setNewGroupName(event.target.value); + }} placeholder="Billing" + required + aria-invalid={hasMissingGroupName || undefined} + aria-describedby={hasMissingGroupName ? "business-group-name-error" : undefined} /> + {hasMissingGroupName ? ( +
+ {groupNameGuidance} +
+ ) : null}
{ onAddTableSubmit={onSubmit} />, ) - fireEvent.change(screen.getByLabelText('테이블 이름'), { target: { value: 'users' } }) + fireEvent.change(screen.getByLabelText(/테이블 이름/), { target: { value: 'users' } }) fireEvent.submit(screen.getByRole('dialog')) expect(setNewTableName).toHaveBeenCalledWith('users') expect(onSubmit).not.toHaveBeenCalled() @@ -113,7 +113,7 @@ describe('modal behavior coverage', () => { />, ) expect(screen.getByText(/From: a/)).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('제약조건 이름 (Label)'), { + fireEvent.change(screen.getByLabelText(/제약조건 이름 \(Label\)/), { target: { value: 'fk_changed' }, }) vi.spyOn(window, 'confirm').mockReturnValueOnce(true) @@ -261,7 +261,7 @@ describe('modal behavior coverage', () => { onAssignBusinessGroup={onAssign} />, ) - fireEvent.change(screen.getByLabelText('그룹 이름'), { target: { value: 'New' } }) + fireEvent.change(screen.getByLabelText(/그룹 이름/), { target: { value: 'New' } }) fireEvent.click(screen.getAllByRole('button', { name: /^색상 / })[1]!) fireEvent.click(screen.getByRole('button', { name: '추가' })) vi.spyOn(window, 'confirm').mockReturnValueOnce(true) diff --git a/frontend/src/components/modals/RequiredFieldValidation.test.tsx b/frontend/src/components/modals/RequiredFieldValidation.test.tsx new file mode 100644 index 000000000..558b5a31f --- /dev/null +++ b/frontend/src/components/modals/RequiredFieldValidation.test.tsx @@ -0,0 +1,166 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, afterEach } from 'vitest'; + +import { AddTableModal } from './AddTableModal'; +import { EditEdgeModal } from './EditEdgeModal'; +import { EditTableModal } from './EditTableModal'; +import { GroupModal } from './GroupModal'; + +describe('required modal field validation', () => { + afterEach(cleanup); + it('provides actionable validation when the relationship label is empty', () => { + render( + , + ); + + const input = screen.getByLabelText(/제약조건 이름 \(Label\)/); + fireEvent.invalid(input); + + expect(input).toHaveProperty('validationMessage', '제약조건 이름을 입력하세요.'); + }); + + it('blocks whitespace-only relationship labels with actionable validation', () => { + const onRelSubmit = vi.fn(); + const setRelLabel = vi.fn(); + + render( + , + ); + + const input = screen.getByLabelText(/제약조건 이름 \(Label\)/); + expect(input).toBeRequired(); + + fireEvent.submit(input.closest('form')!); + + expect(onRelSubmit).not.toHaveBeenCalled(); + expect(input).toHaveProperty('validationMessage', '제약조건 이름을 입력하세요.'); + + fireEvent.change(input, { target: { value: 'fk_orders_customer' } }); + expect(setRelLabel).toHaveBeenCalledWith('fk_orders_customer'); + expect(input).toHaveProperty('validationMessage', ''); + }); + + it('explains an empty group name before the disabled action', () => { + render( + , + ); + + const input = screen.getByLabelText(/그룹 이름/); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-describedby', 'business-group-name-error'); + expect(screen.getByText('그룹 이름을 입력하세요.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '추가' })).toBeDisabled(); + + fireEvent.invalid(input); + expect(input).toHaveProperty('validationMessage', '그룹 이름을 입력하세요.'); + }); + + it('explains and blocks whitespace-only group creation', () => { + const onCreateBusinessGroup = vi.fn(); + const setNewGroupName = vi.fn(); + + render( + , + ); + + const input = screen.getByLabelText(/그룹 이름/); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('공백이 아닌 그룹 이름을 입력하세요.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '추가' })).toBeDisabled(); + + fireEvent.submit(input.closest('form')!); + + expect(onCreateBusinessGroup).not.toHaveBeenCalled(); + expect(input).toHaveProperty('validationMessage', '그룹 이름을 입력하세요.'); + + fireEvent.change(input, { target: { value: 'Billing' } }); + expect(setNewGroupName).toHaveBeenCalledWith('Billing'); + expect(input).toHaveProperty('validationMessage', ''); + }); + + it('keeps the add-table required marker decorative and the input semantic', () => { + render( + , + ); + + expect(screen.getByLabelText(/테이블 이름/)).toBeRequired(); + const indicator = document.querySelector('[data-required-indicator="true"]'); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('marks the table title, column name, and data type as required', () => { + render( + , + ); + + expect(screen.getByLabelText(/테이블명 \(schema\.table\)/)).toBeRequired(); + expect(screen.getByRole('textbox', { name: 'id 컬럼명' })).toBeRequired(); + expect(screen.getByRole('textbox', { name: 'id 데이터 타입' })).toBeRequired(); + }); +}); diff --git a/frontend/src/components/modals/RequiredIndicator.tsx b/frontend/src/components/modals/RequiredIndicator.tsx new file mode 100644 index 000000000..811a03af9 --- /dev/null +++ b/frontend/src/components/modals/RequiredIndicator.tsx @@ -0,0 +1,12 @@ +/** Decorative required-field marker; native form validation remains the semantic authority. */ +export function RequiredIndicator() { + return ( + + ); +}