diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 1f91227..4c8f789 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -59,6 +59,7 @@ export const TaskDialog = ({ onMarkComplete, onMarkDeleted, isOverdue, + isUnsynced, }: EditTaskDialogProps) => { const handleDialogOpenChange = (open: boolean) => { if (open) { @@ -128,6 +129,11 @@ export const TaskDialog = ({ {task.project === '' ? '' : task.project} )} + {isUnsynced && ( + + Unsynced + + )} { onSaveDescription( task, @@ -217,6 +224,7 @@ export const TaskDialog = ({ {editState.dependsDropdownOpen && ( -
+
{ onSaveDepends(task, editState.editedDepends); onUpdateState({ @@ -729,6 +755,7 @@ export const TaskDialog = ({ @@ -1003,6 +1036,7 @@ export const TaskDialog = ({ @@ -1291,6 +1334,7 @@ export const TaskDialog = ({ id={`mark-task-as-deleted-${task.id}`} className="mr-4" variant={'destructive'} + aria-label="delete task" > diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 73b78c5..ff0a546 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -90,6 +90,9 @@ export const Tasks = ( const [searchTerm, setSearchTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); + const [unsyncedTaskUuids, setUnsyncedTaskUuids] = useState>( + new Set() + ); const tableRef = useRef(null); const [hotkeysEnabled, setHotkeysEnabled] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -291,6 +294,8 @@ export const Tasks = ( localStorage.setItem(hashedKey, currentTime.toString()); setLastSyncTime(currentTime); + setUnsyncedTaskUuids(new Set()); + toast.success(`Tasks synced successfully!`); } catch (error) { console.error('Error syncing tasks:', error); @@ -399,6 +404,8 @@ export const Tasks = ( }; const handleMarkComplete = async (taskuuid: string) => { + setUnsyncedTaskUuids((prev) => new Set([...prev, taskuuid])); + await markTaskAsCompleted( props.email, props.encryptionSecret, @@ -408,6 +415,8 @@ export const Tasks = ( }; const handleMarkDelete = async (taskuuid: string) => { + setUnsyncedTaskUuids((prev) => new Set([...prev, taskuuid])); + await markTaskAsDeleted( props.email, props.encryptionSecret, @@ -424,6 +433,9 @@ export const Tasks = ( const handleSaveDescription = (task: Task, description: string) => { task.description = description; + + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -444,6 +456,9 @@ export const Tasks = ( const handleProjectSaveClick = (task: Task, project: string) => { task.project = project; + + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -465,6 +480,8 @@ export const Tasks = ( const handleWaitDateSaveClick = (task: Task, waitDate: string) => { task.wait = waitDate; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -486,6 +503,8 @@ export const Tasks = ( const handleStartDateSaveClick = (task: Task, startDate: string) => { task.start = startDate; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -507,6 +526,8 @@ export const Tasks = ( const handleEntryDateSaveClick = (task: Task, entryDate: string) => { task.entry = entryDate; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -528,6 +549,8 @@ export const Tasks = ( const handleEndDateSaveClick = (task: Task, endDate: string) => { task.end = endDate; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -549,6 +572,8 @@ export const Tasks = ( const handleDueDateSaveClick = (task: Task, dueDate: string) => { task.due = dueDate; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -570,6 +595,8 @@ export const Tasks = ( const handleDependsSaveClick = (task: Task, depends: string[]) => { task.depends = depends; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -601,6 +628,8 @@ export const Tasks = ( task.recur = recur; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -700,6 +729,9 @@ export const Tasks = ( const tagsToRemove = removedTags.map((tag) => `${tag}`); const finalTags = [...updatedTags, ...tagsToRemove]; console.log(finalTags); + + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -722,6 +754,8 @@ export const Tasks = ( try { const priorityValue = priority === 'NONE' ? '' : priority; + setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); + await modifyTaskOnBackend({ email: props.email, encryptionSecret: props.encryptionSecret, @@ -857,7 +891,7 @@ export const Tasks = ( {/* Mobile-only Sync button (desktop already shows a Sync button with filters) */}
@@ -948,13 +989,20 @@ export const Tasks = (
@@ -1031,6 +1079,7 @@ export const Tasks = ( onMarkComplete={handleMarkComplete} onMarkDeleted={handleMarkDelete} isOverdue={isOverdue} + isUnsynced={unsyncedTaskUuids.has(task.uuid)} /> )) )} diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index 08a4e29..05a0037 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -96,6 +96,7 @@ describe('TaskDialog Component', () => { onMarkComplete: jest.fn(), onMarkDeleted: jest.fn(), isOverdue: jest.fn(() => false), + isUnsynced: false, }; beforeEach(() => { @@ -123,6 +124,31 @@ describe('TaskDialog Component', () => { expect(statusBadge).toBeInTheDocument(); }); + test('should display Unsynced badge when isUnsynced is true', () => { + const unsyncedProps = { + ...defaultProps, + isUnsynced: true, + }; + + render(); + + const unsyncedBadge = screen.getByText('Unsynced'); + expect(unsyncedBadge).toBeInTheDocument(); + expect(unsyncedBadge).toHaveClass('animate-pulse'); + }); + + test('should not display Unsynced badge when isUnsynced is false', () => { + const unsyncedProps = { + ...defaultProps, + isUnsynced: false, + }; + + render(); + + const unsyncedBadge = screen.queryByText('Unsynced'); + expect(unsyncedBadge).not.toBeInTheDocument(); + }); + test('should display correct priority indicator', () => { const { container } = render(); @@ -580,7 +606,7 @@ describe('TaskDialog Component', () => { render(); const deleteButton = screen.getByRole('button', { - name: /^d$/i, + name: /delete task/i, }); fireEvent.click(deleteButton); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index a5f510d..f8c7274 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -118,13 +118,20 @@ jest.mock('../hooks', () => ({ uuid: 'uuid-corp-3', }, ]), + delete: jest.fn().mockResolvedValue(undefined), })), })), + bulkPut: jest.fn().mockResolvedValue(undefined), }, + transaction: jest.fn(async (_mode, _table, callback) => { + await callback(); + return Promise.resolve(); + }), })), fetchTaskwarriorTasks: jest.fn().mockResolvedValue([]), addTaskToBackend: jest.fn().mockResolvedValue({}), editTaskOnBackend: jest.fn().mockResolvedValue({}), + modifyTaskOnBackend: jest.fn().mockResolvedValue({}), })); jest.mock('../Pagination', () => { @@ -346,6 +353,7 @@ describe('Tasks Component', () => { const overdueBadge = await screen.findByText('Overdue'); expect(overdueBadge).toBeInTheDocument(); }); + test('filters tasks with fuzzy search (handles typos)', async () => { jest.useFakeTimers(); @@ -493,4 +501,379 @@ describe('Tasks Component', () => { expect(newProjectInput).toHaveValue('My Fresh Project'); }); + + test('shows "Unsynced" badge when task is marked as completed', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + const completeButton = screen.getByLabelText('complete task'); + fireEvent.click(completeButton); + }); + + const yesButton = screen.getAllByText('Yes')[0]; + fireEvent.click(yesButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task is deleted', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + const deleteButton = screen.getByLabelText('delete task'); + fireEvent.click(deleteButton); + }); + + await waitFor(() => { + const yesButtons = screen.getAllByText('Yes'); + if (yesButtons.length > 0) fireEvent.click(yesButtons[0]); + }); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task description is edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Description:')).toBeInTheDocument(); + }); + + const descriptionLabel = screen.getByText('Description:'); + const descRow = descriptionLabel.closest('tr') as HTMLElement; + const editButton = within(descRow).getByLabelText('edit'); + + fireEvent.click(editButton); + + const input = await screen.findByDisplayValue('Task 12'); + + fireEvent.change(input, { target: { value: 'Updated Task 12' } }); + + const saveButton = screen.getByLabelText('save'); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task project is edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Project:')).toBeInTheDocument(); + }); + + const projectLabel = screen.getByText('Project:'); + const projectRow = projectLabel.closest('tr') as HTMLElement; + const editButton = within(projectRow).getByLabelText('edit'); + + fireEvent.click(editButton); + + const input = await screen.findByDisplayValue('ProjectB'); + + fireEvent.change(input, { target: { value: 'UpdatedProject' } }); + + const saveButton = screen.getByLabelText('save'); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test.each([ + ['Wait', 'Wait:', 'Pick a date'], + ['End', 'End:', 'Select end date'], + ['Due', 'Due:', 'Select due date'], + ['Start', 'Start:', 'Pick a date'], + ['Entry', 'Entry:', 'Pick a date'], + ])( + 'shows "Unsynced" badge when task %s date is edited', + async (_, label, placeholder) => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + const dateLabel = screen.getByText(label); + const dateRow = dateLabel.closest('tr') as HTMLElement; + + const editButton = within(dateRow).getByLabelText('edit'); + fireEvent.click(editButton); + + const dateButton = within(dateRow) + .getByText(placeholder) + .closest('button'); + fireEvent.click(dateButton!); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + const dialog = screen.getByRole('dialog'); + const day15 = within(dialog).getByText('15'); + fireEvent.click(day15); + + const saveButton = screen.getByLabelText('save'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + } + ); + + test('shows "Unsynced" badge when task priority is edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Priority:')).toBeInTheDocument(); + }); + + const priorityLabel = screen.getByText('Priority:'); + const priorityRow = priorityLabel.closest('tr') as HTMLElement; + + const editButton = within(priorityRow).getByLabelText('edit'); + fireEvent.click(editButton); + + const select = within(priorityRow).getByTestId('project-select'); + fireEvent.change(select, { target: { value: 'H' } }); + + const saveButton = screen.getByLabelText('save'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task dependencies are edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Depends:')).toBeInTheDocument(); + }); + + const dependsLabel = screen.getByText('Depends:'); + const dependsRow = dependsLabel.closest('tr') as HTMLElement; + + const editButton = within(dependsRow).getByLabelText('edit'); + fireEvent.click(editButton); + + const addDependecyButton = within(dependsRow) + .getByText('Add Dependency') + .closest('button'); + fireEvent.click(addDependecyButton!); + + const dropdown = within(dependsRow).getByTestId('dependency-dropdown'); + + fireEvent.click(within(dropdown).getByText('Task 11')); + fireEvent.click(within(dropdown).getByText('Task 10')); + + const saveButton = screen.getByLabelText('save'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task tags are edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Tags:')).toBeInTheDocument(); + }); + + const tagsLabel = screen.getByText('Tags:'); + const tagsRow = tagsLabel.closest('tr') as HTMLElement; + + const editButton = within(tagsRow).getByLabelText('edit'); + fireEvent.click(editButton); + + const editInput = await screen.findByPlaceholderText( + 'Add a tag (press enter to add)' + ); + + fireEvent.change(editInput, { target: { value: 'unsyncedtag' } }); + fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); + + const saveButton = screen.getByLabelText('Save tags'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows "Unsynced" badge when task recur is edited', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + expect(screen.getByText('Recur:')).toBeInTheDocument(); + }); + + const recurLabel = screen.getByText('Recur:'); + const recurRow = recurLabel.closest('tr') as HTMLElement; + + const editButton = within(recurRow).getByLabelText('edit'); + fireEvent.click(editButton); + + const select = within(recurRow).getByTestId('project-select'); + fireEvent.change(select, { target: { value: 'weekly' } }); + + const saveButton = screen.getByLabelText('save'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + }); + + test('shows and updates notification badge count on Sync button', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + const completeButton = screen.getByLabelText('complete task'); + fireEvent.click(completeButton); + }); + + const yesButton = screen.getAllByText('Yes')[0]; + fireEvent.click(yesButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + + const syncButtons = screen.getAllByText('Sync'); + const syncBtnContainer = syncButtons[0].closest('button'); + + if (syncBtnContainer) { + expect(within(syncBtnContainer).getByText('1')).toBeInTheDocument(); + } else { + throw new Error('Sync button not found'); + } + }); + + test('clears "Unsynced" badges after sync', async () => { + render(); + + await waitFor(async () => { + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + }); + + const task12 = screen.getByText('Task 12'); + fireEvent.click(task12); + + await waitFor(() => { + const completeButton = screen.getByLabelText('complete task'); + fireEvent.click(completeButton); + }); + + const yesButton = screen.getAllByText('Yes')[0]; + fireEvent.click(yesButton); + + await waitFor(() => { + expect(screen.getByText('Unsynced')).toBeInTheDocument(); + }); + + const hooks = require('../hooks'); + hooks.fetchTaskwarriorTasks.mockResolvedValueOnce([ + { + id: 12, + description: 'Task 12', + status: 'completed', + project: 'ProjectA', + tags: ['tag1'], + uuid: 'uuid-12', + }, + ]); + + const syncButtons = screen.getAllByText('Sync'); + fireEvent.click(syncButtons[0]); + + await waitFor(() => { + expect(screen.queryByText('Unsynced')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index 5f18777..3dda77a 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -144,4 +144,5 @@ export interface EditTaskDialogProps { onMarkComplete: (uuid: string) => void; onMarkDeleted: (uuid: string) => void; isOverdue: (due?: string) => boolean; + isUnsynced: boolean; }