Set verification modal on escape (#470) (#473)

* fixup! Fix priority auto (#470)

* Add translations
This commit is contained in:
Chris 2025-11-03 16:27:13 +02:00 committed by GitHub
parent 1e0d35a8c9
commit b8c46e1d0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1060 additions and 160 deletions

View file

@ -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 });
});
});

View file

@ -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 });
});

View file

@ -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<AreaModalProps> = ({
const nameInputRef = useRef<HTMLInputElement>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [isClosing, setIsClosing] = useState(false);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
@ -73,7 +75,21 @@ const AreaModal: React.FC<AreaModalProps> = ({
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<AreaModalProps> = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
}, [isOpen, showDiscardDialog]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
@ -124,14 +140,47 @@ const AreaModal: React.FC<AreaModalProps> = ({
}
};
// 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<AreaModalProps> = ({
if (!isOpen) return null;
return (
<div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="h-full flex items-center justify-center sm:px-4 sm:py-4">
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]">
{/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 sm:rounded-lg">
<div className="flex-1 relative">
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<form
className="h-full"
onSubmit={handleSubmit}
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="h-full flex items-center justify-center sm:px-4 sm:py-4">
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]">
{/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 sm:rounded-lg">
<div className="flex-1 relative">
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{
WebkitOverflowScrolling: 'touch',
}}
>
<fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 pt-4 sm:rounded-t-lg">
<input
ref={nameInputRef}
type="text"
id="areaName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t(
'forms.areaNamePlaceholder'
)}
data-testid="area-name-input"
/>
</div>
{/* Description Section - Always Visible */}
<div className="flex-1 pb-4 sm:px-4">
<textarea
id="areaDescription"
name="description"
value={formData.description}
onChange={handleChange}
className="block w-full h-full sm:border sm:border-gray-300 sm:dark:border-gray-600 sm:rounded-md shadow-sm py-2 px-3 sm:py-3 sm:px-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 sm:focus:ring-2 sm:focus:ring-blue-500 transition duration-150 ease-in-out resize-none"
placeholder={t(
'forms.areaDescriptionPlaceholder'
)}
style={{
minHeight: '150px',
}}
/>
</div>
{/* Error Message */}
{error && (
<div className="text-red-500 px-4 mb-4">
{error}
<form
className="h-full"
onSubmit={handleSubmit}
>
<fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 pt-4 sm:rounded-t-lg">
<input
ref={nameInputRef}
type="text"
id="areaName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t(
'forms.areaNamePlaceholder'
)}
data-testid="area-name-input"
/>
</div>
)}
</fieldset>
</form>
</div>
</div>
{/* Action Buttons - Below border with custom layout */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{area && area.uid && onDelete && (
{/* Description Section - Always Visible */}
<div className="flex-1 pb-4 sm:px-4">
<textarea
id="areaDescription"
name="description"
value={
formData.description
}
onChange={handleChange}
className="block w-full h-full sm:border sm:border-gray-300 sm:dark:border-gray-600 sm:rounded-md shadow-sm py-2 px-3 sm:py-3 sm:px-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 sm:focus:ring-2 sm:focus:ring-blue-500 transition duration-150 ease-in-out resize-none"
placeholder={t(
'forms.areaDescriptionPlaceholder'
)}
style={{
minHeight: '150px',
}}
/>
</div>
{/* Error Message */}
{error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset>
</form>
</div>
</div>
{/* Action Buttons - Below border with custom layout */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{area && area.uid && onDelete && (
<button
type="button"
onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
<TrashIcon className="h-4 w-4" />
{t('common.cancel')}
</button>
)}
</div>
{/* Right side: Save */}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
onClick={() => handleSubmit()}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`}
data-testid="area-save-button"
>
{t('common.cancel')}
{isSubmitting
? t('modals.submitting')
: formData.id && formData.id !== 0
? t('modals.updateArea')
: t('modals.createArea')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
onClick={() => handleSubmit()}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`}
data-testid="area-save-button"
>
{isSubmitting
? t('modals.submitting')
: formData.id && formData.id !== 0
? t('modals.updateArea')
: t('modals.createArea')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{showDiscardDialog && (
<DiscardChangesDialog
onDiscard={handleDiscardChanges}
onCancel={handleCancelDiscard}
/>
)}
</>
);
};

View file

@ -15,6 +15,7 @@ import { useStore } from '../../store/useStore';
import { useTranslation } from 'react-i18next';
import ProjectDropdown from '../Shared/ProjectDropdown';
import ConfirmDialog from '../Shared/ConfirmDialog';
import DiscardChangesDialog from '../Shared/DiscardChangesDialog';
import {
EyeIcon,
PencilIcon,
@ -63,6 +64,7 @@ const NoteModal: React.FC<NoteModalProps> = ({
const [isClosing, setIsClosing] = useState(false);
const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
// Project-related state
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
@ -149,11 +151,40 @@ const NoteModal: React.FC<NoteModalProps> = ({
// Tags are now loaded automatically via getTags() when needed
// Check if there are unsaved changes
const hasUnsavedChanges = () => {
if (!note) {
// New note - check if any field has been filled
return (
formData.title.trim() !== '' ||
formData.content?.trim() !== '' ||
tags.length > 0 ||
formData.project_id !== null
);
}
// Existing note - compare with original
const formChanged =
formData.title !== note.title ||
formData.content !== note.content ||
formData.project_id !== note.project_id;
// Compare tags
const originalTags =
(note.tags || note.Tags)?.map((tag) => tag.name) || [];
const tagsChanged =
tags.length !== originalTags.length ||
tags.some((tag, index) => tag !== originalTags[index]);
return formChanged || tagsChanged;
};
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
setShowDiscardDialog(false);
// Reset expanded sections when closing
setExpandedSections({
tags: false,
@ -162,6 +193,15 @@ const NoteModal: React.FC<NoteModalProps> = ({
}, 300);
}, [onClose]);
const handleDiscardChanges = () => {
setShowDiscardDialog(false);
handleClose();
};
const handleCancelDiscard = () => {
setShowDiscardDialog(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@ -180,10 +220,30 @@ const NoteModal: React.FC<NoteModalProps> = ({
};
}, [isOpen, handleClose]);
// Use ref to store hasUnsavedChanges so it's always current in the event handler
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
useEffect(() => {
hasUnsavedChangesRef.current = hasUnsavedChanges;
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
// Don't show discard dialog if already showing a dialog
if (showConfirmDialog || 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();
}
}
};
@ -193,7 +253,7 @@ const NoteModal: React.FC<NoteModalProps> = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, handleClose]);
}, [isOpen, handleClose, showConfirmDialog, showDiscardDialog]);
// Handle body scroll when modal opens/closes
useEffect(() => {
@ -735,6 +795,12 @@ const NoteModal: React.FC<NoteModalProps> = ({
onCancel={() => setShowConfirmDialog(false)}
/>
)}
{showDiscardDialog && (
<DiscardChangesDialog
onDiscard={handleDiscardChanges}
onCancel={handleCancelDiscard}
/>
)}
</>
);
};

View file

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { Area } from '../../entities/Area';
import { Project } from '../../entities/Project';
import ConfirmDialog from '../Shared/ConfirmDialog';
import DiscardChangesDialog from '../Shared/DiscardChangesDialog';
import { useToast } from '../Shared/ToastContext';
import TagInput from '../Tag/TagInput';
import PriorityDropdown from '../Shared/PriorityDropdown';
@ -73,6 +74,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const nameInputRef = useRef<HTMLInputElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [error, setError] = useState<string | null>(null);
// Collapsible sections state
@ -185,7 +187,21 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
// Don't show discard dialog if already showing a dialog
if (showConfirmDialog || 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();
}
}
};
if (isOpen) {
@ -194,7 +210,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
}, [isOpen, showConfirmDialog, showDiscardDialog]);
const handleChange = (
e: React.ChangeEvent<
@ -383,14 +399,66 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}
};
// Check if there are unsaved changes
const hasUnsavedChanges = () => {
if (!project) {
// New project - check if any field has been filled
return (
formData.name.trim() !== '' ||
formData.description?.trim() !== '' ||
formData.area_id !== null ||
formData.state !== 'idea' ||
tags.length > 0 ||
formData.priority !== null ||
formData.due_date_at !== null ||
imageFile !== null ||
imagePreview !== ''
);
}
// Existing project - compare with original
const formChanged =
formData.name !== project.name ||
formData.description !== project.description ||
formData.area_id !== project.area_id ||
formData.state !== project.state ||
formData.priority !== project.priority ||
formData.due_date_at !== project.due_date_at ||
imageFile !== null;
// Compare tags
const originalTags = project.tags?.map((tag) => tag.name) || [];
const tagsChanged =
tags.length !== originalTags.length ||
tags.some((tag, index) => tag !== originalTags[index]);
return formChanged || tagsChanged;
};
// 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 toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
@ -929,6 +997,12 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onCancel={() => setShowConfirmDialog(false)}
/>
)}
{showDiscardDialog && (
<DiscardChangesDialog
onDiscard={handleDiscardChanges}
onCancel={handleCancelDiscard}
/>
)}
</>,
document.body
);

View file

@ -0,0 +1,77 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface DiscardChangesDialogProps {
onDiscard: () => void;
onCancel: () => void;
}
const DiscardChangesDialog: React.FC<DiscardChangesDialogProps> = ({
onDiscard,
onCancel,
}) => {
const { t } = useTranslation();
const cancelButtonRef = useRef<HTMLButtonElement>(null);
// Focus the "No" (Cancel) button when the dialog opens
useEffect(() => {
if (cancelButtonRef.current) {
cancelButtonRef.current.focus();
}
}, []);
// Handle Escape key to close the dialog (keeping changes)
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCancel();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onCancel]);
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-[60]"
onClick={onCancel}
>
<div
className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-lg mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
{t('common.discardChanges', 'Discard changes?')}
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-8">
{t(
'common.discardChangesMessage',
'You have unsaved changes. Are you sure you want to discard them?'
)}
</p>
<div className="flex justify-end space-x-4">
<button
ref={cancelButtonRef}
onClick={onCancel}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
data-testid="discard-dialog-cancel"
>
{t('common.no', 'No, keep editing')}
</button>
<button
onClick={onDiscard}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
data-testid="discard-dialog-confirm"
>
{t('common.yesDiscard', 'Yes, discard')}
</button>
</div>
</div>
</div>
);
};
export default DiscardChangesDialog;

View file

@ -3,6 +3,7 @@ import { Tag } from '../../entities/Tag';
import { TrashIcon } from '@heroicons/react/24/outline';
import { useToast } from '../Shared/ToastContext';
import { useTranslation } from 'react-i18next';
import DiscardChangesDialog from '../Shared/DiscardChangesDialog';
interface TagModalProps {
isOpen: boolean;
@ -29,6 +30,7 @@ const TagModal: React.FC<TagModalProps> = ({
const nameInputRef = useRef<HTMLInputElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
@ -70,7 +72,21 @@ const TagModal: React.FC<TagModalProps> = ({
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();
}
}
};
if (isOpen) {
@ -79,7 +95,7 @@ const TagModal: React.FC<TagModalProps> = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
}, [isOpen, showDiscardDialog]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
@ -126,14 +142,41 @@ const TagModal: React.FC<TagModalProps> = ({
}
};
// Check if there are unsaved changes
const hasUnsavedChanges = () => {
if (!tag) {
// New tag - check if name has been filled
return formData.name.trim() !== '';
}
// Existing tag - compare with original
return formData.name !== tag.name;
};
// 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 handleDeleteTag = async () => {
if (formData.uid && onDelete) {
try {
@ -242,6 +285,12 @@ const TagModal: React.FC<TagModalProps> = ({
</div>
</div>
</div>
{showDiscardDialog && (
<DiscardChangesDialog
onDiscard={handleDiscardChanges}
onCancel={handleCancelDiscard}
/>
)}
</>
);
};

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PriorityType, Task } from '../../entities/Task';
import ConfirmDialog from '../Shared/ConfirmDialog';
import DiscardChangesDialog from '../Shared/DiscardChangesDialog';
import { useToast } from '../Shared/ToastContext';
import { Project } from '../../entities/Project';
import { useStore } from '../../store/useStore';
@ -76,6 +77,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
const [isClosing, setIsClosing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [parentTask, setParentTask] = useState<Task | null>(null);
const [parentTaskLoading, setParentTaskLoading] = useState(false);
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
@ -433,14 +435,51 @@ const TaskModal: React.FC<TaskModalProps> = ({
}
};
// Check if there are unsaved changes
const hasUnsavedChanges = () => {
// Compare formData with original task
const formChanged =
formData.name !== task.name ||
formData.note !== task.note ||
formData.priority !== task.priority ||
formData.due_date !== task.due_date ||
formData.project_id !== task.project_id ||
formData.recurrence_type !== task.recurrence_type ||
formData.recurrence_interval !== task.recurrence_interval ||
formData.recurrence_weekday !== task.recurrence_weekday ||
formData.recurrence_end_date !== task.recurrence_end_date;
// Compare tags
const originalTags = task.tags?.map((tag) => tag.name) || [];
const tagsChanged =
tags.length !== originalTags.length ||
tags.some((tag, index) => tag !== originalTags[index]);
// Compare subtasks (check if any were added or modified)
const subtasksChanged =
subtasks.length !== (initialSubtasks?.length || 0);
return formChanged || tagsChanged || subtasksChanged;
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
setShowDiscardDialog(false);
}, 300);
};
const handleDiscardChanges = () => {
setShowDiscardDialog(false);
handleClose();
};
const handleCancelDiscard = () => {
setShowDiscardDialog(false);
};
// Handle body scroll when modal opens/closes
useEffect(() => {
if (isOpen) {
@ -457,10 +496,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
};
}, [isOpen]);
// Use ref to store hasUnsavedChanges so it's always current in the event handler
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
useEffect(() => {
hasUnsavedChangesRef.current = hasUnsavedChanges;
});
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
// Don't show discard dialog if already showing a dialog
if (showConfirmDialog || 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();
}
}
};
if (isOpen) {
@ -469,7 +528,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
}, [isOpen, showConfirmDialog, showDiscardDialog]);
// Load existing subtasks when modal opens - use initialSubtasks if provided, no fetching
useEffect(() => {
@ -956,6 +1015,12 @@ const TaskModal: React.FC<TaskModalProps> = ({
onCancel={() => setShowConfirmDialog(false)}
/>
)}
{showDiscardDialog && (
<DiscardChangesDialog
onDiscard={handleDiscardChanges}
onCancel={handleCancelDiscard}
/>
)}
</>,
document.body
);

View file

@ -18,7 +18,11 @@
"status": "الحالة",
"saving": "جارٍ الحفظ...",
"settings": "الإعدادات",
"none": "لا شيء"
"none": "لا شيء",
"discardChanges": "هل تريد إلغاء التغييرات؟",
"discardChangesMessage": "لديك تغييرات غير محفوظة. هل أنت متأكد أنك تريد إلغاءها؟",
"no": "لا، استمر في التحرير",
"yesDiscard": "نعم، ألغِ"
},
"sidebar": {
"dashboard": "لوحة التحكم",
@ -471,7 +475,8 @@
"priority": {
"low": "منخفض",
"medium": "متوسط",
"high": "مرتفع"
"high": "مرتفع",
"none": "لا شيء"
},
"status": {
"notStarted": "لم يبدأ",

View file

@ -18,7 +18,11 @@
"status": "Статус",
"saving": "Записване...",
"settings": "Настройки",
"none": "Няма"
"none": "Няма",
"discardChanges": "Отказване на промените?",
"discardChangesMessage": "Имате непазени промени. Сигурни ли сте, че искате да ги откажете?",
"no": "Не, продължавайте да редактирате",
"yesDiscard": "Да, откажете"
},
"sidebar": {
"dashboard": "Табло",
@ -471,7 +475,8 @@
"priority": {
"low": "Нисък",
"medium": "Среден",
"high": "Висок"
"high": "Висок",
"none": "Няма"
},
"status": {
"notStarted": "Не е започнато",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Gemmer...",
"settings": "Indstillinger",
"none": "Ingen"
"none": "Ingen",
"discardChanges": "Forkast ændringer?",
"discardChangesMessage": "Du har ikke gemte ændringer. Er du sikker på, at du vil forkaste dem?",
"no": "Nej, fortsæt med at redigere",
"yesDiscard": "Ja, forkast"
},
"sidebar": {
"dashboard": "Dashboard",
@ -471,7 +475,8 @@
"priority": {
"low": "Lav",
"medium": "Medium",
"high": "Høj"
"high": "Høj",
"none": "Ingen"
},
"status": {
"notStarted": "Ikke Startet",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Speichern...",
"settings": "Einstellungen",
"none": "Keine"
"none": "Keine",
"discardChanges": "Änderungen verwerfen?",
"discardChangesMessage": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie diese verwerfen möchten?",
"no": "Nein, weiter bearbeiten",
"yesDiscard": "Ja, verwerfen"
},
"sidebar": {
"dashboard": "Dashboard",
@ -696,7 +700,8 @@
"priority": {
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch"
"high": "Hoch",
"none": "Keine"
},
"status": {
"notStarted": "Nicht begonnen",

View file

@ -18,7 +18,11 @@
"status": "Κατάσταση",
"saving": "Αποθήκευση...",
"none": "Κανένα",
"settings": "Ρυθμίσεις"
"settings": "Ρυθμίσεις",
"discardChanges": "Απόρριψη αλλαγών;",
"discardChangesMessage": "Έχετε μη αποθηκευμένες αλλαγές. Είστε σίγουροι ότι θέλετε να τις απορρίψετε;",
"no": "Όχι, συνέχισε την επεξεργασία",
"yesDiscard": "Ναι, απόρριψη"
},
"sidebar": {
"dashboard": "Πίνακας Ελέγχου",
@ -549,7 +553,8 @@
"priority": {
"low": "Χαμηλή",
"medium": "Μεσαία",
"high": "Υψηλή"
"high": "Υψηλή",
"none": "Καμία"
},
"status": {
"notStarted": "Δεν Ξεκίνησε",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Saving...",
"settings": "Settings",
"none": "None"
"none": "None",
"discardChanges": "Discard changes?",
"discardChangesMessage": "You have unsaved changes. Are you sure you want to discard them?",
"no": "No, keep editing",
"yesDiscard": "Yes, discard"
},
"sidebar": {
"dashboard": "Dashboard",

View file

@ -18,7 +18,11 @@
"status": "Estado",
"saving": "Guardando...",
"none": "Ninguno",
"settings": "Configuraciones"
"settings": "Configuraciones",
"discardChanges": "¿Descartar cambios?",
"discardChangesMessage": "Tienes cambios no guardados. ¿Estás seguro de que quieres descartarlos?",
"no": "No, seguir editando",
"yesDiscard": "Sí, descartar"
},
"sidebar": {
"dashboard": "Tablero",
@ -543,7 +547,8 @@
"priority": {
"low": "Baja",
"medium": "Media",
"high": "Alta"
"high": "Alta",
"none": "Ninguno"
},
"status": {
"notStarted": "No Iniciado",

View file

@ -18,7 +18,11 @@
"status": "Tila",
"saving": "Tallennetaan...",
"settings": "Asetukset",
"none": "Ei mitään"
"none": "Ei mitään",
"discardChanges": "Hylkää muutokset?",
"discardChangesMessage": "Sinulla on tallentamattomia muutoksia. Oletko varma, että haluat hylätä ne?",
"no": "Ei, jatka muokkaamista",
"yesDiscard": "Kyllä, hylkää"
},
"sidebar": {
"dashboard": "Ohjauspaneeli",
@ -471,7 +475,8 @@
"priority": {
"low": "Matala",
"medium": "Keskitaso",
"high": "Korkea"
"high": "Korkea",
"none": "Ei mitään"
},
"status": {
"notStarted": "Ei aloitettu",

View file

@ -18,7 +18,11 @@
"status": "Statut",
"saving": "Enregistrement...",
"settings": "Paramètres",
"none": "Aucun"
"none": "Aucun",
"discardChanges": "Abandonner les modifications ?",
"discardChangesMessage": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir les abandonner ?",
"no": "Non, continuer à éditer",
"yesDiscard": "Oui, abandonner"
},
"sidebar": {
"dashboard": "Tableau de bord",
@ -471,7 +475,8 @@
"priority": {
"low": "Faible",
"medium": "Moyenne",
"high": "Élevée"
"high": "Élevée",
"none": "Aucun"
},
"status": {
"notStarted": "Non commencé",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Menyimpan...",
"settings": "Pengaturan",
"none": "Tidak ada"
"none": "Tidak ada",
"discardChanges": "Buang perubahan?",
"discardChangesMessage": "Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin membuangnya?",
"no": "Tidak, lanjutkan mengedit",
"yesDiscard": "Ya, buang"
},
"sidebar": {
"dashboard": "Dasbor",
@ -471,7 +475,8 @@
"priority": {
"low": "Rendah",
"medium": "Sedang",
"high": "Tinggi"
"high": "Tinggi",
"none": "Tidak ada"
},
"status": {
"notStarted": "Belum Dimulai",

View file

@ -18,7 +18,11 @@
"status": "Stato",
"saving": "Salvataggio...",
"settings": "Impostazioni",
"none": "Nessuno"
"none": "Nessuno",
"discardChanges": "Scartare le modifiche?",
"discardChangesMessage": "Hai modifiche non salvate. Sei sicuro di volerle scartare?",
"no": "No, continua a modificare",
"yesDiscard": "Sì, scarta"
},
"sidebar": {
"dashboard": "Dashboard",
@ -471,7 +475,8 @@
"priority": {
"low": "Bassa",
"medium": "Media",
"high": "Alta"
"high": "Alta",
"none": "Nessuno"
},
"status": {
"notStarted": "Non Iniziata",

View file

@ -18,7 +18,11 @@
"status": "ステータス",
"saving": "保存中...",
"settings": "設定",
"none": "なし"
"none": "なし",
"discardChanges": "変更を破棄しますか?",
"discardChangesMessage": "保存されていない変更があります。破棄してもよろしいですか?",
"no": "いいえ、編集を続ける",
"yesDiscard": "はい、破棄する"
},
"sidebar": {
"dashboard": "ダッシュボード",
@ -776,7 +780,8 @@
"priority": {
"low": "低",
"medium": "中",
"high": "高"
"high": "高",
"none": "なし"
},
"status": {
"notStarted": "未開始",

View file

@ -18,7 +18,11 @@
"status": "상태",
"saving": "저장 중...",
"settings": "설정",
"none": "없음"
"none": "없음",
"discardChanges": "변경 사항을 버리시겠습니까?",
"discardChangesMessage": "저장되지 않은 변경 사항이 있습니다. 정말로 이를 버리시겠습니까?",
"no": "아니요, 편집 계속하기",
"yesDiscard": "네, 버리기"
},
"sidebar": {
"dashboard": "대시보드",
@ -471,7 +475,8 @@
"priority": {
"low": "낮음",
"medium": "보통",
"high": "높음"
"high": "높음",
"none": "없음"
},
"status": {
"notStarted": "시작하지 않음",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Opslaan...",
"settings": "Instellingen",
"none": "Geen"
"none": "Geen",
"discardChanges": "Wijzigingen negeren?",
"discardChangesMessage": "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je ze wilt negeren?",
"no": "Nee, blijf bewerken",
"yesDiscard": "Ja, negeren"
},
"sidebar": {
"dashboard": "Dashboard",
@ -471,7 +475,8 @@
"priority": {
"low": "Laag",
"medium": "Gemiddeld",
"high": "Hoog"
"high": "Hoog",
"none": "Geen"
},
"status": {
"notStarted": "Niet Begonnen",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Lagrer...",
"settings": "Innstillinger",
"none": "Ingen"
"none": "Ingen",
"discardChanges": "Forkaste endringer?",
"discardChangesMessage": "Du har usikrede endringer. Er du sikker på at du vil forkaste dem?",
"no": "Nei, fortsett å redigere",
"yesDiscard": "Ja, forkast"
},
"sidebar": {
"dashboard": "Dashbord",
@ -471,7 +475,8 @@
"priority": {
"low": "Lav",
"medium": "Moderat",
"high": "Høy"
"high": "Høy",
"none": "Ingen"
},
"status": {
"notStarted": "Ikke Startet",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Zapisywanie...",
"settings": "Ustawienia",
"none": "Brak"
"none": "Brak",
"discardChanges": "Porzucić zmiany?",
"discardChangesMessage": "Masz niezapisane zmiany. Czy na pewno chcesz je porzucić?",
"no": "Nie, kontynuuj edytowanie",
"yesDiscard": "Tak, porzuć"
},
"sidebar": {
"dashboard": "Panel sterowania",
@ -471,7 +475,8 @@
"priority": {
"low": "Niski",
"medium": "Średni",
"high": "Wysoki"
"high": "Wysoki",
"none": "Brak"
},
"status": {
"notStarted": "Nie rozpoczęto",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Salvando...",
"settings": "Configurações",
"none": "Nenhum"
"none": "Nenhum",
"discardChanges": "Descartar alterações?",
"discardChangesMessage": "Você tem alterações não salvas. Tem certeza de que deseja descartá-las?",
"no": "Não, continuar editando",
"yesDiscard": "Sim, descartar"
},
"sidebar": {
"dashboard": "Painel",
@ -471,7 +475,8 @@
"priority": {
"low": "Baixa",
"medium": "Média",
"high": "Alta"
"high": "Alta",
"none": "Nenhum"
},
"status": {
"notStarted": "Não Iniciado",

View file

@ -18,7 +18,11 @@
"status": "Stare",
"saving": "Se salvează...",
"settings": "Setări",
"none": "Niciunul"
"none": "Niciunul",
"discardChanges": "Renunți la modificări?",
"discardChangesMessage": "Ai modificări nesalvate. Ești sigur că vrei să le renunți?",
"no": "Nu, continuă editarea",
"yesDiscard": "Da, renunță"
},
"sidebar": {
"dashboard": "Tabloul de bord",
@ -471,7 +475,8 @@
"priority": {
"low": "Scăzut",
"medium": "Medie",
"high": "Ridicat"
"high": "Ridicat",
"none": "Niciuna"
},
"status": {
"notStarted": "Nefăcut",

View file

@ -18,7 +18,11 @@
"status": "Статус",
"saving": "Сохранение...",
"settings": "Настройки",
"none": "Нет"
"none": "Нет",
"discardChanges": "Отменить изменения?",
"discardChangesMessage": "У вас есть несохраненные изменения. Вы уверены, что хотите их отменить?",
"no": "Нет, продолжить редактирование",
"yesDiscard": "Да, отменить"
},
"sidebar": {
"dashboard": "Панель управления",
@ -471,7 +475,8 @@
"priority": {
"low": "Низкий",
"medium": "Средний",
"high": "Высокий"
"high": "Высокий",
"none": "Нет"
},
"status": {
"notStarted": "Не начато",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Shranjevanje...",
"settings": "Nastavitve",
"none": "Nič"
"none": "Nič",
"discardChanges": "Zavrzite spremembe?",
"discardChangesMessage": "Imate neshranjene spremembe. Ste prepričani, da jih želite zavreči?",
"no": "Ne, nadaljuj z urejanjem",
"yesDiscard": "Da, zavrzi"
},
"sidebar": {
"dashboard": "Nadzorna plošča",
@ -471,7 +475,8 @@
"priority": {
"low": "Nizka",
"medium": "Srednja",
"high": "Visoka"
"high": "Visoka",
"none": "Noben"
},
"status": {
"notStarted": "Ni začeto",

View file

@ -18,7 +18,11 @@
"status": "Status",
"saving": "Sparar...",
"settings": "Inställningar",
"none": "Ingen"
"none": "Ingen",
"discardChanges": "Kasta bort ändringar?",
"discardChangesMessage": "Du har osparade ändringar. Är du säker på att du vill kasta bort dem?",
"no": "Nej, fortsätt redigera",
"yesDiscard": "Ja, kasta bort"
},
"sidebar": {
"dashboard": "Dashboard",
@ -471,7 +475,8 @@
"priority": {
"low": "Låg",
"medium": "Medel",
"high": "Hög"
"high": "Hög",
"none": "Ingen"
},
"status": {
"notStarted": "Ej påbörjad",

View file

@ -18,7 +18,11 @@
"status": "Durum",
"saving": "Kaydediliyor...",
"settings": "Ayarlar",
"none": "Yok"
"none": "Yok",
"discardChanges": "Değişiklikleri iptal et?",
"discardChangesMessage": "Kaydedilmemiş değişiklikleriniz var. Onları iptal etmek istediğinizden emin misiniz?",
"no": "Hayır, düzenlemeye devam et",
"yesDiscard": "Evet, iptal et"
},
"sidebar": {
"dashboard": "Gösterge Paneli",
@ -471,7 +475,8 @@
"priority": {
"low": "Düşük",
"medium": "Orta",
"high": "Yüksek"
"high": "Yüksek",
"none": "Yok"
},
"status": {
"notStarted": "Başlamadı",

View file

@ -18,7 +18,11 @@
"status": "Статус",
"saving": "Збереження...",
"settings": "Налаштування",
"none": "Немає"
"none": "Немає",
"discardChanges": "Відкинути зміни?",
"discardChangesMessage": "У вас є незбережені зміни. Ви впевнені, що хочете їх відкинути?",
"no": "Ні, продовжити редагування",
"yesDiscard": "Так, відкинути"
},
"sidebar": {
"dashboard": "Дашборд",
@ -729,7 +733,8 @@
"priority": {
"low": "Низький",
"medium": "Середній",
"high": "Високий"
"high": "Високий",
"none": "Немає"
},
"status": {
"notStarted": "Не розпочато",

View file

@ -18,7 +18,11 @@
"status": "Trạng thái",
"saving": "Đang lưu...",
"settings": "Cài đặt",
"none": "Không có"
"none": "Không có",
"discardChanges": "Bỏ qua thay đổi?",
"discardChangesMessage": "Bạn có thay đổi chưa lưu. Bạn có chắc chắn muốn bỏ qua chúng không?",
"no": "Không, tiếp tục chỉnh sửa",
"yesDiscard": "Có, bỏ qua"
},
"sidebar": {
"dashboard": "Bảng điều khiển",
@ -471,7 +475,8 @@
"priority": {
"low": "Thấp",
"medium": "Trung bình",
"high": "Cao"
"high": "Cao",
"none": "Không có"
},
"status": {
"notStarted": "Chưa bắt đầu",

View file

@ -18,7 +18,11 @@
"status": "状态",
"saving": "保存中...",
"settings": "设置",
"none": "无"
"none": "无",
"discardChanges": "放弃更改?",
"discardChangesMessage": "您有未保存的更改。您确定要放弃它们吗?",
"no": "不,继续编辑",
"yesDiscard": "是的,放弃"
},
"sidebar": {
"dashboard": "仪表板",
@ -471,7 +475,8 @@
"priority": {
"low": "低",
"medium": "中",
"high": "高"
"high": "高",
"none": "无"
},
"status": {
"notStarted": "未开始",