From b73849fbe5679285a29c14f666729f34df649ffc Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 07:56:28 -0400 Subject: [PATCH 01/16] chore: bump cn to 0.2.6 and remove duplicate @fontsource-variable/inter entry --- package-lock.json | 8 ++++---- packages/shared/package.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70c09bd3..11027f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5891,10 +5891,9 @@ } }, "node_modules/cn": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/cn/-/cn-0.2.5.tgz", - "integrity": "sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==", - "dev": true, + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.2.6.tgz", + "integrity": "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==", "license": "MIT", "bin": { "cn": "bin/cn.mjs" @@ -12440,6 +12439,7 @@ "@fortawesome/react-fontawesome": "3.5.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "cn": "^0.2.6", "next-themes": "0.4.6", "radix-ui": "1.6.7", "react-syntax-highlighter": "16.1.1", diff --git a/packages/shared/package.json b/packages/shared/package.json index 970d7118..738c9545 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,13 +16,14 @@ "test:coverage": "vitest run --coverage --silent passed-only" }, "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@fortawesome/fontawesome-svg-core": "7.3.1", "@fortawesome/free-regular-svg-icons": "7.3.1", "@fortawesome/free-solid-svg-icons": "7.3.1", "@fortawesome/react-fontawesome": "3.5.0", - "@fontsource-variable/inter": "5.3.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "cn": "^0.2.6", "next-themes": "0.4.6", "radix-ui": "1.6.7", "react-syntax-highlighter": "16.1.1", From 735e529bb108b1125070b49f1ea90177d65489e3 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 07:56:50 -0400 Subject: [PATCH 02/16] feat: add Accordion component with trigger and content functionality --- .../src/components/shadcn/accordion.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/shared/src/components/shadcn/accordion.tsx diff --git a/packages/shared/src/components/shadcn/accordion.tsx b/packages/shared/src/components/shadcn/accordion.tsx new file mode 100644 index 00000000..322147c2 --- /dev/null +++ b/packages/shared/src/components/shadcn/accordion.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { cn } from 'cn'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; + +function Accordion({ className, ...props }: React.ComponentProps) { + return ; +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ className, children, ...props }: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; From 247b9722d4b29064e519cc35002b5a95a9235e15 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 07:57:21 -0400 Subject: [PATCH 03/16] feat: integrate TooltipProvider into App and test wrappers --- packages/web/src/App.tsx | 11 ++++++++--- .../src/test/wrappers/AllProvidersExceptRouter.tsx | 5 ++++- packages/web/src/test/wrappers/WithAllProviders.tsx | 5 ++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 2572701d..fade363b 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Toaster } from '@react-starter/shared/components/shadcn/sonner'; +import { TooltipProvider } from '@react-starter/shared/components/shadcn/tooltip'; import ErrorBoundary from '@/common/components/Errors/ErrorBoundary'; import ErrorFallback from '@/common/components/Errors/ErrorFallback'; @@ -29,7 +30,7 @@ const queryClient = new QueryClient({ * The application. The outermost component of the React application * hierarchy. Declares application-wide providers. */ -export const App = () => { +const App = () => { return (
}> @@ -38,8 +39,10 @@ export const App = () => { - - + + + + @@ -49,3 +52,5 @@ export const App = () => {
); }; + +export { App }; diff --git a/packages/web/src/test/wrappers/AllProvidersExceptRouter.tsx b/packages/web/src/test/wrappers/AllProvidersExceptRouter.tsx index 4ec077a9..78c533dc 100644 --- a/packages/web/src/test/wrappers/AllProvidersExceptRouter.tsx +++ b/packages/web/src/test/wrappers/AllProvidersExceptRouter.tsx @@ -1,6 +1,7 @@ import { PropsWithChildren } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; +import { TooltipProvider } from '@react-starter/shared/components/shadcn/tooltip'; import SettingsContextProvider from '@/common/providers/SettingsProvider'; import AuthContextProvider from '@/common/providers/AuthProvider'; @@ -34,7 +35,9 @@ const AllProvidersExceptRouter = ({ children }: PropsWithChildren) => { return ( - {children} + + {children} + ); diff --git a/packages/web/src/test/wrappers/WithAllProviders.tsx b/packages/web/src/test/wrappers/WithAllProviders.tsx index 6106dbb2..d6093e38 100644 --- a/packages/web/src/test/wrappers/WithAllProviders.tsx +++ b/packages/web/src/test/wrappers/WithAllProviders.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; +import { TooltipProvider } from '@react-starter/shared/components/shadcn/tooltip'; import SettingsContextProvider from '@/common/providers/SettingsProvider'; import AuthContextProvider from '@/common/providers/AuthProvider'; @@ -19,7 +20,9 @@ const WithAllProviders = ({ children }: PropsWithChildren) => { - {children} + + {children} + From 2235ce9fc2f2cbaf10186366d6a5e7aaaf8593ad Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 07:57:44 -0400 Subject: [PATCH 04/16] feat: TaskList improvements --- packages/web/src/__fixtures__/todos.ts | 2 +- packages/web/src/common/types/task.test.ts | 19 +++ packages/web/src/common/types/task.ts | 13 ++ packages/web/src/pages/Tasks/TasksPage.tsx | 31 ++--- .../src/pages/Tasks/api/useCreateTask.test.ts | 2 +- .../web/src/pages/Tasks/api/useCreateTask.ts | 2 +- .../src/pages/Tasks/api/useDeleteTask.test.ts | 2 +- .../web/src/pages/Tasks/api/useDeleteTask.ts | 2 +- .../web/src/pages/Tasks/api/useGetTask.ts | 2 +- .../src/pages/Tasks/api/useGetUserTasks.ts | 11 +- .../src/pages/Tasks/api/useUpdateTask.test.ts | 2 +- .../web/src/pages/Tasks/api/useUpdateTask.ts | 2 +- .../Tasks/components/Add/AddTaskButton.tsx | 30 +++++ .../components/Chart/TaskStatusDonutChart.tsx | 2 +- .../Delete/TaskDeleteDialog.test.tsx | 57 +++++---- .../components/Delete/TaskDeleteDialog.tsx | 61 ++++++--- .../Edit/TaskCompleteToggle.test.tsx | 2 +- .../components/Edit/TaskCompleteToggle.tsx | 2 +- .../pages/Tasks/components/Edit/TaskEdit.tsx | 2 +- .../pages/Tasks/components/Form/TaskForm.tsx | 2 +- .../Tasks/components/List/TaskList.test.tsx | 81 ++---------- .../pages/Tasks/components/List/TaskList.tsx | 121 +++++------------- .../components/List/TaskListItem.test.tsx | 8 +- .../Tasks/components/List/TaskListItem.tsx | 98 ++++++++++++-- .../components/TaskDetailLayout.test.tsx | 2 +- .../Tasks/components/TaskListLayout.test.tsx | 36 ++---- .../pages/Tasks/components/TaskListLayout.tsx | 97 +++++++------- .../pages/Tasks/components/View/TaskView.tsx | 2 +- packages/web/src/test/mocks/handlers.ts | 2 +- 29 files changed, 362 insertions(+), 333 deletions(-) create mode 100644 packages/web/src/common/types/task.test.ts create mode 100644 packages/web/src/common/types/task.ts create mode 100644 packages/web/src/pages/Tasks/components/Add/AddTaskButton.tsx diff --git a/packages/web/src/__fixtures__/todos.ts b/packages/web/src/__fixtures__/todos.ts index 157e4d65..fa09ec8a 100644 --- a/packages/web/src/__fixtures__/todos.ts +++ b/packages/web/src/__fixtures__/todos.ts @@ -1,4 +1,4 @@ -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; export const todosFixture: Task[] = [ { diff --git a/packages/web/src/common/types/task.test.ts b/packages/web/src/common/types/task.test.ts new file mode 100644 index 00000000..e4b6b4bf --- /dev/null +++ b/packages/web/src/common/types/task.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; + +import type { Task } from './task'; + +describe('Task', () => { + it('should have the correct properties', () => { + const task: Task = { + id: 1, + userId: 1, + title: 'Test Task', + completed: false, + }; + + expect(task).toHaveProperty('id'); + expect(task).toHaveProperty('userId'); + expect(task).toHaveProperty('title'); + expect(task).toHaveProperty('completed'); + }); +}); diff --git a/packages/web/src/common/types/task.ts b/packages/web/src/common/types/task.ts new file mode 100644 index 00000000..292b725e --- /dev/null +++ b/packages/web/src/common/types/task.ts @@ -0,0 +1,13 @@ +/** + * The `Task` type. + */ +export type Task = { + // The unique identifier for the task. + id: number; + // The identifier of the user who owns the task. + userId: number; + // The title of the task. + title: string; + // Indicates whether the task is completed. + completed: boolean; +}; diff --git a/packages/web/src/pages/Tasks/TasksPage.tsx b/packages/web/src/pages/Tasks/TasksPage.tsx index 05b677c2..da9ff57f 100644 --- a/packages/web/src/pages/Tasks/TasksPage.tsx +++ b/packages/web/src/pages/Tasks/TasksPage.tsx @@ -1,47 +1,36 @@ -import { Outlet, Link } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Plus } from 'lucide-react'; -import type { PropsWithTestId } from '@react-starter/shared/types/components'; import { Page } from '@react-starter/shared/components/Content/Page'; import { Container } from '@react-starter/shared/components/Content/Container'; import { Heading } from '@react-starter/shared/components/Text/Heading'; -import { Button } from '@react-starter/shared/components/shadcn/button'; -import { useGetCurrentUser } from '@/common/api/useGetCurrentUser'; import TasksPageBreadcrumbs from '@/pages/Tasks/components/TasksPageBreadcrumbs'; +import { AddTaskButton } from '@/pages/Tasks/components/Add/AddTaskButton'; /** * The `TasksPage` component renders the layout for the tasks family of pages. * It provides an `Outlet` for displaying sub-pages. - * @param {PropsWithTestId} props - Component properties. */ -const TasksPage = ({ testId = 'page-tasks' }: PropsWithTestId) => { +const TasksPage = () => { const { t } = useTranslation(); - const { data: currentUser } = useGetCurrentUser(); return ( - - + + {/* page heading */} -
+
{t('tasks', { ns: 'tasks' })}
- +
- {currentUser && ( -
- -
- )} +
+ +
); diff --git a/packages/web/src/pages/Tasks/api/useCreateTask.test.ts b/packages/web/src/pages/Tasks/api/useCreateTask.test.ts index b03e47ba..05f7ba1e 100644 --- a/packages/web/src/pages/Tasks/api/useCreateTask.test.ts +++ b/packages/web/src/pages/Tasks/api/useCreateTask.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { renderHook, waitFor } from '@/test/test-utils'; import { queryClient } from '@/test/query-client'; -import { Task } from './useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { QueryKey } from '@/common/utils/constants'; import { todosFixture } from '@/__fixtures__/todos'; diff --git a/packages/web/src/pages/Tasks/api/useCreateTask.ts b/packages/web/src/pages/Tasks/api/useCreateTask.ts index 929422a5..c5fe2782 100644 --- a/packages/web/src/pages/Tasks/api/useCreateTask.ts +++ b/packages/web/src/pages/Tasks/api/useCreateTask.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import reject from 'lodash/reject'; -import { Task } from './useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useAxios } from '@/common/hooks/useAxios'; import { QueryKey } from '@/common/utils/constants'; import { config } from '@/common/utils/config'; diff --git a/packages/web/src/pages/Tasks/api/useDeleteTask.test.ts b/packages/web/src/pages/Tasks/api/useDeleteTask.test.ts index fc8ca9fc..04f92f6b 100644 --- a/packages/web/src/pages/Tasks/api/useDeleteTask.test.ts +++ b/packages/web/src/pages/Tasks/api/useDeleteTask.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { QueryKey } from '@/common/utils/constants'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { todosFixture } from '@/__fixtures__/todos'; import { queryClient } from '@/test/query-client'; import { renderHook, waitFor } from '@/test/test-utils'; diff --git a/packages/web/src/pages/Tasks/api/useDeleteTask.ts b/packages/web/src/pages/Tasks/api/useDeleteTask.ts index fbbd3e07..c5a49243 100644 --- a/packages/web/src/pages/Tasks/api/useDeleteTask.ts +++ b/packages/web/src/pages/Tasks/api/useDeleteTask.ts @@ -3,7 +3,7 @@ import reject from 'lodash/reject'; import { QueryKey } from '@/common/utils/constants'; import { config } from '@/common/utils/config'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useAxios } from '@/common/hooks/useAxios'; /** diff --git a/packages/web/src/pages/Tasks/api/useGetTask.ts b/packages/web/src/pages/Tasks/api/useGetTask.ts index 25760316..5ff5c800 100644 --- a/packages/web/src/pages/Tasks/api/useGetTask.ts +++ b/packages/web/src/pages/Tasks/api/useGetTask.ts @@ -1,7 +1,7 @@ import { UseQueryResult, useQuery } from '@tanstack/react-query'; import { useAxios } from '@/common/hooks/useAxios'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { QueryKey } from '@/common/utils/constants'; import { config } from '@/common/utils/config'; diff --git a/packages/web/src/pages/Tasks/api/useGetUserTasks.ts b/packages/web/src/pages/Tasks/api/useGetUserTasks.ts index 496f80ce..dd70f295 100644 --- a/packages/web/src/pages/Tasks/api/useGetUserTasks.ts +++ b/packages/web/src/pages/Tasks/api/useGetUserTasks.ts @@ -3,16 +3,7 @@ import { UseQueryResult, useQuery } from '@tanstack/react-query'; import { useAxios } from '@/common/hooks/useAxios'; import { QueryKey } from '@/common/utils/constants'; import { config } from '@/common/utils/config'; - -/** - * The `Task` type. - */ -export type Task = { - id: number; - userId: number; - title: string; - completed: boolean; -}; +import type { Task } from '@/common/types/task'; /** * The request properties for `useGetUserTasks`. diff --git a/packages/web/src/pages/Tasks/api/useUpdateTask.test.ts b/packages/web/src/pages/Tasks/api/useUpdateTask.test.ts index 9c8cf744..f111da04 100644 --- a/packages/web/src/pages/Tasks/api/useUpdateTask.test.ts +++ b/packages/web/src/pages/Tasks/api/useUpdateTask.test.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from '@/test/test-utils'; import { queryClient } from '@/test/query-client'; import { todosFixture } from '@/__fixtures__/todos'; import { QueryKey } from '@/common/utils/constants'; -import { Task } from './useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useUpdateTask } from './useUpdateTask'; diff --git a/packages/web/src/pages/Tasks/api/useUpdateTask.ts b/packages/web/src/pages/Tasks/api/useUpdateTask.ts index 3f6bbec4..319068ae 100644 --- a/packages/web/src/pages/Tasks/api/useUpdateTask.ts +++ b/packages/web/src/pages/Tasks/api/useUpdateTask.ts @@ -3,7 +3,7 @@ import reject from 'lodash/reject'; import { QueryKey } from '@/common/utils/constants'; import { config } from '@/common/utils/config'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useAxios } from '@/common/hooks/useAxios'; /** diff --git a/packages/web/src/pages/Tasks/components/Add/AddTaskButton.tsx b/packages/web/src/pages/Tasks/components/Add/AddTaskButton.tsx new file mode 100644 index 00000000..2c1f7e54 --- /dev/null +++ b/packages/web/src/pages/Tasks/components/Add/AddTaskButton.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom'; +import { Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@react-starter/shared/components/shadcn/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@react-starter/shared/components/shadcn/tooltip'; + +/** + * The `AddTaskButton` component renders a button for adding a new task. It includes a tooltip and an icon. + * @param props - The properties passed to the button component. + * @returns The rendered add task button component. + */ +const AddTaskButton = ({ ...props }: React.ComponentProps) => { + const { t } = useTranslation(); + + return ( + + + + + {t('addTask', { ns: 'tasks' })} + + ); +}; + +export { AddTaskButton }; diff --git a/packages/web/src/pages/Tasks/components/Chart/TaskStatusDonutChart.tsx b/packages/web/src/pages/Tasks/components/Chart/TaskStatusDonutChart.tsx index 047d9b6d..e205cec5 100644 --- a/packages/web/src/pages/Tasks/components/Chart/TaskStatusDonutChart.tsx +++ b/packages/web/src/pages/Tasks/components/Chart/TaskStatusDonutChart.tsx @@ -7,7 +7,7 @@ import { cn } from '@react-starter/shared/utils/css'; import { BaseComponentProps } from '@react-starter/shared/types/components'; import { DonutChart, DonutChartData, DonutChartProps } from '@react-starter/shared/components/Chart/DonutChart'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; /** * Properties for the `TaskStatusDonutChart` component. diff --git a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx index fef70377..bc877a4d 100644 --- a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx +++ b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx @@ -1,12 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; +import { toast } from 'sonner'; import { render, screen } from '@/test/test-utils'; import { todosFixture } from '@/__fixtures__/todos'; -import * as sonner from '@react-starter/shared/components/shadcn/sonner'; +import { Button } from '@react-starter/shared/components/shadcn/button'; import { TaskDeleteDialog } from './TaskDeleteDialog'; -import { Button } from '@react-starter/shared/components/shadcn/button'; + +vi.mock('sonner', () => ({ + toast: { + promise: vi.fn((promise, _data) => { + return { unwrap: () => promise }; + }), + }, +})); describe('TaskDeleteDialog', () => { it('should render successfully', async () => { @@ -15,9 +23,11 @@ describe('TaskDeleteDialog', () => { const task = todosFixture[0]; render(
- - - + Open} + />
, ); @@ -35,9 +45,11 @@ describe('TaskDeleteDialog', () => { const task = todosFixture[0]; render(
- - - + Open} + />
, ); @@ -49,7 +61,7 @@ describe('TaskDeleteDialog', () => { expect(screen.getByRole('dialog')).toBeDefined(); // ACT - CLICK CANCEL BUTTON - const cancelButton = await screen.getByTestId('dialog-button-cancel'); + const cancelButton = await screen.getByTestId(`task-delete-dialog-button-cancel-${task.id}`); await user.click(cancelButton); // ASSERT - DIALOG IS CLOSED @@ -60,15 +72,14 @@ describe('TaskDeleteDialog', () => { // ARRANGE const user = userEvent.setup(); const task = todosFixture[0]; - const mockToast = vi.fn(); - const toastSpy = vi.spyOn(sonner, 'toast'); - toastSpy.mockImplementation(mockToast); render(
- - - + Open} + />
, ); @@ -80,11 +91,11 @@ describe('TaskDeleteDialog', () => { expect(screen.getByRole('dialog')).toBeDefined(); // ACT - CLICK DELETE BUTTON - const deleteButton = await screen.getByTestId('dialog-button-delete'); + const deleteButton = await screen.getByTestId(`task-delete-dialog-button-delete-${task.id}`); await user.click(deleteButton); // ASSERT - TOAST IS CALLED - expect(mockToast).toHaveBeenCalled(); + expect(toast.promise).toHaveBeenCalled(); }); it('should display error when there is a problem deleting task', async () => { @@ -94,9 +105,11 @@ describe('TaskDeleteDialog', () => { render(
- - - + Open} + />
, ); @@ -108,10 +121,10 @@ describe('TaskDeleteDialog', () => { expect(screen.getByRole('dialog')).toBeDefined(); // ACT - CLICK DELETE BUTTON - const deleteButton = await screen.getByTestId('dialog-button-delete'); + const deleteButton = screen.getByTestId(`task-delete-dialog-button-delete-${task.id}`); await user.click(deleteButton); // ASSERT - ERROR IS SHOWN - expect(screen.getByTestId('dialog-error')).toBeDefined(); + expect(screen.getByTestId(`task-delete-dialog-error-${task.id}`)).toBeDefined(); }); }); diff --git a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx index cd4967f8..24e7797a 100644 --- a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx +++ b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.tsx @@ -1,8 +1,5 @@ -import { PropsWithChildren } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { BaseComponentProps } from '@react-starter/shared/types/components'; import { Dialog, DialogClose, @@ -16,46 +13,64 @@ import { import { toast } from '@react-starter/shared/components/shadcn/sonner'; import { ErrorAlert } from '@react-starter/shared/components/Alert/ErrorAlert'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useDeleteTask } from '@/pages/Tasks/api/useDeleteTask'; import { Button } from '@react-starter/shared/components/shadcn/button'; /** * Properties for the `TaskDeleteDialog` component. */ -interface TaskDeleteDialogProps extends BaseComponentProps, PropsWithChildren { +interface TaskDeleteDialogProps extends React.ComponentProps { task: Task; + trigger?: React.ReactNode; + onSuccess?: () => void; } /** - * The `TaskDeleteDialog` renders a dialog prompting for deletion confirmation - * of a `Task`. + * The `TaskDeleteDialog` is a confirmation dialog for the deletion of a `Task`. + * + * It may be used either with a trigger element or programmatically controlled via the `open` prop. + * + * *Example trigger usage:* + * ```tsx + * Delete Task} /> + * ``` + * + * *Example programmatic usage:* + * ```tsx + * + * ``` */ -export const TaskDeleteDialog = ({ children, task, testId = 'dialog-task-delete' }: TaskDeleteDialogProps) => { +const TaskDeleteDialog = ({ onSuccess, task, trigger, ...props }: TaskDeleteDialogProps) => { const { t } = useTranslation(); - const navigate = useNavigate(); - const { mutate: deleteTask, isPending, error } = useDeleteTask(); + const { mutateAsync: deleteTaskAsync, isPending, error } = useDeleteTask(); /** * Performs task deletion. */ const doDelete = () => { - deleteTask( + const deleteTaskPromise = deleteTaskAsync( { task }, { onSuccess: () => { - toast('Task deleted.'); - navigate(-1); + onSuccess?.(); }, }, ); + toast.promise(deleteTaskPromise, { + loading: 'Deleting task...', + success: 'Task deleted.', + error: (err) => `Failed to delete task. Detail: ${err.message}`, + }); }; return ( - - - {children} - + + {trigger && ( + + {trigger} + + )} Are you sure? @@ -66,7 +81,7 @@ export const TaskDeleteDialog = ({ children, task, testId = 'dialog-task-delete' )}
@@ -75,7 +90,11 @@ export const TaskDeleteDialog = ({ children, task, testId = 'dialog-task-delete'
- @@ -83,7 +102,7 @@ export const TaskDeleteDialog = ({ children, task, testId = 'dialog-task-delete' variant="destructive" onClick={() => doDelete()} disabled={isPending} - data-testid={`${testId}-button-delete`} + data-testid={`task-delete-dialog-button-delete-${task.id}`} > Delete @@ -92,3 +111,5 @@ export const TaskDeleteDialog = ({ children, task, testId = 'dialog-task-delete'
); }; + +export { TaskDeleteDialog }; diff --git a/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.test.tsx b/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.test.tsx index 4ebe9ea7..4c3ea3dc 100644 --- a/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.test.tsx +++ b/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import * as sonner from '@react-starter/shared/components/shadcn/sonner'; import { render, screen, waitFor } from '@/test/test-utils'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { todosFixture } from '@/__fixtures__/todos'; import { TaskCompleteToggle } from './TaskCompleteToggle'; diff --git a/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.tsx b/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.tsx index 60a38278..930b7879 100644 --- a/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.tsx +++ b/packages/web/src/pages/Tasks/components/Edit/TaskCompleteToggle.tsx @@ -5,7 +5,7 @@ import { BaseComponentProps } from '@react-starter/shared/types/components'; import { Button } from '@react-starter/shared/components/shadcn/button'; import { toast } from '@react-starter/shared/components/shadcn/sonner'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useUpdateTask } from '@/pages/Tasks/api/useUpdateTask'; /** diff --git a/packages/web/src/pages/Tasks/components/Edit/TaskEdit.tsx b/packages/web/src/pages/Tasks/components/Edit/TaskEdit.tsx index 2b746704..d5962f5d 100644 --- a/packages/web/src/pages/Tasks/components/Edit/TaskEdit.tsx +++ b/packages/web/src/pages/Tasks/components/Edit/TaskEdit.tsx @@ -9,7 +9,7 @@ import { toast } from '@react-starter/shared/components/shadcn/sonner'; import { Skeleton } from '@react-starter/shared/components/shadcn/skeleton'; import { ErrorAlert } from '@react-starter/shared/components/Alert/ErrorAlert'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useGetTask } from '@/pages/Tasks/api/useGetTask'; import { useUpdateTask } from '@/pages/Tasks/api/useUpdateTask'; import TaskForm, { TaskFormValues } from '@/pages/Tasks/components/Form/TaskForm'; diff --git a/packages/web/src/pages/Tasks/components/Form/TaskForm.tsx b/packages/web/src/pages/Tasks/components/Form/TaskForm.tsx index cf9c5717..371a1a97 100644 --- a/packages/web/src/pages/Tasks/components/Form/TaskForm.tsx +++ b/packages/web/src/pages/Tasks/components/Form/TaskForm.tsx @@ -17,7 +17,7 @@ import { Input } from '@react-starter/shared/components/shadcn/input'; import { Button } from '@react-starter/shared/components/shadcn/button'; import { Toggle } from '@react-starter/shared/components/shadcn/toggle'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; /** * Task form values. diff --git a/packages/web/src/pages/Tasks/components/List/TaskList.test.tsx b/packages/web/src/pages/Tasks/components/List/TaskList.test.tsx index 28c9d06f..0a3d38b2 100644 --- a/packages/web/src/pages/Tasks/components/List/TaskList.test.tsx +++ b/packages/web/src/pages/Tasks/components/List/TaskList.test.tsx @@ -4,78 +4,20 @@ import { UseQueryResult } from '@tanstack/react-query'; import { todosFixture } from '@/__fixtures__/todos'; import * as UseGetUserTasks from '@/pages/Tasks/api/useGetUserTasks'; +import { Task } from '@/common/types/task'; -import TaskList from './TaskList'; +import { TaskList } from './TaskList'; describe('TaskList', () => { it('should render successfully', async () => { // ARRANGE - render(); + render(); await screen.findByTestId('list-task'); // ASSERT expect(screen.getByTestId('list-task')).toBeDefined(); }); - it('should show heading', async () => { - // ARRANGE - const title = 'My Title'; - render(); - await screen.findByTestId('list-task-heading'); - - // ASSERT - expect(screen.getByTestId('list-task-heading-title')).toHaveTextContent(title); - }); - - it('should show heading with badge', async () => { - // ARRANGE - const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); - useGetUserTasksSpy.mockReturnValue({ - data: todosFixture, - error: null, - isError: false, - isLoading: false, - } as unknown as UseQueryResult); - const title = 'My Title'; - render(); - await screen.findByTestId('list-task-heading-badge'); - - // ASSERT - expect(screen.getByTestId('list-task-heading-badge')).toBeDefined(); - }); - - it('should show error state', async () => { - // ARRANGE - const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); - useGetUserTasksSpy.mockReturnValue({ - data: undefined, - error: new Error('test'), - isError: true, - isLoading: false, - } as unknown as UseQueryResult); - render(); - await screen.findByTestId('list-task-error'); - - // ASSERT - expect(screen.getByTestId('list-task-error')).toBeDefined(); - }); - - it('should show loading state', async () => { - // ARRANGE - const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); - useGetUserTasksSpy.mockReturnValue({ - data: undefined, - error: null, - isError: false, - isLoading: true, - } as unknown as UseQueryResult); - render(); - await screen.findByTestId('list-task-loading'); - - // ASSERT - expect(screen.getByTestId('list-task-loading')).toBeDefined(); - }); - it('should show empty state', async () => { // ARRANGE const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); @@ -84,12 +26,12 @@ describe('TaskList', () => { error: null, isError: false, isLoading: false, - } as unknown as UseQueryResult); - render(); - await screen.findByTestId('list-task-empty'); + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-list-empty'); // ASSERT - expect(screen.getByTestId('list-task-empty')).toBeDefined(); + expect(screen.getByTestId('task-list-empty')).toBeDefined(); }); it('should show content when loaded successfully', async () => { @@ -100,11 +42,12 @@ describe('TaskList', () => { error: null, isError: false, isLoading: false, - } as unknown as UseQueryResult); - render(); - await screen.findByTestId('list-task-content'); + } as unknown as UseQueryResult); + render(); + const taskList = await screen.findByTestId('task-list'); // ASSERT - expect(screen.getByTestId('list-task-content')).toBeDefined(); + expect(taskList).toBeDefined(); + expect(taskList.children.length).toBe(todosFixture.length); }); }); diff --git a/packages/web/src/pages/Tasks/components/List/TaskList.tsx b/packages/web/src/pages/Tasks/components/List/TaskList.tsx index 11143d23..a1a0e473 100644 --- a/packages/web/src/pages/Tasks/components/List/TaskList.tsx +++ b/packages/web/src/pages/Tasks/components/List/TaskList.tsx @@ -1,107 +1,44 @@ -import { useTranslation } from 'react-i18next'; -import { filter } from 'lodash'; -import { orderBy as order } from 'lodash'; -import { times } from 'lodash'; +import { Flame } from 'lucide-react'; -import { BaseComponentProps } from '@react-starter/shared/types/components'; -import { Skeleton } from '@react-starter/shared/components/shadcn/skeleton'; -import { Badge } from '@react-starter/shared/components/shadcn/badge'; -import { ErrorAlert } from '@react-starter/shared/components/Alert/ErrorAlert'; +import { ItemGroup } from '@react-starter/shared/components/shadcn/item'; +import { Empty, EmptyMedia, EmptyTitle, EmptyDescription } from '@react-starter/shared/components/shadcn/empty'; -import { Task, useGetUserTasks } from '@/pages/Tasks/api/useGetUserTasks'; -import TaskListItem from './TaskListItem'; - -/** - * Type describes the possible sort order directions. - */ -type OrderDir = 'asc' | 'desc'; - -/** - * Type describing all possible keys of a `Task` object. - */ -type TaskKey = keyof Task; +import type { Task } from '@/common/types/task'; +import { TaskListItem } from '@/pages/Tasks/components/List/TaskListItem'; /** * Properties for the `TaskList` component. - * @param filterBy - Optional. Object containing criteria to filter the displayed Tasks. - * @param orderBy - Optional. Array of Task attributes to sort the displayed Tasks. - * @param orderDir - Optional. Array of order directions to apply to the `orderBy`. - * @param showBadge - Optional. Indicates if task count badge should be shown. - * @param title - Optional. List title text. - * @param userId - A `User` identifier whose tasks are to be shown. - * @see {@link BaseComponentProps} */ -interface TaskListProps extends BaseComponentProps { - filterBy?: Partial; - orderBy?: TaskKey[]; - orderDir?: OrderDir[]; - showBadge?: boolean; - title?: string; - userId: number; +interface TaskListProps extends React.ComponentProps<'div'> { + tasks?: Task[]; } /** - * The `TaskList` component renders a list of `Task` items. The list may be filtered - * and ordered using properties. + * The `TaskList` component renders a list of `Task` items. * @param {TaskListProps} props - Component properties. */ -const TaskList = ({ - className, - filterBy = {}, - orderBy = [], - orderDir = [], - showBadge = false, - testId = 'list-task', - title, - userId, -}: TaskListProps) => { - const { t } = useTranslation(); - const { data: tasks, isLoading, isError } = useGetUserTasks({ userId }); - - const filteredTasks = filter(tasks, filterBy); - - const orderedTasks = order(filteredTasks, orderBy, orderDir); - - const isEmpty = !isLoading && (!orderedTasks || orderedTasks?.length === 0); - +const TaskList = ({ tasks = [], ...props }: TaskListProps) => { + // Return null if there are no tasks to display. + if (tasks.length === 0) { + return ( + + + + + You're on fire! + You have no tasks at the moment. Keep up the great work! + + ); + } + + // Render the list of tasks. return ( -
- {!!title && ( -
-

- {title} -

- {showBadge && !isLoading && ( - - {orderedTasks.length} - - )} -
- )} - - {isError && ( - - )} - - {isLoading && ( -
- {times(3, (index) => ( - - ))} -
- )} - - {isEmpty &&
{t('tasks-empty', { ns: 'tasks' })}
} - - {orderedTasks && ( -
- {orderedTasks.map((task, index) => ( - - ))} -
- )} -
+ + {tasks.map((task) => ( + + ))} + ); }; -export default TaskList; +export { TaskList }; diff --git a/packages/web/src/pages/Tasks/components/List/TaskListItem.test.tsx b/packages/web/src/pages/Tasks/components/List/TaskListItem.test.tsx index 97180965..7b613622 100644 --- a/packages/web/src/pages/Tasks/components/List/TaskListItem.test.tsx +++ b/packages/web/src/pages/Tasks/components/List/TaskListItem.test.tsx @@ -3,15 +3,15 @@ import { describe, expect, it } from 'vitest'; import { todosFixture } from '@/__fixtures__/todos'; import { render, screen } from '@/test/test-utils'; -import TaskListItem from './TaskListItem'; +import { TaskListItem } from './TaskListItem'; describe('TaskListItem', () => { it('should render successfully', async () => { // ARRANGE - render(); - await screen.findByTestId('list-task-item'); + render(); + const task = await screen.findByTestId('task-list-item'); // ASSERT - expect(screen.getByTestId('list-task-item')).toBeDefined(); + expect(task).toBeDefined(); }); }); diff --git a/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx b/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx index 94d78ba2..b0df9b8d 100644 --- a/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx +++ b/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx @@ -1,17 +1,28 @@ -import { cn } from '@react-starter/shared/utils/css'; +import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { Circle, CircleCheckBig, Edit, EllipsisVertical, Eye, Trash } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; -import { BaseComponentProps } from '@react-starter/shared/types/components'; +import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from '@react-starter/shared/components/shadcn/item'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@react-starter/shared/components/shadcn/dropdown-menu'; +import { Button } from '@react-starter/shared/components/shadcn/button'; +import { toast } from '@react-starter/shared/components/shadcn/sonner'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; -import { TaskCompleteToggle } from '@/pages/Tasks/components/Edit/TaskCompleteToggle'; +import type { Task } from '@/common/types/task'; +import { useUpdateTask } from '@/pages/Tasks/api/useUpdateTask'; +import { TaskDeleteDialog } from '@/pages/Tasks/components/Delete/TaskDeleteDialog'; /** * Properties for the `TaskListItem` component. * @param task - A `Task` object. - * @see {@link BaseComponentProps} */ -interface TaskListItemProps extends BaseComponentProps { +interface TaskListItemProps extends React.ComponentProps<'div'> { task: Task; } @@ -19,13 +30,76 @@ interface TaskListItemProps extends BaseComponentProps { * The `TaskListItem` component renders a single `Task` within a `TaskList`. * @param {TaskListItemProps} props - Component properties. */ -const TaskListItem = ({ className, task, testId = 'list-task-item' }: TaskListItemProps) => { +const TaskListItem = ({ task, ...props }: TaskListItemProps) => { + const [isTaskDeleteDialogOpen, setIsTaskDeleteDialogOpen] = useState(false); + const { t } = useTranslation(); + const { mutateAsync: updateTaskAsync, isPending: isPendingUpdateTask } = useUpdateTask(); + + /** + * Toggles the completion status of the task. + */ + const handleToggleComplete = () => { + const isComplete = !task.completed; + const taskData = { ...task, completed: isComplete }; + const updateTaskPromise = updateTaskAsync({ task: taskData }); + toast.promise(updateTaskPromise, { + loading: isComplete ? 'Marking complete...' : 'Marking incomplete...', + success: isComplete ? t('markedComplete', { ns: 'tasks' }) : t('markedIncomplete', { ns: 'tasks' }), + error: (error) => t('failedToUpdateTask', { ns: 'tasks', error: error.message }), + }); + }; + return ( -
- - {task.title} -
+ + {task.completed ? : } + + {task.title} + + + + + + + + + + + View + + + + + + Edit + + + + {!task.completed && ( + + + Mark Complete + + )} + {task.completed && ( + + + + )} + + setIsTaskDeleteDialogOpen(true)}> + {/* Dialog trigger */} + + Delete + + + + + + ); }; -export default TaskListItem; +export { TaskListItem }; diff --git a/packages/web/src/pages/Tasks/components/TaskDetailLayout.test.tsx b/packages/web/src/pages/Tasks/components/TaskDetailLayout.test.tsx index bd238cec..9a1d2c7e 100644 --- a/packages/web/src/pages/Tasks/components/TaskDetailLayout.test.tsx +++ b/packages/web/src/pages/Tasks/components/TaskDetailLayout.test.tsx @@ -4,7 +4,7 @@ import { UseQueryResult } from '@tanstack/react-query'; import { render, screen } from '@/test/test-utils'; import * as UseGetTask from '@/pages/Tasks/api/useGetTask'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import { Task } from '@/common/types/task'; import TaskDetailLayout from './TaskDetailLayout'; diff --git a/packages/web/src/pages/Tasks/components/TaskListLayout.test.tsx b/packages/web/src/pages/Tasks/components/TaskListLayout.test.tsx index 3bac71ef..a38b053c 100644 --- a/packages/web/src/pages/Tasks/components/TaskListLayout.test.tsx +++ b/packages/web/src/pages/Tasks/components/TaskListLayout.test.tsx @@ -1,49 +1,39 @@ import { render, screen } from '@/test/test-utils'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { UseQueryResult } from '@tanstack/react-query'; import * as UseGetCurrentUser from '@/common/api/useGetCurrentUser'; -import * as UseGetUserTasks from '@/pages/Tasks/api/useGetUserTasks'; import type { User } from '@/common/api/useGetUser'; import { userFixture1 } from '@/__fixtures__/users'; -import { todosFixture } from '@/__fixtures__/todos'; import TaskListLayout from './TaskListLayout'; describe('TaskListLayout', () => { it('should render successfully', async () => { // ARRANGE - render(); - await screen.findByTestId('layout-task-list'); + render(); + const layout = await screen.findByTestId('layout-task-list'); // ASSERT - expect(screen.getByTestId('layout-task-list')).toBeDefined(); + expect(layout).toBeDefined(); }); it('should render content when user loaded', async () => { // ARRANGE + const user = userEvent.setup(); const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); useGetCurrentUserSpy.mockReturnValue({ data: userFixture1 } as unknown as UseQueryResult); - render(); - await screen.findByTestId('component-list-incomplete'); + render(); + const layout = await screen.findByTestId('layout-task-list'); - // ASSERT - expect(screen.getByTestId('component-list-incomplete')).toBeDefined(); - expect(screen.getByTestId('component-list-complete')).toBeDefined(); - }); - - it('should render task status chart when tasks loaded', async () => { - // ARRANGE - const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); - useGetCurrentUserSpy.mockReturnValue({ data: userFixture1 } as unknown as UseQueryResult); - const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); - useGetUserTasksSpy.mockReturnValue({ - data: todosFixture, - } as unknown as UseQueryResult); - render(); - await screen.findByTestId('component-chart-status'); + // ACT + const completedTasksAccordionTrigger = screen.getByTestId('task-list-complete-trigger'); + await user.click(completedTasksAccordionTrigger); // ASSERT - expect(screen.getByTestId('component-chart-status')).toBeDefined(); + expect(layout).toBeDefined(); + expect(screen.getByTestId('task-list-incomplete')).toBeDefined(); + expect(screen.getByTestId('task-list-complete')).toBeDefined(); }); }); diff --git a/packages/web/src/pages/Tasks/components/TaskListLayout.tsx b/packages/web/src/pages/Tasks/components/TaskListLayout.tsx index 73b98355..75449ad0 100644 --- a/packages/web/src/pages/Tasks/components/TaskListLayout.tsx +++ b/packages/web/src/pages/Tasks/components/TaskListLayout.tsx @@ -1,59 +1,68 @@ import { useTranslation } from 'react-i18next'; +import { filter } from 'lodash'; -import type { BaseComponentProps } from '@react-starter/shared/types/components'; -import { Card, CardHeader, CardTitle, CardContent } from '@react-starter/shared/components/shadcn/card'; +import { cn } from '@react-starter/shared/utils/css'; +import { Skeleton } from '@react-starter/shared/components/shadcn/skeleton'; +import { ErrorAlert } from '@react-starter/shared/components/Alert/ErrorAlert'; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from '@react-starter/shared/components/shadcn/accordion'; import { useGetCurrentUser } from '@/common/api/useGetCurrentUser'; import { useGetUserTasks } from '@/pages/Tasks/api/useGetUserTasks'; -import TaskList from '@/pages/Tasks/components/List/TaskList'; -import { TaskStatusDonutChart } from '@/pages/Tasks/components/Chart/TaskStatusDonutChart'; +import { TaskList } from '@/pages/Tasks/components/List/TaskList'; /** - * The `TaskListLayout` component renders the layout for all tasks for a - * single `User`. - * @param {BaseComponentProps} props - Component properties. + * The `TaskListLayout` component renders the layout for all tasks for a single `User`. */ -const TaskListLayout = ({ className, testId = 'layout-task-list' }: BaseComponentProps) => { +const TaskListLayout = ({ ...props }: React.ComponentProps<'div'>) => { const { t } = useTranslation(); const { data: currentUser } = useGetCurrentUser(); - const { data: tasks } = useGetUserTasks({ userId: currentUser?.id }); + const { data: tasks, isLoading, error } = useGetUserTasks({ userId: currentUser?.id }); + // Filter tasks into incomplete and complete categories. + const incompleteTasks = filter(tasks, { completed: false }); + const completeTasks = filter(tasks, { completed: true }); + + // Show loading state while fetching tasks. + if (isLoading) { + return ( +
+ + + +
+ ); + } + + // Show error state if there was an error fetching tasks. + if (error) { + return ( +
+ +
+ ); + } + + // Render the task list layout once tasks are successfully fetched. return ( -
- {!!currentUser && ( - <> -
- {!!tasks && ( - - - {t('status-of-tasks', { ns: 'tasks' })} - - - - - - )} -
- - - - - +
+ + + {completeTasks?.length > 0 && ( + + + + {t('status.complete', { ns: 'tasks' })} + + + + + + )}
); diff --git a/packages/web/src/pages/Tasks/components/View/TaskView.tsx b/packages/web/src/pages/Tasks/components/View/TaskView.tsx index 5003f085..3664254d 100644 --- a/packages/web/src/pages/Tasks/components/View/TaskView.tsx +++ b/packages/web/src/pages/Tasks/components/View/TaskView.tsx @@ -5,7 +5,7 @@ import { BaseComponentProps } from '@react-starter/shared/types/components'; import { Badge } from '@react-starter/shared/components/shadcn/badge'; import { Skeleton } from '@react-starter/shared/components/shadcn/skeleton'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; import { useGetUser } from '@/common/api/useGetUser'; /** diff --git a/packages/web/src/test/mocks/handlers.ts b/packages/web/src/test/mocks/handlers.ts index 7b54fdb1..462644c2 100644 --- a/packages/web/src/test/mocks/handlers.ts +++ b/packages/web/src/test/mocks/handlers.ts @@ -4,7 +4,7 @@ import find from 'lodash/find'; import { usersFixture } from '@/__fixtures__/users'; import { todosFixture } from '@/__fixtures__/todos'; -import { Task } from '@/pages/Tasks/api/useGetUserTasks'; +import type { Task } from '@/common/types/task'; export const handlers = [ http.get('https://jsonplaceholder.typicode.com/users', () => { From b110ef9ee0f6a0faef1c61b14e16be3b2863ae3b Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 08:04:25 -0400 Subject: [PATCH 05/16] test: improve error handling in TaskDeleteDialog tests and update mock response status --- .../pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx | 4 ++++ packages/web/src/test/mocks/handlers.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx index bc877a4d..a5bd9fd1 100644 --- a/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx +++ b/packages/web/src/pages/Tasks/components/Delete/TaskDeleteDialog.test.tsx @@ -11,6 +11,10 @@ import { TaskDeleteDialog } from './TaskDeleteDialog'; vi.mock('sonner', () => ({ toast: { promise: vi.fn((promise, _data) => { + // Ensure the promise is caught to prevent unhandled rejection in tests + promise.catch(() => { + // Silently catch errors - they're handled by the component + }); return { unwrap: () => promise }; }), }, diff --git a/packages/web/src/test/mocks/handlers.ts b/packages/web/src/test/mocks/handlers.ts index 462644c2..a30475c0 100644 --- a/packages/web/src/test/mocks/handlers.ts +++ b/packages/web/src/test/mocks/handlers.ts @@ -67,6 +67,6 @@ export const handlers = [ if (todo) { return new HttpResponse(null, { status: 200 }); } - return new HttpResponse(null, { status: 500 }); + return new HttpResponse(null, { status: 404 }); }), ]; From 80f6190bfbad4346022e25f2a59a82a781e0ea2b Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 08:16:34 -0400 Subject: [PATCH 06/16] fix: add aria-label to DropdownMenuTrigger for accessibility --- packages/web/src/pages/Tasks/components/List/TaskListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx b/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx index b0df9b8d..ca50cd24 100644 --- a/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx +++ b/packages/web/src/pages/Tasks/components/List/TaskListItem.tsx @@ -57,7 +57,7 @@ const TaskListItem = ({ task, ...props }: TaskListItemProps) => { - + From 4421730e6fa3dbc361cb4db4c663f71d76664e11 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 08:31:28 -0400 Subject: [PATCH 07/16] feat: add Tooltip to LanguageToggle --- .../components/Button/LanguageToggle.tsx | 18 ++++++++++++------ packages/web/vitest.setup.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/web/src/common/components/Button/LanguageToggle.tsx b/packages/web/src/common/components/Button/LanguageToggle.tsx index 746b1d30..8bcc08e8 100644 --- a/packages/web/src/common/components/Button/LanguageToggle.tsx +++ b/packages/web/src/common/components/Button/LanguageToggle.tsx @@ -12,6 +12,7 @@ import { DropdownMenuItem, } from '@react-starter/shared/components/shadcn/dropdown-menu'; import { Button } from '@react-starter/shared/components/shadcn/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@react-starter/shared/components/shadcn/tooltip'; /** * The `LanguageToggle` component renders a `Dropdown` which allows users @@ -31,12 +32,17 @@ export const LanguageToggle = () => { return ( - - - - + + + + + + + Select Language + + e.preventDefault()}> Languages setLanguage('en')} data-testid="dropdown-item-en"> diff --git a/packages/web/vitest.setup.ts b/packages/web/vitest.setup.ts index d4c2ab49..521a89a3 100644 --- a/packages/web/vitest.setup.ts +++ b/packages/web/vitest.setup.ts @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { afterAll, afterEach, beforeAll } from 'vitest'; +import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import { server } from './src/test/mocks/server'; import { queryClient } from './src/test/query-client'; @@ -16,3 +16,12 @@ afterEach(() => { afterAll(() => { server.close(); }); + +// Mock ResizeObserver for unit tests +if (!('ResizeObserver' in globalThis)) { + globalThis.ResizeObserver = class ResizeObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } as unknown as typeof ResizeObserver; +} From 2be12ef42172b743c5fc6ffc91d7d9bf53f6a381 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 09:25:09 -0400 Subject: [PATCH 08/16] feat: add Tooltip to LanguageToggle and ThemeToggle --- .../components/Button/LanguageToggle.test.tsx | 2 +- .../components/Button/LanguageToggle.tsx | 13 ++-- .../common/components/Button/ThemeToggle.tsx | 62 ++++++++++--------- .../common/utils/i18n/locales/en/common.json | 5 ++ .../common/utils/i18n/locales/es/common.json | 5 ++ .../common/utils/i18n/locales/fr/common.json | 6 ++ 6 files changed, 60 insertions(+), 33 deletions(-) diff --git a/packages/web/src/common/components/Button/LanguageToggle.test.tsx b/packages/web/src/common/components/Button/LanguageToggle.test.tsx index b60fd847..c61123a7 100644 --- a/packages/web/src/common/components/Button/LanguageToggle.test.tsx +++ b/packages/web/src/common/components/Button/LanguageToggle.test.tsx @@ -13,7 +13,7 @@ vi.mock('react-i18next', async () => { const original = await vi.importActual('react-i18next'); return { ...original, - useTranslation: () => ({ i18n: { changeLanguage: mockChangeLanguage } }), + useTranslation: () => ({ i18n: { changeLanguage: mockChangeLanguage }, t: vi.fn() }), }; }); diff --git a/packages/web/src/common/components/Button/LanguageToggle.tsx b/packages/web/src/common/components/Button/LanguageToggle.tsx index 8bcc08e8..e1441ff1 100644 --- a/packages/web/src/common/components/Button/LanguageToggle.tsx +++ b/packages/web/src/common/components/Button/LanguageToggle.tsx @@ -19,7 +19,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@react-starter/shared/c * to select the language in which they wish to view the application. */ export const LanguageToggle = () => { - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); /** * Set the application-wide langague code used for i18n. @@ -35,12 +35,17 @@ export const LanguageToggle = () => { - - Select Language + {t('select-language')} e.preventDefault()}> diff --git a/packages/web/src/common/components/Button/ThemeToggle.tsx b/packages/web/src/common/components/Button/ThemeToggle.tsx index fffdbf3b..057d3387 100644 --- a/packages/web/src/common/components/Button/ThemeToggle.tsx +++ b/packages/web/src/common/components/Button/ThemeToggle.tsx @@ -1,44 +1,50 @@ import { Moon, Sun } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@react-starter/shared/components/shadcn/tooltip'; +import { Button } from '@react-starter/shared/components/shadcn/button'; -import { PropsWithClassName } from '@react-starter/shared/types/components'; import { useSetSettings } from '@/common/api/useSetSettings'; import { useSettings } from '@/common/hooks/useSettings'; -import { Button } from '@react-starter/shared/components/shadcn/button'; /** * The `ThemeToggle` React component renders a `Button` which allows users * to toggle between light and dark themes. * @param {PropsWithClassName} [props] - Component properties, `PropsWithClassName`. */ -export const ThemeToggle = ({ className }: PropsWithClassName) => { +export const ThemeToggle = ({ className }: Pick, 'className'>) => { + const { t } = useTranslation(); const settings = useSettings(); const { mutate: setSettings } = useSetSettings(); return ( - <> - {settings?.theme === 'light' ? ( - - ) : ( - - )} - + + + {settings?.theme === 'light' ? ( + + ) : ( + + )} + + {settings?.theme === 'light' ? t('theme.switch-dark') : t('theme.switch-light')} + ); }; diff --git a/packages/web/src/common/utils/i18n/locales/en/common.json b/packages/web/src/common/utils/i18n/locales/en/common.json index a3392244..dd7efbf1 100644 --- a/packages/web/src/common/utils/i18n/locales/en/common.json +++ b/packages/web/src/common/utils/i18n/locales/en/common.json @@ -24,8 +24,13 @@ "letsGetStarted": "Let's get started", "privacy": "Privacy", "privacyPolicy": "Privacy policy", + "select-language": "Select language", "terms": "Terms", "termsAndConditions": "Terms and conditions", + "theme": { + "switch-light": "Switch to light mode", + "switch-dark": "Switch to dark mode" + }, "validation": { "max_one": "Must be at most {{count}} character. ", "max_other": "Must be at most {{count}} characters. ", diff --git a/packages/web/src/common/utils/i18n/locales/es/common.json b/packages/web/src/common/utils/i18n/locales/es/common.json index 3ebd2ca6..7ffd2e0b 100644 --- a/packages/web/src/common/utils/i18n/locales/es/common.json +++ b/packages/web/src/common/utils/i18n/locales/es/common.json @@ -15,8 +15,13 @@ "letsGetStarted": "Empecemos", "privacy": "Privacidad", "privacyPolicy": "Política de privacidad", + "select-language": "Seleccionar idioma", "terms": "Términos", "termsAndConditions": "Términos y condiciones", + "theme": { + "switch-light": "Cambiar a modo claro", + "switch-dark": "Cambiar a modo oscuro" + }, "validation": { "max_one": "Debe tener como máximo {{count}} caracter. ", "max_other": "Debe tener como máximo {{count}} caracteres. ", diff --git a/packages/web/src/common/utils/i18n/locales/fr/common.json b/packages/web/src/common/utils/i18n/locales/fr/common.json index 9dcd7aad..3ce3eb29 100644 --- a/packages/web/src/common/utils/i18n/locales/fr/common.json +++ b/packages/web/src/common/utils/i18n/locales/fr/common.json @@ -15,8 +15,14 @@ "letsGetStarted": "Commençons", "privacy": "Confidentialité", "privacyPolicy": "Politique de confidentialité", + "select-language": "Choisir la langue", "terms": "Termes", "termsAndConditions": "Termes et conditions", + "theme": { + "switch-light": "Passer en mode clair", + "switch-dark": "Passer en mode sombre" + }, + "validation": { "max_one": "Doit contenir au maximum {{count}} caractère. ", "max_other": "Doit contenir au maximum {{count}} caractères. ", From 95103f977bd75df866a971698bba3c5c5657287d Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 09:47:00 -0400 Subject: [PATCH 09/16] feat: implement i18n support for AppSidebar and AppSidebarTrigger components --- .../components/Header/AppSidebar.test.tsx | 2 +- .../common/components/Header/AppSidebar.tsx | 20 +++++----- .../components/Header/AppSidebarTrigger.tsx | 40 +++++++++++-------- .../common/utils/i18n/locales/en/common.json | 13 ++++++ .../common/utils/i18n/locales/es/common.json | 13 ++++++ .../common/utils/i18n/locales/fr/common.json | 13 ++++++ 6 files changed, 75 insertions(+), 26 deletions(-) diff --git a/packages/web/src/common/components/Header/AppSidebar.test.tsx b/packages/web/src/common/components/Header/AppSidebar.test.tsx index b1eaba2b..4ffc528b 100644 --- a/packages/web/src/common/components/Header/AppSidebar.test.tsx +++ b/packages/web/src/common/components/Header/AppSidebar.test.tsx @@ -48,7 +48,7 @@ describe('AppSidebar', () => { // ASSERT expect(screen.getByTestId('app-sidebar')).toBeDefined(); - expect(screen.getByText('Sign Out')).toBeDefined(); + expect(screen.getByText(/Sign out/i)).toBeDefined(); expect(screen.getByText('Settings')).toBeDefined(); expect(screen.getByText('Tasks')).toBeDefined(); }); diff --git a/packages/web/src/common/components/Header/AppSidebar.tsx b/packages/web/src/common/components/Header/AppSidebar.tsx index 1af42bcb..68a64b29 100644 --- a/packages/web/src/common/components/Header/AppSidebar.tsx +++ b/packages/web/src/common/components/Header/AppSidebar.tsx @@ -1,4 +1,5 @@ import { Info, LogIn, LogOut, Sliders, UserPlus, ListChecks, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { PropsWithTestId } from '@react-starter/shared/types/components'; import { cn } from '@react-starter/shared/utils/css'; @@ -27,6 +28,7 @@ import { Link } from 'react-router-dom'; */ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { const { isAuthenticated } = useAuth(); + const { t } = useTranslation(); return ( @@ -36,14 +38,14 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - Account + {t('sidebar.account')} - Sign In + {t('sidebar.sign-in')} @@ -51,7 +53,7 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - Create Account + {t('sidebar.account-create')} @@ -59,7 +61,7 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - Sign Out + {t('sidebar.sign-out')} @@ -67,7 +69,7 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - Settings + {t('sidebar.settings')} @@ -76,19 +78,19 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - My Stuff + {t('sidebar.my-stuff')} - Tasks + {t('sidebar.tasks')} - Add Task + {t('sidebar.add-task')} @@ -103,7 +105,7 @@ export const AppSidebar = ({ testId = 'app-sidebar' }: PropsWithTestId) => { - About + {t('sidebar.about')} diff --git a/packages/web/src/common/components/Header/AppSidebarTrigger.tsx b/packages/web/src/common/components/Header/AppSidebarTrigger.tsx index 7864f32e..28fffd84 100644 --- a/packages/web/src/common/components/Header/AppSidebarTrigger.tsx +++ b/packages/web/src/common/components/Header/AppSidebarTrigger.tsx @@ -1,8 +1,10 @@ import { Menu } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '@react-starter/shared/utils/css'; import { useSidebar } from '@react-starter/shared/components/shadcn/sidebar'; import { Button } from '@react-starter/shared/components/shadcn/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@react-starter/shared/components/shadcn/tooltip'; /** * The `AppSidebarTrigger` component renders a button that toggles the sidebar when clicked. A custom implementation @@ -11,23 +13,29 @@ import { Button } from '@react-starter/shared/components/shadcn/button'; * @returns The `AppSidebarTrigger` component, which is a button that toggles the sidebar when clicked. */ export const AppSidebarTrigger = ({ className, onClick, ...props }: React.ComponentProps) => { - const { toggleSidebar } = useSidebar(); + const { open, toggleSidebar } = useSidebar(); + const { t } = useTranslation(); return ( - + + + + + {open ? t('sidebar.toggle-close') : t('sidebar.toggle-open')} + ); }; diff --git a/packages/web/src/common/utils/i18n/locales/en/common.json b/packages/web/src/common/utils/i18n/locales/en/common.json index dd7efbf1..2b6b1141 100644 --- a/packages/web/src/common/utils/i18n/locales/en/common.json +++ b/packages/web/src/common/utils/i18n/locales/en/common.json @@ -25,6 +25,19 @@ "privacy": "Privacy", "privacyPolicy": "Privacy policy", "select-language": "Select language", + "sidebar": { + "about": "About", + "account": "Account", + "account-create": "Create account", + "my-stuff": "My Stuff", + "settings": "Settings", + "sign-in": "Sign in", + "sign-out": "Sign out", + "task-add": "Add a task", + "tasks": "Tasks", + "toggle-close": "Close menu", + "toggle-open": "Open menu" + }, "terms": "Terms", "termsAndConditions": "Terms and conditions", "theme": { diff --git a/packages/web/src/common/utils/i18n/locales/es/common.json b/packages/web/src/common/utils/i18n/locales/es/common.json index 7ffd2e0b..acba6b6c 100644 --- a/packages/web/src/common/utils/i18n/locales/es/common.json +++ b/packages/web/src/common/utils/i18n/locales/es/common.json @@ -16,6 +16,19 @@ "privacy": "Privacidad", "privacyPolicy": "Política de privacidad", "select-language": "Seleccionar idioma", + "sidebar": { + "about": "Acerca de", + "account": "Cuenta", + "account-create": "Crear cuenta", + "my-stuff": "Mis cosas", + "settings": "Configuración", + "sign-in": "Iniciar sesión", + "sign-out": "Cerrar sesión", + "task-add": "Agregar una tarea", + "tasks": "Tareas", + "toggle-close": "Cerrar menú", + "toggle-open": "Abrir menú" + }, "terms": "Términos", "termsAndConditions": "Términos y condiciones", "theme": { diff --git a/packages/web/src/common/utils/i18n/locales/fr/common.json b/packages/web/src/common/utils/i18n/locales/fr/common.json index 3ce3eb29..b0b3db09 100644 --- a/packages/web/src/common/utils/i18n/locales/fr/common.json +++ b/packages/web/src/common/utils/i18n/locales/fr/common.json @@ -16,6 +16,19 @@ "privacy": "Confidentialité", "privacyPolicy": "Politique de confidentialité", "select-language": "Choisir la langue", + "sidebar": { + "about": "À propos", + "account": "Compte", + "account-create": "Créer un compte", + "my-stuff": "Mes affaires", + "settings": "Paramètres", + "sign-in": "Se connecter", + "sign-out": "Se déconnecter", + "task-add": "Ajouter une tâche", + "tasks": "Tâches", + "toggle-close": "Fermer le menu", + "toggle-open": "Ouvrir le menu" + }, "terms": "Termes", "termsAndConditions": "Termes et conditions", "theme": { From 77370915c72986369b15f6255427bd0d9c32e77f Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 10:00:00 -0400 Subject: [PATCH 10/16] feat: add sign-in button to Header component with translation support --- .../web/src/common/components/Header/Header.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/web/src/common/components/Header/Header.tsx b/packages/web/src/common/components/Header/Header.tsx index 11f73515..41eed844 100644 --- a/packages/web/src/common/components/Header/Header.tsx +++ b/packages/web/src/common/components/Header/Header.tsx @@ -1,7 +1,10 @@ import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { LogIn } from 'lucide-react'; import { PropsWithTestId } from '@react-starter/shared/types/components'; import { ButtonGroup } from '@react-starter/shared/components/shadcn/button-group'; +import { Button } from '@react-starter/shared/components/shadcn/button'; import logo from '@/assets/img/logo.png'; import { useAuth } from '@/common/hooks/useAuth'; @@ -15,6 +18,7 @@ import { LanguageToggle } from '@/common/components/Button/LanguageToggle'; */ const Header = ({ testId = 'header' }: PropsWithTestId) => { const { isAuthenticated } = useAuth(); + const { t } = useTranslation(); return (
@@ -32,6 +36,16 @@ const Header = ({ testId = 'header' }: PropsWithTestId) => { + {!isAuthenticated && ( + + + + )}
From cc790409db0d928afba427958a927b004a80bcb0 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Sep 2026 10:14:52 -0400 Subject: [PATCH 11/16] feat: add cursor pointer style to interactive components for better UX --- .../components/Button/LanguageToggle.tsx | 7 ++++--- .../common/components/Button/ThemeToggle.tsx | 5 +++-- .../components/Header/AppSidebarTrigger.tsx | 2 +- .../Tasks/components/List/TaskListItem.tsx | 20 +++++++++++++------ .../pages/Tasks/components/TaskListLayout.tsx | 2 +- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/web/src/common/components/Button/LanguageToggle.tsx b/packages/web/src/common/components/Button/LanguageToggle.tsx index e1441ff1..887a9e69 100644 --- a/packages/web/src/common/components/Button/LanguageToggle.tsx +++ b/packages/web/src/common/components/Button/LanguageToggle.tsx @@ -38,6 +38,7 @@ export const LanguageToggle = () => { )} {task && ( - - - + + + + } + onSuccess={() => navigate(-1)} + /> )}