diff --git a/e2e/tests/discard-changes.spec.ts b/e2e/tests/discard-changes.spec.ts new file mode 100644 index 0000000..5e1b8ca --- /dev/null +++ b/e2e/tests/discard-changes.spec.ts @@ -0,0 +1,369 @@ +import { test, expect } from '@playwright/test'; +import { + login, + navigateAndWait, + waitForElement, + hoverAndWaitForVisible, + createUniqueEntity, + waitForNetworkIdle, +} from '../helpers/testHelpers'; + +// Helper to create a task +async function createTask(page, taskName) { + const taskInput = page.locator('[data-testid="new-task-input"]'); + await taskInput.fill(taskName); + await taskInput.press('Enter'); + await waitForNetworkIdle(page); +} + +// Helper to open task edit modal +async function openTaskEditModal(page, taskName) { + const taskContainer = page + .locator('[data-testid*="task-item"]') + .filter({ hasText: taskName }); + await expect(taskContainer).toBeVisible({ timeout: 15000 }); + + const editButton = taskContainer.locator('[data-testid*="task-edit"]'); + await hoverAndWaitForVisible(taskContainer, editButton); + + await editButton.click(); + + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + await waitForElement(taskNameInput, { timeout: 15000 }); + + return taskNameInput; +} + +test.describe('Discard Changes Dialog', () => { + test('shows discard dialog when closing task modal with unsaved changes', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task for Discard'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Make a change to the task name + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + await taskNameInput.fill(taskName + ' Modified'); + + // Press Escape key + await page.keyboard.press('Escape'); + + // Verify discard dialog appears + const discardDialog = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(discardDialog).toBeVisible({ timeout: 5000 }); + + // Verify the "No, keep editing" button is focused + await expect(discardDialog).toBeFocused(); + + // Verify both buttons are visible + await expect( + page.locator('[data-testid="discard-dialog-confirm"]') + ).toBeVisible(); + }); + + test('keeps editing when clicking "No, keep editing" button', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task Keep Editing'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Make a change + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + const modifiedName = taskName + ' Modified'; + await taskNameInput.fill(modifiedName); + + // Press Escape + await page.keyboard.press('Escape'); + + // Wait for discard dialog + const cancelButton = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(cancelButton).toBeVisible({ timeout: 5000 }); + + // Click "No, keep editing" + await cancelButton.click(); + + // Verify modal is still open and changes are preserved + await expect( + page.locator('[data-testid="task-modal"]') + ).toBeVisible(); + await expect(taskNameInput).toHaveValue(modifiedName); + }); + + test('discards changes when clicking "Yes, discard" button', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task Discard'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Make a change + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + await taskNameInput.fill(taskName + ' Modified'); + + // Press Escape + await page.keyboard.press('Escape'); + + // Wait for discard dialog + const confirmButton = page.locator( + '[data-testid="discard-dialog-confirm"]' + ); + await expect(confirmButton).toBeVisible({ timeout: 5000 }); + + // Click "Yes, discard" + await confirmButton.click(); + + // Verify modal is closed + await expect( + page.locator('[data-testid="task-modal"]') + ).not.toBeVisible({ timeout: 5000 }); + }); + + test('closes modal directly when pressing Escape with no changes', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task No Changes'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Don't make any changes, just press Escape + await page.keyboard.press('Escape'); + + // Verify modal closes immediately without showing discard dialog + await expect( + page.locator('[data-testid="task-modal"]') + ).not.toBeVisible({ timeout: 5000 }); + + // Verify discard dialog never appeared + await expect( + page.locator('[data-testid="discard-dialog-cancel"]') + ).not.toBeVisible(); + }); + + test('closes discard dialog when pressing Escape in the dialog', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task Escape Dialog'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Make a change + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + const modifiedName = taskName + ' Modified'; + await taskNameInput.fill(modifiedName); + + // Press Escape to show discard dialog + await page.keyboard.press('Escape'); + + // Wait for discard dialog + const cancelButton = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(cancelButton).toBeVisible({ timeout: 5000 }); + + // Press Escape again to close the discard dialog + await page.keyboard.press('Escape'); + + // Verify discard dialog is closed + await expect(cancelButton).not.toBeVisible({ timeout: 5000 }); + + // Verify task modal is still open with changes preserved + await expect( + page.locator('[data-testid="task-modal"]') + ).toBeVisible(); + await expect(taskNameInput).toHaveValue(modifiedName); + }); + + test('shows discard dialog when closing project modal with unsaved changes', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/projects'); + + // Click "Add Project" button + const addProjectButton = page.locator( + 'button[aria-label="Add Project"]' + ); + await expect(addProjectButton).toBeVisible(); + await addProjectButton.click(); + + // Wait for modal to open + await expect(page.locator('input[name="name"]')).toBeVisible({ + timeout: 10000, + }); + + // Type a project name + const projectName = createUniqueEntity('Test Project'); + const nameInput = page.locator('[data-testid="project-name-input"]'); + await nameInput.fill(projectName); + + // Press Escape + await page.keyboard.press('Escape'); + + // Verify discard dialog appears + const discardDialog = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(discardDialog).toBeVisible({ timeout: 5000 }); + + // Click "Yes, discard" + await page + .locator('[data-testid="discard-dialog-confirm"]') + .click(); + + // Verify modal is closed + await expect( + page.locator('[data-testid="project-modal"]') + ).not.toBeVisible({ timeout: 5000 }); + }); + + test('detects changes in task note field', async ({ page, baseURL }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task Note Change'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Add content to the note field + const noteTextarea = page.locator('textarea[name="note"]'); + await noteTextarea.fill('This is a test note'); + + // Press Escape + await page.keyboard.press('Escape'); + + // Verify discard dialog appears + const discardDialog = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(discardDialog).toBeVisible({ timeout: 5000 }); + }); + + test('detects changes when adding tags to task', async ({ + page, + baseURL, + }) => { + const appUrl = await login(page, baseURL); + + await navigateAndWait(page, appUrl + '/tasks'); + + // Create a task + const taskName = createUniqueEntity('Test Task Tag Change'); + await createTask(page, taskName); + + // Open the task edit modal + await openTaskEditModal(page, taskName); + + // Wait for modal to be in idle state + await expect( + page.locator('[data-testid="task-modal"][data-state="idle"]') + ).toBeVisible(); + + // Open tags section + const tagsSectionButton = page + .locator('button[title*="Tags"]') + .filter({ has: page.locator('svg') }); + await tagsSectionButton.click(); + + // Wait for tag input to become visible + const tagInput = page.locator('input[placeholder*="tag"]'); + await expect(tagInput).toBeVisible({ timeout: 5000 }); + + // Add a tag + await tagInput.fill('test-tag'); + await tagInput.press('Enter'); + + // Wait a moment for tag to be added + await page.waitForTimeout(500); + + // Press Escape + await page.keyboard.press('Escape'); + + // Verify discard dialog appears + const discardDialog = page.locator( + '[data-testid="discard-dialog-cancel"]' + ); + await expect(discardDialog).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/tests/priority-defaults.spec.ts b/e2e/tests/priority-defaults.spec.ts index d08e393..03a4129 100644 --- a/e2e/tests/priority-defaults.spec.ts +++ b/e2e/tests/priority-defaults.spec.ts @@ -151,6 +151,12 @@ test('project created without priority selection defaults to None', async ({ pag // Close modal without saving await page.keyboard.press('Escape'); + + // Handle discard changes dialog (appears because we filled in the project name) + const discardButton = page.locator('[data-testid="discard-dialog-confirm"]'); + await expect(discardButton).toBeVisible({ timeout: 5000 }); + await discardButton.click(); + await expect(page.locator('[data-testid="project-modal"]')).not.toBeVisible({ timeout: 5000 }); }); @@ -257,5 +263,11 @@ test('project priority can be set to None after being set to Medium', async ({ p // Close modal without saving await page.keyboard.press('Escape'); + + // Handle discard changes dialog (appears because we filled in the project name) + const discardButton = page.locator('[data-testid="discard-dialog-confirm"]'); + await expect(discardButton).toBeVisible({ timeout: 5000 }); + await discardButton.click(); + await expect(page.locator('[data-testid="project-modal"]')).not.toBeVisible({ timeout: 5000 }); }); diff --git a/frontend/components/Area/AreaModal.tsx b/frontend/components/Area/AreaModal.tsx index 8f60f68..f8740f5 100644 --- a/frontend/components/Area/AreaModal.tsx +++ b/frontend/components/Area/AreaModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Area } from '../../entities/Area'; import { useToast } from '../Shared/ToastContext'; import { useTranslation } from 'react-i18next'; +import DiscardChangesDialog from '../Shared/DiscardChangesDialog'; import { TrashIcon } from '@heroicons/react/24/outline'; interface AreaModalProps { @@ -32,6 +33,7 @@ const AreaModal: React.FC = ({ const nameInputRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isClosing, setIsClosing] = useState(false); + const [showDiscardDialog, setShowDiscardDialog] = useState(false); const { showSuccessToast, showErrorToast } = useToast(); @@ -73,7 +75,21 @@ const AreaModal: React.FC = ({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - handleClose(); + // Don't show discard dialog if already showing + if (showDiscardDialog) { + // Let the dialog handle its own Escape + return; + } + + event.preventDefault(); + event.stopPropagation(); + + // Check for unsaved changes using ref to get current value + if (hasUnsavedChangesRef.current()) { + setShowDiscardDialog(true); + } else { + handleClose(); + } } }; @@ -83,7 +99,7 @@ const AreaModal: React.FC = ({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [isOpen]); + }, [isOpen, showDiscardDialog]); const handleChange = ( e: React.ChangeEvent @@ -124,14 +140,47 @@ const AreaModal: React.FC = ({ } }; + // Check if there are unsaved changes + const hasUnsavedChanges = () => { + if (!area) { + // New area - check if any field has been filled + return ( + formData.name.trim() !== '' || + formData.description?.trim() !== '' + ); + } + + // Existing area - compare with original + return ( + formData.name !== area.name || + formData.description !== area.description + ); + }; + + // Use ref to store hasUnsavedChanges so it's always current in the event handler + const hasUnsavedChangesRef = useRef(hasUnsavedChanges); + useEffect(() => { + hasUnsavedChangesRef.current = hasUnsavedChanges; + }); + const handleClose = () => { setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); + setShowDiscardDialog(false); }, 300); }; + const handleDiscardChanges = () => { + setShowDiscardDialog(false); + handleClose(); + }; + + const handleCancelDiscard = () => { + setShowDiscardDialog(false); + }; + const handleDeleteArea = async () => { if (formData.uid && onDelete) { try { @@ -152,124 +201,139 @@ const AreaModal: React.FC = ({ if (!isOpen) return null; return ( -
-
-
-
- {/* Main Form Section */} -
-
-
-
+
+
+
+
+ {/* Main Form Section */} +
+
+
-
- {/* Area Title Section - Always Visible */} -
- -
- - {/* Description Section - Always Visible */} -
-