diff --git a/package-lock.json b/package-lock.json index 70c09bd3..4920ec6e 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..9f48e9a3 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", 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 }; diff --git a/packages/shared/src/utils/css.test.ts b/packages/shared/src/utils/css.test.ts deleted file mode 100644 index bf52933f..00000000 --- a/packages/shared/src/utils/css.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { cn } from './css'; - -describe('css', () => { - it('should return class names', () => { - // ARRANGE - const classes = 'font-mono font-bold text-xl'; - const result = cn(classes); - - // ASSERT - expect(result).toBe(classes); - }); - - it('should deduplicate class names', () => { - // ARRANGE - const classes = 'font-mono font-bold text-xl text-xl'; - const result = cn(classes); - - // ASSERT - const expected = 'font-mono font-bold text-xl'; - expect(result).toBe(expected); - }); -}); diff --git a/packages/shared/src/utils/css.ts b/packages/shared/src/utils/css.ts index 83631f53..c3c4d730 100644 --- a/packages/shared/src/utils/css.ts +++ b/packages/shared/src/utils/css.ts @@ -1,21 +1,2 @@ -/** - * @module css - * @description Utility functions for handling CSS class names, particularly in the context of Tailwind CSS. - * This module provides a function to conditionally construct `className` strings and merge Tailwind CSS classes without conflicts. - * It leverages the `clsx` library for conditional class name construction and the `tailwind-merge` library for merging Tailwind CSS classes. - * @see {@link https://github.com/lukeed/clsx} for `clsx` documentation. - * @see {@link https://github.com/dcastil/tailwind-merge} for `tailwind-merge` documentation. - */ - -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -/** - * Conditionally construct `className` strings. Merge Tailwind CSS classes - * without conflicts. - * @param inputs - Class values used to calculate the `className` value. - * @returns {string} - A consolidated string containing CSS class names. - */ -export const cn = (...inputs: ClassValue[]): string => { - return twMerge(clsx(inputs)); -}; +// Backwards compatibility for the `cn` utility function for shadcn. +export { cn } from 'cn'; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 4e6e29d5..91a93b0f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -25,7 +25,12 @@ export default mergeConfig( include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { include: ['src/**/*.ts', 'src/**/*.tsx'], - exclude: ['src/components/shadcn/**', ...coverageConfigDefaults.exclude], + exclude: [ + 'src/components/shadcn/**', + 'src/utils/css.ts', + 'src/hooks/use-mobile.ts', + ...coverageConfigDefaults.exclude, + ], }, }, }), 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/__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/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 746b1d30..887a9e69 100644 --- a/packages/web/src/common/components/Button/LanguageToggle.tsx +++ b/packages/web/src/common/components/Button/LanguageToggle.tsx @@ -12,13 +12,14 @@ 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 * 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. @@ -31,21 +32,32 @@ export const LanguageToggle = () => { return ( - - - - + + + + + + + {t('select-language')} + + e.preventDefault()}> Languages - setLanguage('en')} data-testid="dropdown-item-en"> + setLanguage('en')} data-testid="dropdown-item-en"> English - setLanguage('fr')} data-testid="dropdown-item-fr"> + setLanguage('fr')} data-testid="dropdown-item-fr"> French - setLanguage('es')} data-testid="dropdown-item-es"> + setLanguage('es')} data-testid="dropdown-item-es"> Spanish diff --git a/packages/web/src/common/components/Button/ThemeToggle.tsx b/packages/web/src/common/components/Button/ThemeToggle.tsx index fffdbf3b..73382579 100644 --- a/packages/web/src/common/components/Button/ThemeToggle.tsx +++ b/packages/web/src/common/components/Button/ThemeToggle.tsx @@ -1,44 +1,51 @@ import { Moon, Sun } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { cn } from '@react-starter/shared/utils/css'; +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/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..166441b1 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/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 && ( + + + + )}
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/common/utils/i18n/locales/en/common.json b/packages/web/src/common/utils/i18n/locales/en/common.json index a3392244..2b6b1141 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,26 @@ "letsGetStarted": "Let's get started", "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": { + "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/en/tasks.json b/packages/web/src/common/utils/i18n/locales/en/tasks.json index 5771237e..ce7f0a8b 100644 --- a/packages/web/src/common/utils/i18n/locales/en/tasks.json +++ b/packages/web/src/common/utils/i18n/locales/en/tasks.json @@ -10,6 +10,8 @@ "markIncomplete": "Mark incomplete", "markedComplete": "Marked task complete", "markedIncomplete": "Marked task incomplete", + "marking-complete": "Marking task complete...", + "marking-incomplete": "Marking task incomplete...", "status": { "complete": "Complete", "incomplete": "Incomplete" @@ -18,5 +20,6 @@ "task": "Task", "tasks": "Tasks", "tasks-empty": "Nothing to do. Create a task to get started.", - "updatedTask": "Task updated." + "updatedTask": "Task updated.", + "update-failed": "Failed to update task. Detail: {{message}}" } 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..acba6b6c 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,26 @@ "letsGetStarted": "Empecemos", "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": { + "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/es/tasks.json b/packages/web/src/common/utils/i18n/locales/es/tasks.json index 16c32199..0142bece 100644 --- a/packages/web/src/common/utils/i18n/locales/es/tasks.json +++ b/packages/web/src/common/utils/i18n/locales/es/tasks.json @@ -10,6 +10,8 @@ "markIncomplete": "Márcalo como incompleto", "markedComplete": "Tarea marcada completada", "markedIncomplete": "Tarea marcada como incompleta", + "marking-complete": "Marcando la tarea como completada...", + "marking-incomplete": "Marcando la tarea como incompleta...", "status": { "complete": "Completo", "incomplete": "Incompleto" @@ -18,5 +20,6 @@ "task": "Tarea", "tasks": "Tareas", "tasks-empty": "No hay nada que hacer. Haz una tarea para empezar.", - "updatedTask": "Tarea actualizada." + "updatedTask": "Tarea actualizada.", + "update-failed": "Error al actualizar la tarea. Detalle: {{message}}" } 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..b0b3db09 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,27 @@ "letsGetStarted": "Commençons", "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": { + "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. ", diff --git a/packages/web/src/common/utils/i18n/locales/fr/tasks.json b/packages/web/src/common/utils/i18n/locales/fr/tasks.json index 1a23a55f..1c0bc80f 100644 --- a/packages/web/src/common/utils/i18n/locales/fr/tasks.json +++ b/packages/web/src/common/utils/i18n/locales/fr/tasks.json @@ -10,6 +10,8 @@ "markIncomplete": "Marquez-le comme incomplet", "markedComplete": "Tâche marquée terminée", "markedIncomplete": "Tâche marquée incomplète", + "marking-complete": "Marquage de la tâche comme terminée...", + "marking-incomplete": "Marquage de la tâche comme incomplète...", "status": { "complete": "Complet", "incomplete": "Incomplet" @@ -18,5 +20,6 @@ "task": "Tâche", "tasks": "Tâches", "tasks-empty": "Rien à faire. Créez une tâche pour commencer.", - "updatedTask": "Tâche mise à jour." + "updatedTask": "Tâche mise à jour.", + "update-failed": "Échec de la mise à jour de la tâche. Détail : {{message}}" } 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.test.tsx b/packages/web/src/pages/Tasks/components/Add/AddTaskButton.test.tsx new file mode 100644 index 00000000..075e9fd5 --- /dev/null +++ b/packages/web/src/pages/Tasks/components/Add/AddTaskButton.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from '@/test/test-utils'; +import { AddTaskButton } from './AddTaskButton'; + +describe('AddTaskButton', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByTestId('add-task-button'); + + // ASSERT + expect(screen.getByTestId('add-task-button')).toBeDefined(); + }); + + it('should render Plus icon', async () => { + // ARRANGE + render(); + await screen.findByTestId('add-task-button'); + + // ACT & ASSERT + const button = screen.getByTestId('add-task-button'); + const svgIcon = button.querySelector('svg'); + expect(svgIcon).toBeDefined(); + }); + + it('should link to tasks add page', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByRole('link'); + + // ASSERT + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/app/tasks/add'); + }); + + it('should render button with aria-label', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByRole('button'); + + // ASSERT + const button = screen.getByRole('button'); + // aria-label contains the translated text "Add a new task" + expect(button).toHaveAttribute('aria-label'); + expect(button.getAttribute('aria-label')).toMatch(/Add|task/i); + }); + + it('should render tooltip content on hover', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + const button = await screen.findByRole('button'); + + // ACT + await user.hover(button); + + // ASSERT + // Tooltip content should display the translated text + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.textContent).toMatch(/Add|task/i); + }); + + it('should accept and apply custom className prop', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByTestId('custom-add-button'); + + // ASSERT + const button = screen.getByTestId('custom-add-button'); + expect(button).toHaveClass('custom-class'); + }); + + it('should pass through button props', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByTestId('add-button'); + + // ASSERT + const button = screen.getByTestId('add-button'); + expect(button).toHaveAttribute('disabled'); + }); + + it('should render with icon size button styling', async () => { + // ARRANGE + render(); + + // ACT + await screen.findByTestId('add-task-button'); + + // ASSERT + const button = screen.getByTestId('add-task-button'); + // Button should have size classes from shadcn "icon" size variant + expect(button.className).toContain('size-8'); + }); +}); 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..6de9f438 --- /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..a5bd9fd1 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,24 @@ 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) => { + // 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 }; + }), + }, +})); describe('TaskDeleteDialog', () => { it('should render successfully', async () => { @@ -15,9 +27,11 @@ describe('TaskDeleteDialog', () => { const task = todosFixture[0]; render(
- - - + Open} + />
, ); @@ -35,9 +49,11 @@ describe('TaskDeleteDialog', () => { const task = todosFixture[0]; render(
- - - + Open} + />
, ); @@ -49,7 +65,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 +76,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 +95,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 +109,11 @@ describe('TaskDeleteDialog', () => { render(
- - - + Open} + />
, ); @@ -108,10 +125,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.test.tsx b/packages/web/src/pages/Tasks/components/Edit/TaskEdit.test.tsx index e7554e90..4f615c0a 100644 --- a/packages/web/src/pages/Tasks/components/Edit/TaskEdit.test.tsx +++ b/packages/web/src/pages/Tasks/components/Edit/TaskEdit.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@/test/test-utils'; @@ -8,19 +8,17 @@ import TaskEdit from './TaskEdit'; const mockNavigate = vi.fn(); let taskId = '1'; -describe('TaskEdit', () => { - beforeEach(() => { - // mock select functions from react-router-dom - vi.mock('react-router-dom', async () => { - const original = await vi.importActual('react-router-dom'); - return { - ...original, - useNavigate: () => mockNavigate, - useParams: () => ({ taskId }), - }; - }); - }); +// mock select functions from react-router-dom +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + useParams: () => ({ taskId }), + }; +}); +describe('TaskEdit', () => { afterEach(() => { taskId = '1'; }); 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..574c1c86 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,84 @@ 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 ? t('marking-complete', { ns: 'tasks' }) : t('marking-incomplete', { ns: 'tasks' }), + success: isComplete ? t('markedComplete', { ns: 'tasks' }) : t('markedIncomplete', { ns: 'tasks' }), + error: (error) => t('update-failed', { ns: 'tasks', message: 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/TaskDetailLayout.tsx b/packages/web/src/pages/Tasks/components/TaskDetailLayout.tsx index ce9be3a4..33b097a7 100644 --- a/packages/web/src/pages/Tasks/components/TaskDetailLayout.tsx +++ b/packages/web/src/pages/Tasks/components/TaskDetailLayout.tsx @@ -51,11 +51,15 @@ const TaskDetailLayout = ({ className, testId = 'layout-task-detail' }: BaseComp )} {task && ( - - - + + + + } + onSuccess={() => navigate(-1)} + /> )}