diff --git a/.Jules/palette.md b/.Jules/palette.md index 7577a1ca7..ab9c16102 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -56,3 +56,13 @@ ## 2024-07-14 - Native Keyboard Submission with Forms for Modals **Learning:** Modals designed with plain `
` elements as wrappers instead of `
` lack native keyboard submission support, forcing users to switch from keyboard to mouse to confirm actions like "Save". **Action:** When designing modals or popups containing inputs, always use a `` element to wrap the content, handle the `onSubmit` event (calling `e.preventDefault()`), and set the primary confirmation button to `type="submit"` to enable seamless Enter-key submission for keyboard users. + + + +## 2024-07-16 - Add ARIA Labels to Generic Modal Action Buttons +**Learning:** In a canvas-based data modeling tool (like this ERD app) containing many separate modals for tasks like table editing, edge editing, and grouping, generic button text like "취소" (Cancel), "저장" (Save), and "삭제" (Delete) causes significant ambiguity for screen reader users when isolated from visual context. When navigating forms, screen reader users might not know *which* modal's action they are triggering. +**Action:** Added explicit context to generic buttons in `EditTableModal`, `AddTableModal`, and `EditEdgeModal` using `aria-label`s (e.g., `aria-label="테이블 편집 취소"` or `aria-label="a에서 b 관계 삭제"`). Always remember to update the corresponding tests (e.g. `getByRole`) whenever `aria-label` is changed. + +## 2026-07-16 - Safe Component Unmounting in React Modals +**Learning:** When a modal is closed, unsubmitted form state can inadvertently persist in parent component structures if state reset logic is absent. In `EditTableModal.tsx`, editing the graph (e.g. adding or removing columns) mutates `setNodes` directly, but clicking 'Cancel' only dismissed the modal. This creates a state desync where canceled changes remain in the diagram. Strix security scanners flag this as a business-logic integrity flaw. +**Action:** For modals that perform optimistic state updates on shared state objects (like nodes in a canvas), always take a deep-copy snapshot of the initial state upon opening. Ensure `onCancel` handles reverting the parent state back to this snapshot before dismissal. diff --git a/frontend/package.json b/frontend/package.json index 02439a975..9d7379b5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,5 +36,6 @@ }, "overrides": { "esbuild": "^0.25.0" - } + }, + "packageManager": "pnpm@10.30.3" } diff --git a/frontend/src/components/modals/AddTableModal.tsx b/frontend/src/components/modals/AddTableModal.tsx index 024bde03f..15f2d57d0 100644 --- a/frontend/src/components/modals/AddTableModal.tsx +++ b/frontend/src/components/modals/AddTableModal.tsx @@ -75,10 +75,11 @@ export function AddTableModal({ className="row" style={{ justifyContent: "flex-end", marginTop: 8 }} > - +
- + diff --git a/frontend/src/components/modals/EditTableModal.tsx b/frontend/src/components/modals/EditTableModal.tsx index 998929d44..c512662ab 100644 --- a/frontend/src/components/modals/EditTableModal.tsx +++ b/frontend/src/components/modals/EditTableModal.tsx @@ -22,7 +22,27 @@ export function EditTableModal({ onEditTableSubmit, onDeleteTable, }: EditTableModalProps) { - const dialogRef = useDialogAccessibility(isOpen && Boolean(editingNode), onEditTableCancel); + // Snapshot the original node so we can restore it on cancel if needed + const [originalNodeSnapshot, setOriginalNodeSnapshot] = React.useState | null>(null); + + React.useEffect(() => { + if (isOpen && editingNode && !originalNodeSnapshot) { + setOriginalNodeSnapshot(JSON.parse(JSON.stringify(editingNode))); + } + if (!isOpen) { + setOriginalNodeSnapshot(null); + } + }, [isOpen, editingNode, originalNodeSnapshot]); + + const handleCancel = () => { + if (originalNodeSnapshot) { + setNodes((nds) => nds.map((n) => n.id === originalNodeSnapshot.id ? originalNodeSnapshot : n)); + setEditingNode(originalNodeSnapshot); + } + onEditTableCancel(); + }; + + const dialogRef = useDialogAccessibility(isOpen && Boolean(editingNode), handleCancel); if (!isOpen || !editingNode) return null; @@ -31,7 +51,7 @@ export function EditTableModal({

테이블 편집

- +
@@ -61,6 +81,12 @@ export function EditTableModal({
- + diff --git a/frontend/src/components/modals/ModalCoverage.test.tsx b/frontend/src/components/modals/ModalCoverage.test.tsx index aa9dac53a..be74abc2c 100644 --- a/frontend/src/components/modals/ModalCoverage.test.tsx +++ b/frontend/src/components/modals/ModalCoverage.test.tsx @@ -69,7 +69,7 @@ describe('modal behavior coverage', () => { fireEvent.submit(screen.getByRole('dialog')) expect(setNewTableName).toHaveBeenCalledWith('users') expect(onSubmit).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: '취소' })) + fireEvent.click(screen.getByRole('button', { name: '테이블 추가 취소' })) expect(onCancel).toHaveBeenCalledOnce() rerender( @@ -83,7 +83,7 @@ describe('modal behavior coverage', () => { ) fireEvent.submit(screen.getByRole('dialog')) expect(onSubmit).toHaveBeenCalledOnce() - expect(screen.getByRole('button', { name: '저장' })).toBeEnabled() + expect(screen.getByRole('button', { name: '테이블 추가 저장' })).toBeEnabled() }) it('covers EditEdgeModal visibility and actions', () => { @@ -116,9 +116,9 @@ describe('modal behavior coverage', () => { fireEvent.change(screen.getByLabelText('제약조건 이름 (Label)'), { target: { value: 'fk_changed' }, }) - fireEvent.click(screen.getByRole('button', { name: '삭제' })) - fireEvent.click(screen.getByRole('button', { name: '취소' })) - fireEvent.click(screen.getByRole('button', { name: '저장' })) + fireEvent.click(screen.getByRole('button', { name: 'a에서 b 관계 삭제' })) + fireEvent.click(screen.getByRole('button', { name: '관계 설정 취소' })) + fireEvent.click(screen.getByRole('button', { name: '관계 설정 저장' })) expect(setRelLabel).toHaveBeenCalledWith('fk_changed') expect(onDelete).toHaveBeenCalledOnce() expect(onCancel).toHaveBeenCalledOnce() @@ -191,7 +191,7 @@ describe('modal behavior coverage', () => { data: { title: 'public.users_copy' }, }) expect(duplicated.data.columns).not.toBe(tableNode.data.columns) - fireEvent.click(screen.getByRole('button', { name: '취소' })) + fireEvent.click(screen.getByRole('button', { name: '테이블 편집 취소' })) fireEvent.click(screen.getByRole('button', { name: '닫기' })) expect(onSubmit).toHaveBeenCalledOnce() expect(onDeleteTable).toHaveBeenCalledOnce()