* fixup! Fix priority auto (#470) * Add translations
This commit is contained in:
parent
1e0d35a8c9
commit
b8c46e1d0d
33 changed files with 1060 additions and 160 deletions
369
e2e/tests/discard-changes.spec.ts
Normal file
369
e2e/tests/discard-changes.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
77
frontend/components/Shared/DiscardChangesDialog.tsx
Normal file
77
frontend/components/Shared/DiscardChangesDialog.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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": "لم يبدأ",
|
||||
|
|
|
|||
|
|
@ -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": "Не е започнато",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Δεν Ξεκίνησε",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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é",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "未開始",
|
||||
|
|
|
|||
|
|
@ -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": "시작하지 않음",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Не начато",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ı",
|
||||
|
|
|
|||
|
|
@ -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": "Не розпочато",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "未开始",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue