diff --git a/.Jules/palette.md b/.Jules/palette.md index 7577a1ca..ab9c1610 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 02439a97..9d7379b5 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 024bde03..15f2d57d 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 998929d4..c512662a 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 aa9dac53..be74abc2 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()