From cf3dce8887f6c716fab7bc3bd07d37b0c318f3d0 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 11 Dec 2024 06:42:22 -0500 Subject: [PATCH 01/15] initial app --- .github/workflows/ci.yml | 120 + .github/workflows/deploy-dev.yml | 87 + .github/workflows/deploy-prod.yml | 88 + .github/workflows/deploy-qa.yml | 87 + .gitignore | 32 + .nvmrc | 1 + .prettierrc | 8 + README.md | 275 +- eslint.config.mjs | 46 + index.html | 23 + package-lock.json | 7499 +++++++++++++++++ package.json | 79 + postcss.config.js | 6 + public/favicon.ico | Bin 0 -> 4286 bytes src/App.tsx | 58 + src/__fixtures__/settings.ts | 3 + src/__fixtures__/toasts.ts | 17 + src/__fixtures__/todos.ts | 40 + src/__fixtures__/tokens.ts | 10 + src/__fixtures__/users.ts | 51 + src/__tests__/App.test.tsx | 15 + src/assets/react.svg | 1 + .../api/__tests__/useGetCurrentUser.test.ts | 50 + .../api/__tests__/useGetSettings.test.ts | 49 + src/common/api/__tests__/useGetUser.test.ts | 17 + .../api/__tests__/useGetUserTokens.test.ts | 59 + .../api/__tests__/useSetSettings.test.ts | 48 + src/common/api/useGetCurrentUser.ts | 31 + src/common/api/useGetSettings.ts | 40 + src/common/api/useGetUser.ts | 73 + src/common/api/useGetUserTokens.ts | 62 + src/common/api/useSetSettings.ts | 51 + src/common/components/Badge/Badge.tsx | 21 + .../components/Badge/__tests__/Badge.test.tsx | 34 + src/common/components/Button/Button.tsx | 20 + .../components/Button/LanguageToggle.tsx | 68 + src/common/components/Button/ThemeToggle.tsx | 52 + .../Button/__tests__/Button.test.tsx | 15 + .../Button/__tests__/LanguageToggle.test.tsx | 79 + .../Button/__tests__/ThemeToggle.test.tsx | 89 + src/common/components/Card/Card.tsx | 26 + src/common/components/Card/MessageCard.tsx | 47 + .../components/Card/__tests__/Card.test.tsx | 46 + .../Card/__tests__/MessageCard.test.tsx | 54 + src/common/components/Dialog/Backdrop.tsx | 46 + src/common/components/Dialog/Dialog.tsx | 69 + src/common/components/Dialog/DialogButton.tsx | 45 + .../components/Dialog/DialogButtons.tsx | 34 + .../components/Dialog/DialogContent.tsx | 30 + .../components/Dialog/DialogHeading.tsx | 30 + src/common/components/Dialog/Divider.tsx | 25 + .../Dialog/__tests__/Backdrop.test.tsx | 50 + .../Dialog/__tests__/DiaglogButtons.test.tsx | 47 + .../Dialog/__tests__/Dialog.test.tsx | 98 + .../Dialog/__tests__/DialogButton.test.tsx | 53 + .../Dialog/__tests__/DialogContent.test.tsx | 34 + .../Dialog/__tests__/DialogHeading.test.tsx | 34 + .../Dialog/__tests__/Divider.test.tsx | 34 + src/common/components/Dropdown/Dropdown.tsx | 88 + .../components/Dropdown/DropdownContent.tsx | 40 + .../components/Dropdown/DropdownItem.tsx | 45 + .../Dropdown/__tests__/Dropdown.test.tsx | 86 + .../__tests__/DropdownContent.test.tsx | 34 + .../Dropdown/__tests__/DropdownItem.test.tsx | 48 + .../components/Errors/ErrorBoundary.tsx | 45 + .../components/Errors/ErrorFallback.tsx | 25 + .../Errors/__tests__/ErrorBoundary.test.tsx | 49 + .../Errors/__tests__/ErrorFallback.test.tsx | 15 + src/common/components/Footer/Footer.tsx | 55 + .../Footer/__tests__/Footer.test.tsx | 24 + src/common/components/Form/SearchField.tsx | 121 + src/common/components/Form/SearchResult.tsx | 44 + src/common/components/Form/SelectField.tsx | 185 + src/common/components/Form/TextField.tsx | 106 + .../Form/__tests__/SearchField.test.tsx | 99 + .../Form/__tests__/SearchResult.test.tsx | 61 + .../Form/__tests__/SelectField.test.tsx | 240 + .../Form/__tests__/TextField.test.tsx | 219 + src/common/components/Header/AppMenu.tsx | 75 + src/common/components/Header/Header.tsx | 45 + .../Header/__tests__/AppMenu.test.tsx | 82 + .../Header/__tests__/Header.test.tsx | 86 + src/common/components/Header/logo.png | Bin 0 -> 2503 bytes src/common/components/Icon/Avatar.tsx | 97 + src/common/components/Icon/FAIcon.tsx | 145 + .../components/Icon/__tests__/Avatar.test.tsx | 52 + .../components/Icon/__tests__/FAIcon.test.tsx | 17 + .../components/Layout/StandardLayout.tsx | 37 + .../Layout/__tests__/StandardLayout.test.tsx | 15 + src/common/components/Link/Link.tsx | 35 + .../components/Link/__tests__/Link.test.tsx | 32 + .../components/Loader/LoaderSkeleton.tsx | 32 + .../components/Loader/LoaderSpinner.tsx | 43 + .../Loader/__tests__/LoaderSkeleton.test.tsx | 33 + .../Loader/__tests__/LoaderSpinner.test.tsx | 82 + src/common/components/Menu/Menu.tsx | 7 + src/common/components/Menu/MenuButton.tsx | 52 + .../components/Menu/MenuCloseButton.tsx | 42 + src/common/components/Menu/MenuNavLink.tsx | 81 + src/common/components/Menu/MenuSeparator.tsx | 29 + .../components/Menu/SideMenu/SideMenu.tsx | 89 + .../Menu/SideMenu/SideMenuHeader.tsx | 45 + .../Menu/SideMenu/__tests__/SideMenu.test.tsx | 122 + .../__tests__/SideMenuHeader.test.tsx | 92 + .../Menu/__tests__/MenuButton.test.tsx | 75 + .../Menu/__tests__/MenuCloseButton.test.tsx | 47 + .../Menu/__tests__/MenuNavLink.test.tsx | 110 + .../Menu/__tests__/MenuSeparator.test.tsx | 33 + src/common/components/Page/Page.tsx | 26 + .../components/Page/__tests__/Page.test.tsx | 45 + .../components/Router/PrivateOutlet.tsx | 24 + src/common/components/Router/Router.tsx | 130 + .../Router/__tests__/PrivateOutlet.test.tsx | 67 + src/common/components/Table/Table.tsx | 73 + .../components/Table/__tests__/Table.test.tsx | 63 + src/common/components/Tabs/Tab.tsx | 64 + src/common/components/Tabs/TabContent.tsx | 31 + src/common/components/Tabs/Tabs.tsx | 96 + .../components/Tabs/__tests__/Tab.test.tsx | 66 + .../Tabs/__tests__/TabContent.test.tsx | 47 + .../components/Tabs/__tests__/Tabs.test.tsx | 67 + src/common/components/Text/CodeSnippet.tsx | 81 + src/common/components/Text/Text.tsx | 48 + .../Text/__tests__/CodeSnippet.test.tsx | 60 + .../components/Text/__tests__/Text.test.tsx | 59 + src/common/components/Theme/Theme.tsx | 38 + .../components/Theme/__tests__/Theme.test.tsx | 24 + src/common/components/Toast/Toast.tsx | 89 + src/common/components/Toast/Toasts.tsx | 36 + .../components/Toast/__tests__/Toast.test.tsx | 66 + .../Toast/__tests__/Toasts.test.tsx | 60 + src/common/hooks/__tests__/useAuth.test.ts | 31 + src/common/hooks/__tests__/useAxios.test.ts | 30 + src/common/hooks/__tests__/useConfig.test.ts | 23 + .../hooks/__tests__/useDebounce.test.ts | 58 + .../hooks/__tests__/useSettings.test.ts | 35 + src/common/hooks/__tests__/useToasts.test.ts | 56 + src/common/hooks/useAuth.ts | 11 + src/common/hooks/useAxios.ts | 12 + src/common/hooks/useConfig.ts | 15 + src/common/hooks/useDebounce.ts | 27 + src/common/hooks/useSettings.ts | 18 + src/common/hooks/useToasts.ts | 16 + src/common/providers/AuthContext.ts | 25 + src/common/providers/AuthProvider.tsx | 39 + src/common/providers/AxiosContext.ts | 17 + src/common/providers/AxiosProvider.tsx | 92 + src/common/providers/ConfigContext.ts | 22 + src/common/providers/ConfigProvider.tsx | 56 + src/common/providers/SettingsContext.ts | 8 + src/common/providers/SettingsProvider.tsx | 24 + src/common/providers/ToastsContext.ts | 30 + src/common/providers/ToastsProvider.tsx | 118 + .../providers/__tests__/AuthProvider.test.tsx | 65 + .../__tests__/AxiosProvider.test.tsx | 78 + .../__tests__/ConfigProvider.test.tsx | 46 + .../__tests__/SettingsProvider.test.tsx | 33 + .../__tests__/ToastsProvider.test.tsx | 21 + src/common/utils/__tests__/numbers.test.ts | 80 + src/common/utils/__tests__/storage.test.ts | 50 + src/common/utils/constants.ts | 35 + src/common/utils/i18n/__tests__/i18n.test.ts | 10 + src/common/utils/i18n/index.ts | 41 + src/common/utils/i18n/locales/en/common.json | 9 + src/common/utils/i18n/locales/en/index.ts | 4 + src/common/utils/i18n/locales/en/users.json | 16 + src/common/utils/i18n/locales/es/common.json | 9 + src/common/utils/i18n/locales/es/index.ts | 4 + src/common/utils/i18n/locales/es/users.json | 16 + src/common/utils/i18n/locales/fr/common.json | 9 + src/common/utils/i18n/locales/fr/index.ts | 4 + src/common/utils/i18n/locales/fr/users.json | 16 + src/common/utils/numbers.ts | 37 + src/common/utils/storage.ts | 46 + src/index.css | 31 + src/main.tsx | 12 + src/pages/ComponentsPage/ComponentsPage.tsx | 48 + .../__tests__/ComponentsPage.test.tsx | 14 + .../components/AvatarComponents.tsx | 123 + .../components/BadgeComponents.tsx | 124 + .../components/ButtonComponents.tsx | 146 + .../components/CardComponents.tsx | 188 + .../components/TextComponents.tsx | 121 + .../__tests__/AvatarComponents.test.tsx | 32 + .../__tests__/BadgeComponents.test.tsx | 32 + .../__tests__/ButtonComponents.test.tsx | 46 + .../__tests__/CardComponents.test.tsx | 32 + .../__tests__/TextComponents.test.tsx | 33 + .../components/avatar-picture.png | Bin 0 -> 6269 bytes src/pages/ComponentsPage/model/components.ts | 10 + src/pages/DashboardPage/DashboardPage.tsx | 39 + .../__tests__/DashboardPage.test.tsx | 50 + src/pages/ErrorPage/ErrorPage.tsx | 45 + .../ErrorPage/__tests__/ErrorPage.test.tsx | 18 + src/pages/LandingPage/LandingPage.tsx | 36 + .../__tests__/LandingPage.test.tsx | 52 + src/pages/SettingsPage/SettingsPage.tsx | 47 + .../__tests__/SettingsPage.test.tsx | 51 + .../components/AppearanceSettings.tsx | 84 + .../components/SettingsHeading.tsx | 28 + .../__tests__/AppearanceSettings.test.tsx | 70 + .../__tests__/SettingsHeading.test.tsx | 25 + src/pages/SigninPage/SigninPage.tsx | 22 + .../SigninPage/__tests__/SigninPage.test.tsx | 16 + .../api/__tests__/useSignin.test.ts | 42 + src/pages/SigninPage/api/useSignin.ts | 68 + .../SigninPage/components/SigninForm.tsx | 123 + .../components/__tests__/SigninForm.test.tsx | 74 + src/pages/SignoutPage/SignoutPage.tsx | 39 + .../__tests__/SignoutPage.test.tsx | 34 + .../api/__tests__/useSignout.test.ts | 45 + src/pages/SignoutPage/api/useSignout.ts | 40 + .../Tasks/api/__tests__/useDeleteTask.test.ts | 59 + .../Tasks/api/__tests__/useGetTask.test.ts | 19 + .../Tasks/api/__tests__/useUpdateTask.test.ts | 84 + .../UsersPage/Tasks/api/useDeleteTask.ts | 50 + src/pages/UsersPage/Tasks/api/useGetTask.ts | 38 + .../UsersPage/Tasks/api/useUpdateTask.ts | 52 + .../Tasks/components/TaskCompleteToggle.tsx | 86 + .../Tasks/components/TaskDeleteDialog.tsx | 60 + .../UsersPage/Tasks/components/TaskDetail.tsx | 192 + .../__tests__/TaskCompleteToggle.test.tsx | 115 + .../__tests__/TaskDeleteDialog.test.tsx | 78 + .../components/__tests__/TaskDetail.test.tsx | 233 + src/pages/UsersPage/UsersPage.tsx | 46 + .../UsersPage/__tests__/UsersPage.test.tsx | 14 + .../api/__tests__/useGetUserTasks.test.ts | 29 + .../api/__tests__/useGetUsers.test.ts | 18 + src/pages/UsersPage/api/useGetUserTasks.ts | 49 + src/pages/UsersPage/api/useGetUsers.ts | 27 + src/pages/UsersPage/components/UserDetail.tsx | 73 + .../UsersPage/components/UserDetailEmpty.tsx | 34 + .../UsersPage/components/UserDetailLayout.tsx | 82 + src/pages/UsersPage/components/UserList.tsx | 58 + .../UsersPage/components/UserListItem.tsx | 60 + .../UsersPage/components/UserTaskList.tsx | 93 + .../UsersPage/components/UserTaskListItem.tsx | 40 + src/pages/UsersPage/components/UserTasks.tsx | 75 + .../UsersPage/components/UserTasksCard.tsx | 77 + .../components/__tests__/UserDetail.test.tsx | 86 + .../__tests__/UserDetailEmpty.test.tsx | 33 + .../__tests__/UserDetailLayout.test.tsx | 86 + .../components/__tests__/UserList.test.tsx | 87 + .../__tests__/UserListItem.test.tsx | 74 + .../__tests__/UserTaskList.test.tsx | 89 + .../__tests__/UserTaskListItem.test.tsx | 51 + .../components/__tests__/UserTasks.test.tsx | 76 + .../__tests__/UserTasksCard.test.tsx | 125 + src/test/mocks/handlers.ts | 57 + src/test/mocks/server.ts | 5 + src/test/query-client.ts | 9 + src/test/test-utils.tsx | 40 + .../wrappers/AllProvidersExceptRouter.tsx | 50 + src/test/wrappers/WithAllProviders.tsx | 36 + src/test/wrappers/WithQueryClientProvider.tsx | 25 + src/vite-env.d.ts | 1 + tailwind.config.js | 32 + template.yml | 226 + tsconfig.json | 35 + tsconfig.node.json | 11 + vite.config.ts | 35 + vitest.setup.ts | 18 + 262 files changed, 21486 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-dev.yml create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/deploy-qa.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 eslint.config.mjs create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 src/App.tsx create mode 100644 src/__fixtures__/settings.ts create mode 100644 src/__fixtures__/toasts.ts create mode 100644 src/__fixtures__/todos.ts create mode 100644 src/__fixtures__/tokens.ts create mode 100644 src/__fixtures__/users.ts create mode 100644 src/__tests__/App.test.tsx create mode 100644 src/assets/react.svg create mode 100644 src/common/api/__tests__/useGetCurrentUser.test.ts create mode 100644 src/common/api/__tests__/useGetSettings.test.ts create mode 100644 src/common/api/__tests__/useGetUser.test.ts create mode 100644 src/common/api/__tests__/useGetUserTokens.test.ts create mode 100644 src/common/api/__tests__/useSetSettings.test.ts create mode 100644 src/common/api/useGetCurrentUser.ts create mode 100644 src/common/api/useGetSettings.ts create mode 100644 src/common/api/useGetUser.ts create mode 100644 src/common/api/useGetUserTokens.ts create mode 100644 src/common/api/useSetSettings.ts create mode 100644 src/common/components/Badge/Badge.tsx create mode 100644 src/common/components/Badge/__tests__/Badge.test.tsx create mode 100644 src/common/components/Button/Button.tsx create mode 100644 src/common/components/Button/LanguageToggle.tsx create mode 100644 src/common/components/Button/ThemeToggle.tsx create mode 100644 src/common/components/Button/__tests__/Button.test.tsx create mode 100644 src/common/components/Button/__tests__/LanguageToggle.test.tsx create mode 100644 src/common/components/Button/__tests__/ThemeToggle.test.tsx create mode 100644 src/common/components/Card/Card.tsx create mode 100644 src/common/components/Card/MessageCard.tsx create mode 100644 src/common/components/Card/__tests__/Card.test.tsx create mode 100644 src/common/components/Card/__tests__/MessageCard.test.tsx create mode 100644 src/common/components/Dialog/Backdrop.tsx create mode 100644 src/common/components/Dialog/Dialog.tsx create mode 100644 src/common/components/Dialog/DialogButton.tsx create mode 100644 src/common/components/Dialog/DialogButtons.tsx create mode 100644 src/common/components/Dialog/DialogContent.tsx create mode 100644 src/common/components/Dialog/DialogHeading.tsx create mode 100644 src/common/components/Dialog/Divider.tsx create mode 100644 src/common/components/Dialog/__tests__/Backdrop.test.tsx create mode 100644 src/common/components/Dialog/__tests__/DiaglogButtons.test.tsx create mode 100644 src/common/components/Dialog/__tests__/Dialog.test.tsx create mode 100644 src/common/components/Dialog/__tests__/DialogButton.test.tsx create mode 100644 src/common/components/Dialog/__tests__/DialogContent.test.tsx create mode 100644 src/common/components/Dialog/__tests__/DialogHeading.test.tsx create mode 100644 src/common/components/Dialog/__tests__/Divider.test.tsx create mode 100644 src/common/components/Dropdown/Dropdown.tsx create mode 100644 src/common/components/Dropdown/DropdownContent.tsx create mode 100644 src/common/components/Dropdown/DropdownItem.tsx create mode 100644 src/common/components/Dropdown/__tests__/Dropdown.test.tsx create mode 100644 src/common/components/Dropdown/__tests__/DropdownContent.test.tsx create mode 100644 src/common/components/Dropdown/__tests__/DropdownItem.test.tsx create mode 100644 src/common/components/Errors/ErrorBoundary.tsx create mode 100644 src/common/components/Errors/ErrorFallback.tsx create mode 100644 src/common/components/Errors/__tests__/ErrorBoundary.test.tsx create mode 100644 src/common/components/Errors/__tests__/ErrorFallback.test.tsx create mode 100644 src/common/components/Footer/Footer.tsx create mode 100644 src/common/components/Footer/__tests__/Footer.test.tsx create mode 100644 src/common/components/Form/SearchField.tsx create mode 100644 src/common/components/Form/SearchResult.tsx create mode 100644 src/common/components/Form/SelectField.tsx create mode 100644 src/common/components/Form/TextField.tsx create mode 100644 src/common/components/Form/__tests__/SearchField.test.tsx create mode 100644 src/common/components/Form/__tests__/SearchResult.test.tsx create mode 100644 src/common/components/Form/__tests__/SelectField.test.tsx create mode 100644 src/common/components/Form/__tests__/TextField.test.tsx create mode 100644 src/common/components/Header/AppMenu.tsx create mode 100644 src/common/components/Header/Header.tsx create mode 100644 src/common/components/Header/__tests__/AppMenu.test.tsx create mode 100644 src/common/components/Header/__tests__/Header.test.tsx create mode 100644 src/common/components/Header/logo.png create mode 100644 src/common/components/Icon/Avatar.tsx create mode 100644 src/common/components/Icon/FAIcon.tsx create mode 100644 src/common/components/Icon/__tests__/Avatar.test.tsx create mode 100644 src/common/components/Icon/__tests__/FAIcon.test.tsx create mode 100644 src/common/components/Layout/StandardLayout.tsx create mode 100644 src/common/components/Layout/__tests__/StandardLayout.test.tsx create mode 100644 src/common/components/Link/Link.tsx create mode 100644 src/common/components/Link/__tests__/Link.test.tsx create mode 100644 src/common/components/Loader/LoaderSkeleton.tsx create mode 100644 src/common/components/Loader/LoaderSpinner.tsx create mode 100644 src/common/components/Loader/__tests__/LoaderSkeleton.test.tsx create mode 100644 src/common/components/Loader/__tests__/LoaderSpinner.test.tsx create mode 100644 src/common/components/Menu/Menu.tsx create mode 100644 src/common/components/Menu/MenuButton.tsx create mode 100644 src/common/components/Menu/MenuCloseButton.tsx create mode 100644 src/common/components/Menu/MenuNavLink.tsx create mode 100644 src/common/components/Menu/MenuSeparator.tsx create mode 100644 src/common/components/Menu/SideMenu/SideMenu.tsx create mode 100644 src/common/components/Menu/SideMenu/SideMenuHeader.tsx create mode 100644 src/common/components/Menu/SideMenu/__tests__/SideMenu.test.tsx create mode 100644 src/common/components/Menu/SideMenu/__tests__/SideMenuHeader.test.tsx create mode 100644 src/common/components/Menu/__tests__/MenuButton.test.tsx create mode 100644 src/common/components/Menu/__tests__/MenuCloseButton.test.tsx create mode 100644 src/common/components/Menu/__tests__/MenuNavLink.test.tsx create mode 100644 src/common/components/Menu/__tests__/MenuSeparator.test.tsx create mode 100644 src/common/components/Page/Page.tsx create mode 100644 src/common/components/Page/__tests__/Page.test.tsx create mode 100644 src/common/components/Router/PrivateOutlet.tsx create mode 100644 src/common/components/Router/Router.tsx create mode 100644 src/common/components/Router/__tests__/PrivateOutlet.test.tsx create mode 100644 src/common/components/Table/Table.tsx create mode 100644 src/common/components/Table/__tests__/Table.test.tsx create mode 100644 src/common/components/Tabs/Tab.tsx create mode 100644 src/common/components/Tabs/TabContent.tsx create mode 100644 src/common/components/Tabs/Tabs.tsx create mode 100644 src/common/components/Tabs/__tests__/Tab.test.tsx create mode 100644 src/common/components/Tabs/__tests__/TabContent.test.tsx create mode 100644 src/common/components/Tabs/__tests__/Tabs.test.tsx create mode 100644 src/common/components/Text/CodeSnippet.tsx create mode 100644 src/common/components/Text/Text.tsx create mode 100644 src/common/components/Text/__tests__/CodeSnippet.test.tsx create mode 100644 src/common/components/Text/__tests__/Text.test.tsx create mode 100644 src/common/components/Theme/Theme.tsx create mode 100644 src/common/components/Theme/__tests__/Theme.test.tsx create mode 100644 src/common/components/Toast/Toast.tsx create mode 100644 src/common/components/Toast/Toasts.tsx create mode 100644 src/common/components/Toast/__tests__/Toast.test.tsx create mode 100644 src/common/components/Toast/__tests__/Toasts.test.tsx create mode 100644 src/common/hooks/__tests__/useAuth.test.ts create mode 100644 src/common/hooks/__tests__/useAxios.test.ts create mode 100644 src/common/hooks/__tests__/useConfig.test.ts create mode 100644 src/common/hooks/__tests__/useDebounce.test.ts create mode 100644 src/common/hooks/__tests__/useSettings.test.ts create mode 100644 src/common/hooks/__tests__/useToasts.test.ts create mode 100644 src/common/hooks/useAuth.ts create mode 100644 src/common/hooks/useAxios.ts create mode 100644 src/common/hooks/useConfig.ts create mode 100644 src/common/hooks/useDebounce.ts create mode 100644 src/common/hooks/useSettings.ts create mode 100644 src/common/hooks/useToasts.ts create mode 100644 src/common/providers/AuthContext.ts create mode 100644 src/common/providers/AuthProvider.tsx create mode 100644 src/common/providers/AxiosContext.ts create mode 100644 src/common/providers/AxiosProvider.tsx create mode 100644 src/common/providers/ConfigContext.ts create mode 100644 src/common/providers/ConfigProvider.tsx create mode 100644 src/common/providers/SettingsContext.ts create mode 100644 src/common/providers/SettingsProvider.tsx create mode 100644 src/common/providers/ToastsContext.ts create mode 100644 src/common/providers/ToastsProvider.tsx create mode 100644 src/common/providers/__tests__/AuthProvider.test.tsx create mode 100644 src/common/providers/__tests__/AxiosProvider.test.tsx create mode 100644 src/common/providers/__tests__/ConfigProvider.test.tsx create mode 100644 src/common/providers/__tests__/SettingsProvider.test.tsx create mode 100644 src/common/providers/__tests__/ToastsProvider.test.tsx create mode 100644 src/common/utils/__tests__/numbers.test.ts create mode 100644 src/common/utils/__tests__/storage.test.ts create mode 100644 src/common/utils/constants.ts create mode 100644 src/common/utils/i18n/__tests__/i18n.test.ts create mode 100644 src/common/utils/i18n/index.ts create mode 100644 src/common/utils/i18n/locales/en/common.json create mode 100644 src/common/utils/i18n/locales/en/index.ts create mode 100644 src/common/utils/i18n/locales/en/users.json create mode 100644 src/common/utils/i18n/locales/es/common.json create mode 100644 src/common/utils/i18n/locales/es/index.ts create mode 100644 src/common/utils/i18n/locales/es/users.json create mode 100644 src/common/utils/i18n/locales/fr/common.json create mode 100644 src/common/utils/i18n/locales/fr/index.ts create mode 100644 src/common/utils/i18n/locales/fr/users.json create mode 100644 src/common/utils/numbers.ts create mode 100644 src/common/utils/storage.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/ComponentsPage/ComponentsPage.tsx create mode 100644 src/pages/ComponentsPage/__tests__/ComponentsPage.test.tsx create mode 100644 src/pages/ComponentsPage/components/AvatarComponents.tsx create mode 100644 src/pages/ComponentsPage/components/BadgeComponents.tsx create mode 100644 src/pages/ComponentsPage/components/ButtonComponents.tsx create mode 100644 src/pages/ComponentsPage/components/CardComponents.tsx create mode 100644 src/pages/ComponentsPage/components/TextComponents.tsx create mode 100644 src/pages/ComponentsPage/components/__tests__/AvatarComponents.test.tsx create mode 100644 src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx create mode 100644 src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx create mode 100644 src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx create mode 100644 src/pages/ComponentsPage/components/__tests__/TextComponents.test.tsx create mode 100644 src/pages/ComponentsPage/components/avatar-picture.png create mode 100644 src/pages/ComponentsPage/model/components.ts create mode 100644 src/pages/DashboardPage/DashboardPage.tsx create mode 100644 src/pages/DashboardPage/__tests__/DashboardPage.test.tsx create mode 100644 src/pages/ErrorPage/ErrorPage.tsx create mode 100644 src/pages/ErrorPage/__tests__/ErrorPage.test.tsx create mode 100644 src/pages/LandingPage/LandingPage.tsx create mode 100644 src/pages/LandingPage/__tests__/LandingPage.test.tsx create mode 100644 src/pages/SettingsPage/SettingsPage.tsx create mode 100644 src/pages/SettingsPage/__tests__/SettingsPage.test.tsx create mode 100644 src/pages/SettingsPage/components/AppearanceSettings.tsx create mode 100644 src/pages/SettingsPage/components/SettingsHeading.tsx create mode 100644 src/pages/SettingsPage/components/__tests__/AppearanceSettings.test.tsx create mode 100644 src/pages/SettingsPage/components/__tests__/SettingsHeading.test.tsx create mode 100644 src/pages/SigninPage/SigninPage.tsx create mode 100644 src/pages/SigninPage/__tests__/SigninPage.test.tsx create mode 100644 src/pages/SigninPage/api/__tests__/useSignin.test.ts create mode 100644 src/pages/SigninPage/api/useSignin.ts create mode 100644 src/pages/SigninPage/components/SigninForm.tsx create mode 100644 src/pages/SigninPage/components/__tests__/SigninForm.test.tsx create mode 100644 src/pages/SignoutPage/SignoutPage.tsx create mode 100644 src/pages/SignoutPage/__tests__/SignoutPage.test.tsx create mode 100644 src/pages/SignoutPage/api/__tests__/useSignout.test.ts create mode 100644 src/pages/SignoutPage/api/useSignout.ts create mode 100644 src/pages/UsersPage/Tasks/api/__tests__/useDeleteTask.test.ts create mode 100644 src/pages/UsersPage/Tasks/api/__tests__/useGetTask.test.ts create mode 100644 src/pages/UsersPage/Tasks/api/__tests__/useUpdateTask.test.ts create mode 100644 src/pages/UsersPage/Tasks/api/useDeleteTask.ts create mode 100644 src/pages/UsersPage/Tasks/api/useGetTask.ts create mode 100644 src/pages/UsersPage/Tasks/api/useUpdateTask.ts create mode 100644 src/pages/UsersPage/Tasks/components/TaskCompleteToggle.tsx create mode 100644 src/pages/UsersPage/Tasks/components/TaskDeleteDialog.tsx create mode 100644 src/pages/UsersPage/Tasks/components/TaskDetail.tsx create mode 100644 src/pages/UsersPage/Tasks/components/__tests__/TaskCompleteToggle.test.tsx create mode 100644 src/pages/UsersPage/Tasks/components/__tests__/TaskDeleteDialog.test.tsx create mode 100644 src/pages/UsersPage/Tasks/components/__tests__/TaskDetail.test.tsx create mode 100644 src/pages/UsersPage/UsersPage.tsx create mode 100644 src/pages/UsersPage/__tests__/UsersPage.test.tsx create mode 100644 src/pages/UsersPage/api/__tests__/useGetUserTasks.test.ts create mode 100644 src/pages/UsersPage/api/__tests__/useGetUsers.test.ts create mode 100644 src/pages/UsersPage/api/useGetUserTasks.ts create mode 100644 src/pages/UsersPage/api/useGetUsers.ts create mode 100644 src/pages/UsersPage/components/UserDetail.tsx create mode 100644 src/pages/UsersPage/components/UserDetailEmpty.tsx create mode 100644 src/pages/UsersPage/components/UserDetailLayout.tsx create mode 100644 src/pages/UsersPage/components/UserList.tsx create mode 100644 src/pages/UsersPage/components/UserListItem.tsx create mode 100644 src/pages/UsersPage/components/UserTaskList.tsx create mode 100644 src/pages/UsersPage/components/UserTaskListItem.tsx create mode 100644 src/pages/UsersPage/components/UserTasks.tsx create mode 100644 src/pages/UsersPage/components/UserTasksCard.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserDetail.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserDetailEmpty.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserDetailLayout.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserList.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserListItem.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserTaskList.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserTaskListItem.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserTasks.test.tsx create mode 100644 src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx create mode 100644 src/test/mocks/handlers.ts create mode 100644 src/test/mocks/server.ts create mode 100644 src/test/query-client.ts create mode 100644 src/test/test-utils.tsx create mode 100644 src/test/wrappers/AllProvidersExceptRouter.tsx create mode 100644 src/test/wrappers/WithAllProviders.tsx create mode 100644 src/test/wrappers/WithQueryClientProvider.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 template.yml create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 vitest.setup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c3218e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + AWS_CFN_TEMPLATE: template.yml + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_DEV }} + ENV_FILE: ${{ secrets.ENV_CI }} + +jobs: + build: + name: 'Build' + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + + - name: Build + run: npm run build + + test: + name: 'Test' + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + + - name: Run Unit Tests + run: npm run test:ci + + scan: + name: 'Scan' + + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: 'Create .env File' + run: | + echo "${{ env.ENV_FILE }}" > .env + + - name: Run Linter + run: npm run lint + + validate-template: + name: 'Validate CloudFormation Template' + + runs-on: ubuntu-latest + timeout-minutes: 3 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Validate AWS CloudFormation Template + run: |- + aws cloudformation validate-template \ + --template-body file://${{ env.AWS_CFN_TEMPLATE }} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..4556ced --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,87 @@ +name: Deploy to Development + +on: + push: + branches: + - main + tags: + - dev + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: react-starter.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-reactstarter-resources-dev + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: dev + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_DEV }} + ENV_FILE: ${{ secrets.ENV_DEV }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..af81041 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,88 @@ +name: Deploy to Production + +on: + release: + types: + - published + push: + tags: + - prod + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: react-starter.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-reactstarter-resources-prod + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: prod + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_PROD }} + ENV_FILE: ${{ secrets.ENV_PROD }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml new file mode 100644 index 0000000..666da4a --- /dev/null +++ b/.github/workflows/deploy-qa.yml @@ -0,0 +1,87 @@ +name: Deploy to QA + +on: + push: + branches: + - release/* + tags: + - qa + +concurrency: + group: ${{ github.workflow }} + +env: + APP_NAME: react-starter.leanstacks.net + AWS_CFN_STACK_NAME: ls-ui-reactstarter-resources-qa + AWS_CFN_TEMPLATE: template.yml + AWS_ENV_CODE: qa + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ROLE_ARN: ${{ vars.AWS_ROLE_ARN_QA }} + ENV_FILE: ${{ secrets.ENV_QA }} + +jobs: + deploy: + name: Deploy + + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + + - name: Install Dependencies + run: npm ci + + - name: Create Environment Configuration + run: | + echo "${{ env.ENV_FILE }}" > .env + echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env + echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env + echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env + echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env + echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env + echo "VITE_BUILD_WORKFLOW_NAME=${{ github.workflow }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_NUMBER=${{ github.run_number }}" >> .env + echo "VITE_BUILD_WORKFLOW_RUN_ATTEMPT=${{ github.run_attempt }}" >> .env + + - name: Build + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy AWS CloudFormation Stack + run: |- + aws cloudformation deploy \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --template-file ${{ env.AWS_CFN_TEMPLATE }} \ + --parameter-overrides EnvironmentCode=${{ env.AWS_ENV_CODE }} \ + --tags App=${{ env.APP_NAME }} Env=${{ env.AWS_ENV_CODE }} OU=leanstacks Owner='Matthew Warman' + + - name: Get CloudFormation Stack Outputs + id: cloudformation + run: |- + APP_BUCKET_NAME=$( + aws cloudformation describe-stacks \ + --stack-name ${{ env.AWS_CFN_STACK_NAME }} \ + --query "Stacks[0].Outputs[?OutputKey=='AppBucketName'].OutputValue | [0]" + ) + echo "APP_BUCKET_NAME=$APP_BUCKET_NAME" >> "$GITHUB_OUTPUT" + + - name: Deploy to AWS S3 + run: | + aws s3 sync dist s3://${{ steps.cloudformation.outputs.APP_BUCKET_NAME }} --delete diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7e569a --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules + +# Build +dist +dist-ssr + +# Config +*.local + +# Tests +coverage + +# Editor +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..12419e9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.18.1 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..41b16d2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "printWidth": 100, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/README.md b/README.md index 68e8a1f..4cf8dd3 100644 --- a/README.md +++ b/README.md @@ -1 +1,274 @@ -# react-starter-kit \ No newline at end of file +# React Starter Kit + +A serverless, progressive, responsive starter user interface (UI) with React at the core of the technology stack. + +[![CI](https://github.com/leanstacks/react-starter-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/leanstacks/react-starter-kit/actions/workflows/ci.yml) + +## Helpful Hints + +### Data + +This project's API integration uses the simulated REST endpoints made available by [JSON Placeholder](https://jsonplaceholder.typicode.com/). + +### Authentication + +When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_ so long as it meets the password validation requirements. For example, try username `Bret` and password `aB123456789!`. + +## About + +This project was bootstrapped with the [Vite](https://vitejs.dev/) React TypeScript template. + +The production technology stack includes: + +- React - core web user interface library +- React Router Dom - declarative component routing +- TanStack Query - asynchronous state management, caching, and data fetching +- Axios - http client +- Formik - form input validation, formatting, masking, and error handling +- TailwindCSS - utility first CSS framework +- React Spring - animations +- Font Awesome - iconography +- Google Fonts - typography +- React i18next - internationalization +- Yup - schema based validation +- Lodash - utility functions +- DayJS - date and time utility functions +- TanStack React Table - advanced tables and datagrids + +The development technology stack includes: +- Vite - front end build tooling +- Vitest - core unit test framework +- React Testing Library - user-centric UI component testing +- Mock Service Worker - API mocking +- TypeScript - the TypeScript language + +### Repository + +This repository uses [trunk-based development](https://www.atlassian.com/continuous-delivery/continuous-integration/trunk-based-development). The latest code is located on the `main` branch. The `main` branch is always ready for deployment. + +Features are developed on branches named `feature/NNNNN` which are created from the `main` branch. The feature name used in the branch contains an issue identifier or a short name, e.g. `feature/123-do-something`. + +Releases are created on branches named `release/MM.mm.pp` which are created from the `main` branch. The release name follows the [semantic versioning](https://semver.org/) specification. + +Hotfixes are created on branches named `release/MM.mm.pp` which are created from the appropriate `release/MM.mm.pp` branch. + +A pull request must be opened requesting merge from any branch back to `main`. GitHub actions perform continuous integration, CI, checks against the PR source branch. At least one code review approval is required to complete the pull request. + +See also: [Feature flags](https://www.atlassian.com/continuous-delivery/principles/feature-flags) + +### Issue Management + +This project uses [GitHub Issues](https://github.com/leanstacks/react-starter-kit/issues). + +### Code Formatting + +The project includes a configuration file for the [Prettier](https://prettier.io/docs/en/configuration.html) code formatter. This allows all project contributors to share the same code formatting rules. + +Adjust the Prettier configuration as desired. + +## Installation + +### Prerequistes + +It is strongly recommended that you install Node Version Manager, [`nvm`][nvm]. Node Version Manager simplifies working on multiple projects with different versions of Node.js. + +### Clone the Repository + +Open the [repository][repo] in a browser. Follow the instructions to clone the repository to your local machine. + +### Install Node + +Open a terminal window and navigate to the project base directory. Issue the following command to install the version of Node and NPM used by the application: + +```bash +# If you already have this version of Node, simply switch to it... +nvm use + +# If you do NOT have this version of Node, install it... +nvm install +``` + +Node Version Manager inspects the `.nvmrc` file in the project base directory and uses or installs the specified version of Node and the Node Package Manager, npm. + +### Install the Dependencies + +To install the project dependencies, issue the following commands at a terminal prompt in the project base directory: + +```bash +# Switch to the project node version... +nvm use + +# Install project dependencies +npm install +``` + +### After Installation + +The installation is now complete! You may open the project in your favorite source code editor (we recommend [Visual Studio Code](https://code.visualstudio.com/)). + +We recommend the following VS Code extensions: + +- Prettier - Code formatter (required) +- Tailwind CSS IntelliSense (required) +- ESLint (recommended) +- Indent Rainbow (optional) +- GitLens (optional) +- Dotenv Official +Vault (optional) +- GitHub Actions (optional) + +Install the _Prettier_ extension to ensure that all project participants' contributions are formatted using the same rules. The extension leverages project-specific rules found in the `.prettierrc` file in the project base directory. + +The _Tailwind CSS IntelliSense_ extension is a must-have companion in all projects using Tailwind. The extension ensures that Tailwind CSS classes are named and ordered correctly and flags any conflicting classes. + +## Configuration + +The application is configured using Environment Variables. Because single-page applications are static, environment variable values are injected into the application during the build. The environment variables may be sourced from the environment or `.env` files as described in the [Vite documentation](https://vitejs.dev/guide/env-and-mode.html). + +### `.env` files + +> **NOTE:** Because they may contain sensitive information, `.env` files are not committed to the repository. + +After project installation and before running the application locally, create the following `.env` files in the project base directory. Learn more in the official [Vite guide for environment variables and modes](https://vitejs.dev/guide/env-and-mode.html). + +#### `.env.local` + +The `.env.local` configuration file provides the configuration values when the application is started on a developer's local machine. + +``` +# Provided by Pipeline (Simulated) +VITE_BUILD_DATE=1970-01-01 +VITE_BUILD_TIME=00:00:00 +VITE_BUILD_TS=1970-01-01T00:00:00+0000 +VITE_BUILD_COMMIT_SHA=local +VITE_BUILD_ENV_CODE=local +VITE_BUILD_WORKFLOW_NAME=local +VITE_BUILD_WORKFLOW_RUN_NUMBER=1 +VITE_BUILD_WORKFLOW_RUN_ATTEMPT=1 + +# API Configuration +VITE_BASE_URL_API=https://jsonplaceholder.typicode.com + +# Toasts Configuration +VITE_TOAST_AUTO_DISMISS_MILLIS=5000 +``` + +#### `.env.test.local` + +The `.env.test.local` configuration file provides configuration values used when unit tests are executed on a developer's local machine. + +> **NOTE:** Use the same values when running tests in a CI/CD pipeline. + +``` +# Provided by Pipeline (Simulated) +VITE_BUILD_DATE=1970-01-01 +VITE_BUILD_TIME=00:00:00 +VITE_BUILD_TS=1970-01-01T00:00:00+0000 +VITE_BUILD_COMMIT_SHA=test +VITE_BUILD_ENV_CODE=test +VITE_BUILD_WORKFLOW_NAME=test +VITE_BUILD_WORKFLOW_RUN_NUMBER=1 +VITE_BUILD_WORKFLOW_RUN_ATTEMPT=1 + +# API Configuration +VITE_BASE_URL_API=https://jsonplaceholder.typicode.com + +# Toasts Configuration +VITE_TOAST_AUTO_DISMISS_MILLIS=1500 +``` + +## Available Scripts + +Many of the scripts leverage the [Vite CLI](https://vitejs.dev/guide/cli.html) or the [Vitest CLI](https://vitest.dev/guide/cli.html). Read more about them in their respective official guides. + +In the project base directory, the following commands are available to run. + +### `npm run dev` + +Runs the app in the development mode. +Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +The page will reload when source files are saved. + +### `npm test` + +Launches the test runner in the interactive watch mode. +See the section about [running tests](https://vitest.dev/guide/cli.html) for more information. + +### `npm run test:ci` + +Executes the test runner in `CI` mode and produces a coverage report. With `CI` mode enabled, the test runner executes all tests one time and prints a summary report to the console. A code coverage report is printed to the console immediately following the test summary. + +A detailed test coverage report is created in the `./coverage` directory. + +> **NOTE:** This is the command which should be utilized by CI/CD platforms. + +### `npm run build` + +Builds the app for production to the `dist` folder. +It correctly bundles React in production mode and optimizes the build for the best performance. + +See the official guide for more information about [building for production](https://vitejs.dev/guide/build.html) and [deploying a static site](https://vitejs.dev/guide/static-deploy.html). + +### `npm run lint` + +Runs the eslint static code analysis and prints the results to the console. + +## DevOps + +### Cloud Resources + +The AWS resources for this application component are provisioned via AWS CloudFormation. The `template.yml` file is the CloudFormation template. + +The resources provisioned are: + +| Resource | Description | +| ----------------------- | ----------------------------------------------------------------------------- | +| S3 Bucket | Contains the published application. | +| S3 Bucket Policy | Provides access to the S3 Bucket from AWS CloudFront. | +| CloudFront Distribution | A CloudFront distribution to serve the SPA application. | +| CloudFront Distribution | A CloudFront distribution to serve the full-stack application (UI, API, etc). | +| Route53 RecordSet | An `A` record for the application distribution. | +| Route53 RecordSet | An `AAAA` record for the application distribution. | + +### CI/CD Pipelines + +This project uses GitHub Actions to perform DevOps automation activities such as Continuous Integration and Continous Deployment. See all project [GitHub Actions workflow runs](https://github.com/leanstacks/react-starter-kit/actions). + +| Workflow | Trigger | Description | +| --------------------- | ------------------------------ | ------------------------------------------------------------------------------------ | +| CI | Pull Request for `main` branch | Builds, lints, and tests the application. Validates the AWS CloudFormation template. | +| Deploy to Development | Push to `main` branch | Deploys AWS CloudFormation stack. Builds and deploys the application. | +| Deploy to QA | Push to `release/*` branch | Deploys AWS CloudFormation stack. Builds and deploys the application. | +| Deploy to Production | Publish a Release | Deploys AWS CloudFormation stack. Builds and deploys the application. | + +## Related Information + +- [Vite][vite] +- [React][react] +- [React Router][reactrouter] +- [TanStack][tanstack] +- [Axios][axios] +- [Formik][formik] +- [Yup][yup] +- [Tailwind CSS][tailwind] +- [Font Awesome][fontawesome] +- [React Spring][reactspring] +- [React i18next][reacti18next] +- [React Testing Library][testing-library] +- [GitHub Actions][ghactions] + +[repo]: https://github.com/leanstacks/react-starter-kit 'GitHub Repository' +[nvm]: https://github.com/nvm-sh/nvm 'Node Version Manager' +[react]: https://react.dev 'React' +[reactrouter]: https://reactrouter.com/ 'React Router' +[vite]: https://vitejs.dev/ 'Vite' +[axios]: https://axios-http.com/ 'Axios' +[formik]: https://formik.org/ 'Formik' +[yup]: https://github.com/jquense/yup 'Yup' +[tailwind]: https://tailwindcss.com/ 'Tailwind CSS' +[fontawesome]: https://fontawesome.com/ 'Font Awesome' +[tanstack]: https://tanstack.com/ 'TanStack' +[testing-library]: https://testing-library.com/ 'Testing Library' +[ghactions]: https://docs.github.com/en/actions 'GitHub Actions' +[reacti18next]: https://react.i18next.com/ 'React i18next' +[reactspring]: https://www.react-spring.dev/ 'React Spring' diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..9632899 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,46 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; + +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +import pluginReactHooks from 'eslint-plugin-react-hooks'; +import pluginReactRefresh from 'eslint-plugin-react-refresh'; + +/** + * The ESLint configuration in "flat config" format. + * + * Note: This configuration uses helper functions from `typescript-eslint`. + * + * @see {@link https://eslint.org/docs/latest/use/configure/ Configure ESLint} + * @see {@link https://typescript-eslint.io/packages/typescript-eslint typescript-eslint} + */ +export default tseslint.config( + { + files: ['**/*.ts', '**/*.tsx'], + extends: [eslint.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + globals: { + ...globals.browser, + }, + + parser: tsParser, + }, + linterOptions: { + reportUnusedDisableDirectives: 'warn', + }, + plugins: { + 'react-hooks': pluginReactHooks, + 'react-refresh': pluginReactRefresh, + }, + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-empty-object-type': ['error', { allowWithName: 'Props$' }], + }, + }, + { + // global ignores + // do not add any other keys to this object + ignores: ['coverage/', 'dist/'], + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..e090e36 --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + React Starter | LeanStacks + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc84299 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7499 @@ +{ + "name": "react-starter-kit", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-starter-kit", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@codesandbox/sandpack-react": "2.19.9", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/react-fontawesome": "0.2.2", + "@leanstacks/react-common": "1.1.0", + "@react-spring/web": "9.7.5", + "@tanstack/react-query": "5.59.19", + "@tanstack/react-query-devtools": "5.59.19", + "@tanstack/react-table": "8.20.5", + "axios": "1.7.7", + "classnames": "2.5.1", + "dayjs": "1.11.13", + "formik": "2.4.6", + "i18next": "23.16.4", + "i18next-browser-languagedetector": "8.0.0", + "lodash": "4.17.21", + "qs": "6.13.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "15.1.0", + "react-router-dom": "6.27.0", + "tailwindcss": "3.4.14", + "uuid": "11.0.2", + "yup": "1.4.0" + }, + "devDependencies": { + "@eslint/js": "9.14.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/eslint__js": "8.42.3", + "@types/lodash": "4.17.13", + "@types/qs": "6.9.16", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@types/uuid": "10.0.0", + "@vitejs/plugin-react": "4.3.3", + "@vitest/coverage-v8": "2.1.4", + "autoprefixer": "10.4.20", + "eslint": "9.14.0", + "eslint-plugin-react-hooks": "5.0.0", + "eslint-plugin-react-refresh": "0.4.14", + "globals": "15.12.0", + "jsdom": "25.0.1", + "msw": "2.6.0", + "postcss": "8.4.47", + "prettier": "3.3.3", + "prettier-plugin-tailwindcss": "0.6.8", + "typescript": "5.6.3", + "typescript-eslint": "8.12.2", + "vite": "5.4.10", + "vitest": "2.1.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", + "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", + "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", + "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.3", + "@babel/types": "^7.26.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", + "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", + "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", + "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.18.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.3.tgz", + "integrity": "sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.6", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.6.tgz", + "integrity": "sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz", + "integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.35.3", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.35.3.tgz", + "integrity": "sha512-ScY7L8+EGdPl4QtoBiOzE4FELp7JmNUsBvgBcCakXWM2uiv/K89VAzU3BMDscf0DsACLvTKePbd5+cFDTcei6g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@codesandbox/nodebox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@codesandbox/nodebox/-/nodebox-0.1.8.tgz", + "integrity": "sha512-2VRS6JDSk+M+pg56GA6CryyUSGPjBEe8Pnae0QL3jJF1mJZJVMDKr93gJRtBbLkfZN6LD/DwMtf+2L0bpWrjqg==", + "license": "SEE LICENSE IN ./LICENSE", + "dependencies": { + "outvariant": "^1.4.0", + "strict-event-emitter": "^0.4.3" + } + }, + "node_modules/@codesandbox/sandpack-client": { + "version": "2.19.8", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.19.8.tgz", + "integrity": "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==", + "license": "Apache-2.0", + "dependencies": { + "@codesandbox/nodebox": "0.1.8", + "buffer": "^6.0.3", + "dequal": "^2.0.2", + "mime-db": "^1.52.0", + "outvariant": "1.4.0", + "static-browser-server": "1.0.3" + } + }, + "node_modules/@codesandbox/sandpack-react": { + "version": "2.19.9", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-react/-/sandpack-react-2.19.9.tgz", + "integrity": "sha512-a5uXWYdg5Wtz6VHwXIegdS7C63foCofFa/eHO9crtPp1Yf5/npKimds0S3kKJL7jpOmMAascEvAOqOy5S9e6qQ==", + "license": "Apache-2.0", + "dependencies": { + "@codemirror/autocomplete": "^6.4.0", + "@codemirror/commands": "^6.1.3", + "@codemirror/lang-css": "^6.0.1", + "@codemirror/lang-html": "^6.4.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.3.2", + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.7.1", + "@codesandbox/sandpack-client": "^2.19.8", + "@lezer/highlight": "^1.1.3", + "@react-hook/intersection-observer": "^3.1.1", + "@stitches/core": "^1.2.6", + "anser": "^2.1.1", + "clean-set": "^1.1.2", + "dequal": "^2.0.2", + "escape-carriage": "^1.3.1", + "lz-string": "^1.4.4", + "react-devtools-inline": "4.4.0", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", + "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", + "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", + "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.0.tgz", + "integrity": "sha512-osaBbIMEqVFjTX5exoqPXs6PilWQdjaLhGtMDXMXg/yxkHXNq43GlxGyTA35lK2HpzUgDN+Cjh/2AmqCN0QJpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.1.tgz", + "integrity": "sha512-rmZVXy9iZvO3ZStEe/ayuuwIJ23LSF13aPMlLMTQARX6lGUBDHGV8UB5i9MRrfy0+mZwt5/9bdy8llszSD3NQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", + "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leanstacks/react-common": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@leanstacks/react-common/-/react-common-1.1.0.tgz", + "integrity": "sha512-2ThKzKL3/uM448gXIPTVyzP/hTZoFW2h90miXTYdMW6F63CUcoyWEpsYdD1RcjN5diRuKnOWmNuTlXSpGtxmRg==", + "license": "MIT", + "dependencies": { + "classnames": "2.5.1", + "dayjs": "1.11.13" + }, + "peerDependencies": { + "react": "18.x", + "tailwindcss": "3.x" + } + }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", + "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.21", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.21.tgz", + "integrity": "sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.36.10", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.36.10.tgz", + "integrity": "sha512-GXrJgakgJW3DWKueebkvtYgGKkxA7s0u5B0P5syJM5rvQUnrpLPigvci8Hukl7yEM+sU06l+er2Fgvx/gmiRgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mswjs/interceptors/node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@react-hook/intersection-observer": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-hook/intersection-observer/-/intersection-observer-3.1.2.tgz", + "integrity": "sha512-mWU3BMkmmzyYMSuhO9wu3eJVP21N8TcgYm9bZnTrMwuM818bEk+0NRM3hP+c/TqA9Ln5C7qE53p1H0QMtzYdvQ==", + "license": "MIT", + "dependencies": { + "@react-hook/passive-layout-effect": "^1.2.0", + "intersection-observer": "^0.10.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/@react-hook/passive-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz", + "integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", + "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", + "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", + "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", + "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", + "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", + "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", + "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", + "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", + "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", + "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", + "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", + "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", + "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", + "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", + "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", + "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", + "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", + "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", + "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stitches/core": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@stitches/core/-/core-1.2.8.tgz", + "integrity": "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.59.17", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.17.tgz", + "integrity": "sha512-jWdDiif8kaqnRGHNXAa9CnudtxY5v9DUxXhodgqX2Rwzj+1UwStDHEbBd9IA5C7VYAaJ2s+BxFR6PUBs8ERorA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.59.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.59.19.tgz", + "integrity": "sha512-Gw+3zsADpqiYgx/6MMr9bP1+x2LR8vOuGjo5Un/89qwwP3z7WAHPWFagLFDYkLq68NX7ekUpW/EOYlUMugMXGA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.19.tgz", + "integrity": "sha512-xLRfyFyQOFcLltKCds0LijfC6/HQJrrTTnZB8ciyn74LIkVAm++vZJ6eUVG20RmJtdP8REdy7vSOYW4M3//XLA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.59.17" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.59.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.19.tgz", + "integrity": "sha512-mYFWTHLtJr2HdyYPZPzzvQ2ksCsSL6L04fCtusPFD3waskXrtmvWvyuDIGeEGdVAYS0Urwxw/0sYvcTVQZH+zQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.59.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.59.19", + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint__js": { + "version": "8.42.3", + "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", + "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", + "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz", + "integrity": "sha512-gQxbxM8mcxBwaEmWdtLCIGLfixBMHhQjBqR8sVWNTPpcj45WlYL2IObS/DNMLH1DBP0n8qz+aiiLTGfopPEebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.12.2", + "@typescript-eslint/type-utils": "8.12.2", + "@typescript-eslint/utils": "8.12.2", + "@typescript-eslint/visitor-keys": "8.12.2", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.12.2.tgz", + "integrity": "sha512-MrvlXNfGPLH3Z+r7Tk+Z5moZAc0dzdVjTgUgwsdGweH7lydysQsnSww3nAmsq8blFuRD5VRlAr9YdEFw3e6PBw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.12.2", + "@typescript-eslint/types": "8.12.2", + "@typescript-eslint/typescript-estree": "8.12.2", + "@typescript-eslint/visitor-keys": "8.12.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.12.2.tgz", + "integrity": "sha512-gPLpLtrj9aMHOvxJkSbDBmbRuYdtiEbnvO25bCMza3DhMjTQw0u7Y1M+YR5JPbMsXXnSPuCf5hfq0nEkQDL/JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.12.2", + "@typescript-eslint/visitor-keys": "8.12.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.12.2.tgz", + "integrity": "sha512-bwuU4TAogPI+1q/IJSKuD4shBLc/d2vGcRT588q+jzayQyjVK2X6v/fbR4InY2U2sgf8MEvVCqEWUzYzgBNcGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.12.2", + "@typescript-eslint/utils": "8.12.2", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.12.2.tgz", + "integrity": "sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.12.2.tgz", + "integrity": "sha512-mME5MDwGe30Pq9zKPvyduyU86PH7aixwqYR2grTglAdB+AN8xXQ1vFGpYaUSJ5o5P/5znsSBeNcs5g5/2aQwow==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.12.2", + "@typescript-eslint/visitor-keys": "8.12.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.12.2.tgz", + "integrity": "sha512-UTTuDIX3fkfAz6iSVa5rTuSfWIYZ6ATtEocQ/umkRSyC9O919lbZ8dcH7mysshrCdrAM03skJOEYaBugxN+M6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.12.2", + "@typescript-eslint/types": "8.12.2", + "@typescript-eslint/typescript-estree": "8.12.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.12.2.tgz", + "integrity": "sha512-PChz8UaKQAVNHghsHcPyx1OMHoFRUEA7rJSK/mDhdq85bk+PLsUHUBqTQTFt18VJZbmxBovM65fezlheQRsSDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.12.2", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz", + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.4.tgz", + "integrity": "sha512-FPKQuJfR6VTfcNMcGpqInmtJuVXFSCd9HQltYncfR01AzXhLucMEtQ5SinPdZxsT5x/5BK7I5qFJ5/ApGCmyTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.7.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.4", + "vitest": "2.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz", + "integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.4", + "@vitest/utils": "2.1.4", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz", + "integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.4.tgz", + "integrity": "sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.4", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.4.tgz", + "integrity": "sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.4", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", + "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz", + "integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.4.tgz", + "integrity": "sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.4", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", + "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.0.tgz", + "integrity": "sha512-pGGR7Nq1K/i9KGs29PvHDXA8AsfZ3OiYRMDClT3FIC085Kbns9CJ7ogq9MEiGnrjd9THOGoh7B+kWzePHzZyJQ==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001687", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz", + "integrity": "sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-set": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/clean-set/-/clean-set-1.1.2.tgz", + "integrity": "sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", + "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.72", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.72.tgz", + "integrity": "sha512-ZpSAUOZ2Izby7qnZluSrAlGgGQzucmFbN0n64dYzocYxnxV5ufurpj3VgEe4cUp7ir9LmeLxNYo8bVnlM8bQHw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-carriage": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", + "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", + "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.14.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", + "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", + "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formik": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.5.tgz", + "integrity": "sha512-Y4+pKa7XeRUPWFNvOOYHkRYrfzW07oraURSvjDmRVOJ748OrVmeXtpE4+GCEHncjCjkTxPNRt8kEbxDhsn6VTg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz", + "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/i18next": { + "version": "23.16.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.4.tgz", + "integrity": "sha512-9NIYBVy9cs4wIqzurf7nLXPyf3R78xYbxExVqHLK9od3038rjpyOEzW+XB130kZ1N4PZ9inTtJ471CRJ4Ituyg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz", + "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/intersection-observer": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.10.0.tgz", + "integrity": "sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==", + "license": "W3C-20150513" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.15", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.15.tgz", + "integrity": "sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.6.0.tgz", + "integrity": "sha512-n3tx2w0MZ3H4pxY0ozrQ4sNPzK/dGtlr2cIIyuEsgq2Bhy4wvcW6ZH2w/gXM9+MEUY6HC1fWhqtcXDxVZr5Jxw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.36.5", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "chalk": "^4.1.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.8.tgz", + "integrity": "sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-inline": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-devtools-inline/-/react-devtools-inline-4.4.0.tgz", + "integrity": "sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ==", + "license": "MIT", + "dependencies": { + "es6-symbol": "^3" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", + "license": "MIT" + }, + "node_modules/react-i18next": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.1.0.tgz", + "integrity": "sha512-zj3nJynMnZsy2gPZiOTC7XctCY5eQGqT3tcKMmfJWC9FMvgd+960w/adq61j8iPzpwmsXejqID9qC3Mqu1Xu2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", + "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.28.1", + "@rollup/rollup-android-arm64": "4.28.1", + "@rollup/rollup-darwin-arm64": "4.28.1", + "@rollup/rollup-darwin-x64": "4.28.1", + "@rollup/rollup-freebsd-arm64": "4.28.1", + "@rollup/rollup-freebsd-x64": "4.28.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", + "@rollup/rollup-linux-arm-musleabihf": "4.28.1", + "@rollup/rollup-linux-arm64-gnu": "4.28.1", + "@rollup/rollup-linux-arm64-musl": "4.28.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", + "@rollup/rollup-linux-riscv64-gnu": "4.28.1", + "@rollup/rollup-linux-s390x-gnu": "4.28.1", + "@rollup/rollup-linux-x64-gnu": "4.28.1", + "@rollup/rollup-linux-x64-musl": "4.28.1", + "@rollup/rollup-win32-arm64-msvc": "4.28.1", + "@rollup/rollup-win32-ia32-msvc": "4.28.1", + "@rollup/rollup-win32-x64-msvc": "4.28.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/static-browser-server": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz", + "integrity": "sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==", + "license": "Apache-2.0", + "dependencies": { + "@open-draft/deferred-promise": "^2.1.0", + "dotenv": "^16.0.3", + "mime-db": "^1.52.0", + "outvariant": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.66", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.66.tgz", + "integrity": "sha512-l3ciXsYFel/jSRfESbyKYud1nOw7WfhrBEF9I3UiarYk/qEaOOwu3qXNECHw4fHGHGTEOuhf/VdKgoDX5M/dhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.66" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.66", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.66.tgz", + "integrity": "sha512-s07jJruSwndD2X8bVjwioPfqpIc1pDTzszPe9pL1Skbh4bjytL85KNQ3tolqLbCvpQHawIsGfFi9dgerWjqW4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.30.0.tgz", + "integrity": "sha512-G6zXWS1dLj6eagy6sVhOMQiLtJdxQBHIA9Z6HFUNLOlr6MFOgzV8wvmidtPONfPtEUv0uZsy77XJNzTAfwPDaA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.12.2.tgz", + "integrity": "sha512-UbuVUWSrHVR03q9CWx+JDHeO6B/Hr9p4U5lRH++5tq/EbFq1faYZe50ZSBePptgfIKLEti0aPQ3hFgnPVcd8ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.12.2", + "@typescript-eslint/parser": "8.12.2", + "@typescript-eslint/utils": "8.12.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", + "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.4.tgz", + "integrity": "sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.4.tgz", + "integrity": "sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.4", + "@vitest/mocker": "2.1.4", + "@vitest/pretty-format": "^2.1.4", + "@vitest/runner": "2.1.4", + "@vitest/snapshot": "2.1.4", + "@vitest/spy": "2.1.4", + "@vitest/utils": "2.1.4", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.4", + "@vitest/ui": "2.1.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..64999af --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "react-starter-kit", + "description": "Welcome to the LEANSTACKS React starter kit. This project provides a template to kickstart React single-page applications utilizing a curated Technology Stack for optimal testability, maintainability, and operability.", + "keywords": [ + "react" + ], + "author": "LeanStacks", + "license": "MIT", + "version": "0.1.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/leanstacks/react-starter-kit.git" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint --max-warnings 0", + "preview": "vite preview", + "test": "vitest", + "test:coverage": "vitest --coverage --coverage.all=false", + "test:ci": "vitest run --coverage --silent" + }, + "dependencies": { + "@codesandbox/sandpack-react": "2.19.9", + "@fortawesome/fontawesome-svg-core": "6.6.0", + "@fortawesome/free-regular-svg-icons": "6.6.0", + "@fortawesome/free-solid-svg-icons": "6.6.0", + "@fortawesome/react-fontawesome": "0.2.2", + "@leanstacks/react-common": "1.1.0", + "@react-spring/web": "9.7.5", + "@tanstack/react-query": "5.59.19", + "@tanstack/react-query-devtools": "5.59.19", + "@tanstack/react-table": "8.20.5", + "axios": "1.7.7", + "classnames": "2.5.1", + "dayjs": "1.11.13", + "formik": "2.4.6", + "i18next": "23.16.4", + "i18next-browser-languagedetector": "8.0.0", + "lodash": "4.17.21", + "qs": "6.13.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "15.1.0", + "react-router-dom": "6.27.0", + "tailwindcss": "3.4.14", + "uuid": "11.0.2", + "yup": "1.4.0" + }, + "devDependencies": { + "@eslint/js": "9.14.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/eslint__js": "8.42.3", + "@types/lodash": "4.17.13", + "@types/qs": "6.9.16", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@types/uuid": "10.0.0", + "@vitejs/plugin-react": "4.3.3", + "@vitest/coverage-v8": "2.1.4", + "autoprefixer": "10.4.20", + "eslint": "9.14.0", + "eslint-plugin-react-hooks": "5.0.0", + "eslint-plugin-react-refresh": "0.4.14", + "globals": "15.12.0", + "jsdom": "25.0.1", + "msw": "2.6.0", + "postcss": "8.4.47", + "prettier": "3.3.3", + "prettier-plugin-tailwindcss": "0.6.8", + "typescript": "5.6.3", + "typescript-eslint": "8.12.2", + "vite": "5.4.10", + "vitest": "2.1.4" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c6161c87af5f737de732f33dd63186095a7d6b1f GIT binary patch literal 4286 zcmc(jOH5o<7{^boiA%e3p$i*zVKu?%LSkr{;bnOYFrZ)xJHgUI(*~Mo>jGP`Cb+0# z4394BLK9g~6L3)j17X7oc_uu<3}GV@k|_%?FwEcYf1Go3=gysW?o6zg{O8<0KA18|i$xsEOzme{k2YyL#RrooDSiAe*v3cN zPKiT;|B|GWB2(9M>i79z^cXv2kfmn;Q_(Xb&J)i}UtuAr(vlKXRaT<5rUuo40NyVv z)4s6G#V%Q7>amcN>ttJ`>-v0tv;+$*41LI*YH}}OwMR+hW zqVaosdx%6Ly6@p|82kJCmRlPe8~CWP(dh%3WOKhv+SfAWb8N44bl~355H7W~p}ww8 zkA1MV7T@&tT0R^e9^&SW8z?+?&fX6)$^J!T&qtvwvI%pU9m1{ozB9%j@9a09~D(ZhW%&?#j9Ub-^IM>gmDw z_&9cVcQx1i%+AiDM%E>Lbg;;dIr2QmpJThcyo{rxBZJiG@$oSh78dl}NV5-+oi(2} zuU)-rjkAF>-t+VG_`a_XAJo_D{mI$K#537h$Dix(x3MvU)#E%S&)2)U5U8rsT$h;J z$H1Ih?0Ff-zx@1p{2^m&uCdWkloS{1c}A{T7is!VCg)$)>#wY|6w}kw29K%9Ni;Sz zpt7O@HPzK-bE}H(>^fW+Siw7dfz4;@o-yV)k4oH~bKeSz+zj;hyYbEboAUlK@4ufq z-$GO1Y});wKG6rp`|CS*5DJB`wzh`N%}t$hZ2E=Wbm4;A+UGluY`z1N-oLc#MP{yX z@8h_0%`xTt;2dPR!=C;9o74HjVOO)rb79(ju+ASm1c?obXQG_WUrt}rJSUyMY<&9g zznniEK0NjFH~GAz|3-|%l!)IB_HoPRo;GNUHnshKY3{$Kj-*fh`^)Z8!Vi8sa%~QX z!lI;enmoJ3gLPP!ZP?b+_ov+l-b;ImHqMB+R}YKKxh3cCZ`?Y8xKdxll=A(p)XPz& ho{uQ?Tu7;tx8z?3l*;ll?L%TKA~tE846?{1`yZ9j&!PYT literal 0 HcmV?d00001 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..e3d9248 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,58 @@ +import { RouterProvider } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +import ErrorBoundary from 'common/components/Errors/ErrorBoundary'; +import ErrorFallback from 'common/components/Errors/ErrorFallback'; +import ConfigContextProvider from 'common/providers/ConfigProvider'; +import SettingsContextProvider from 'common/providers/SettingsProvider'; +import AxiosContextProvider from 'common/providers/AxiosProvider'; +import { router } from 'common/components/Router/Router'; +import Theme from 'common/components/Theme/Theme'; +import AuthContextProvider from 'common/providers/AuthProvider'; +import ToastsProvider from 'common/providers/ToastsProvider'; + +/** + * React Query `QueryClient` and configuration. + * @see {@link https://tanstack.com/query/latest/docs/react/guides/important-defaults | Important Defaults} + * @see {@link https://tanstack.com/query/latest/docs/react/reference/QueryClient | QueryClient} + */ +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, +}); + +/** + * The application. The outermost component of the React application + * hierarchy. Declares application-wide providers. + * @returns {JSX.Element} JSX + */ +function App(): JSX.Element { + return ( +
+ }> + + + + + + + + + + + + + + + + + +
+ ); +} + +export default App; diff --git a/src/__fixtures__/settings.ts b/src/__fixtures__/settings.ts new file mode 100644 index 0000000..5edcdc6 --- /dev/null +++ b/src/__fixtures__/settings.ts @@ -0,0 +1,3 @@ +import { Settings } from 'common/api/useGetSettings'; + +export const settingsFixture: Settings = { theme: 'dark' }; diff --git a/src/__fixtures__/toasts.ts b/src/__fixtures__/toasts.ts new file mode 100644 index 0000000..f1fe23b --- /dev/null +++ b/src/__fixtures__/toasts.ts @@ -0,0 +1,17 @@ +import { ToastDetail } from 'common/providers/ToastsContext'; + +export const toastFixture: ToastDetail = { + id: 'toast1', + text: 'Toast text', + isAutoDismiss: false, + createdAt: '2024-01-17T12:17:09.733Z', +}; + +export const toastFixture2: ToastDetail = { + id: 'toast2', + text: 'Toast text two', + isAutoDismiss: false, + createdAt: '2024-01-17T12:18:09.733Z', +}; + +export const toastsFixture: ToastDetail[] = [toastFixture, toastFixture2]; diff --git a/src/__fixtures__/todos.ts b/src/__fixtures__/todos.ts new file mode 100644 index 0000000..33c30f3 --- /dev/null +++ b/src/__fixtures__/todos.ts @@ -0,0 +1,40 @@ +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; + +export const todosFixture: Task[] = [ + { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: false, + }, + { + userId: 1, + id: 2, + title: 'quis ut nam facilis et officia qui', + completed: true, + }, + { + userId: 1, + id: 3, + title: 'fugiat veniam minus', + completed: false, + }, + { + userId: 2, + id: 21, + title: 'suscipit repellat esse quibusdam voluptatem incidunt', + completed: true, + }, + { + userId: 2, + id: 22, + title: 'distinctio vitae autem nihil ut molestias quo', + completed: true, + }, + { + userId: 2, + id: 23, + title: 'et itaque necessitatibus maxime molestiae qui quas velit', + completed: false, + }, +]; diff --git a/src/__fixtures__/tokens.ts b/src/__fixtures__/tokens.ts new file mode 100644 index 0000000..5d70ab2 --- /dev/null +++ b/src/__fixtures__/tokens.ts @@ -0,0 +1,10 @@ +import { UserTokens } from 'common/api/useGetUserTokens'; + +export const userTokensFixture: UserTokens = { + id_token: 'id-token', + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + token_type: 'Bearer', + expires_at: '2024-01-17T12:17:09.733Z', +}; diff --git a/src/__fixtures__/users.ts b/src/__fixtures__/users.ts new file mode 100644 index 0000000..6e9b9a5 --- /dev/null +++ b/src/__fixtures__/users.ts @@ -0,0 +1,51 @@ +import { User } from 'common/api/useGetUser'; + +export const userFixture1: User = { + id: 1, + name: 'Leanne Graham', + username: 'Bret', + email: 'Sincere@april.biz', + address: { + street: 'Kulas Light', + suite: 'Apt. 556', + city: 'Gwenborough', + zipcode: '92998-3874', + geo: { + lat: '-37.3159', + lng: '81.1496', + }, + }, + phone: '1-770-736-8031 x56442', + website: 'hildegard.org', + company: { + name: 'Romaguera-Crona', + catchPhrase: 'Multi-layered client-server neural-net', + bs: 'harness real-time e-markets', + }, +}; + +export const userFixture2: User = { + id: 2, + name: 'Ervin Howell', + username: 'Antonette', + email: 'Shanna@melissa.tv', + address: { + street: 'Victor Plains', + suite: 'Suite 879', + city: 'Wisokyburgh', + zipcode: '90566-7771', + geo: { + lat: '-43.9509', + lng: '-34.4618', + }, + }, + phone: '010-692-6593 x09125', + website: 'anastasia.net', + company: { + name: 'Deckow-Crist', + catchPhrase: 'Proactive didactic contingency', + bs: 'synergize scalable supply-chains', + }, +}; + +export const usersFixture: User[] = [userFixture1, userFixture2]; diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx new file mode 100644 index 0000000..2cb6771 --- /dev/null +++ b/src/__tests__/App.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import App from '../App'; + +describe('App', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('app'); + + // ASSERT + expect(screen.getByTestId('app')).toBeDefined(); + }); +}); diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/common/api/__tests__/useGetCurrentUser.test.ts b/src/common/api/__tests__/useGetCurrentUser.test.ts new file mode 100644 index 0000000..079439a --- /dev/null +++ b/src/common/api/__tests__/useGetCurrentUser.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { userFixture1 } from '__fixtures__/users'; +import storage from 'common/utils/storage'; + +import { useGetCurrentUser } from 'common/api/useGetCurrentUser'; + +describe('useGetCurrentUser', () => { + const getItemSpy = vi.spyOn(storage, 'getItem'); + + beforeEach(() => { + getItemSpy.mockReturnValue(JSON.stringify(userFixture1)); + }); + + it('should get current user', async () => { + // ARRANGE + const { result } = renderHook(() => useGetCurrentUser()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toEqual(userFixture1); + }); + + it('should error when user not found', async () => { + // ARRANGE + getItemSpy.mockReturnValue(null); + const { result } = renderHook(() => useGetCurrentUser()); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.data).not.toBeDefined(); + }); + + it('should error on failure to get current user', async () => { + // ARRANGE + getItemSpy.mockReturnValue('{invalid-json}}'); + const { result } = renderHook(() => useGetCurrentUser()); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.data).not.toBeDefined(); + }); +}); diff --git a/src/common/api/__tests__/useGetSettings.test.ts b/src/common/api/__tests__/useGetSettings.test.ts new file mode 100644 index 0000000..0cdc598 --- /dev/null +++ b/src/common/api/__tests__/useGetSettings.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { settingsFixture } from '__fixtures__/settings'; +import { DEFAULT_SETTINGS, StorageKeys } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +import { useGetSettings } from 'common/api/useGetSettings'; + +describe('useGetSettings', () => { + const getItemSpy = vi.spyOn(storage, 'getItem'); + + beforeEach(() => { + getItemSpy.mockReturnValue(JSON.stringify(settingsFixture)); + }); + + it('should get settings', async () => { + // ARRANGE + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.data).toBeDefined(); + expect(result.current.data?.theme).toBeDefined(); + expect(getItemSpy).toHaveBeenCalled(); + expect(getItemSpy).toHaveBeenLastCalledWith(StorageKeys.Settings); + }); + + it('should return default settings when nothing stored', async () => { + // ARRANGE + getItemSpy.mockReturnValue(null); + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual(DEFAULT_SETTINGS); + }); + + it('should error on failure to get settings', async () => { + // ARRANGE + getItemSpy.mockReturnValue('{invalid-json}}'); + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.isError).toBe(true); + }); +}); diff --git a/src/common/api/__tests__/useGetUser.test.ts b/src/common/api/__tests__/useGetUser.test.ts new file mode 100644 index 0000000..4ff0d75 --- /dev/null +++ b/src/common/api/__tests__/useGetUser.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { useGetUser } from 'common/api/useGetUser'; +import { renderHook, waitFor } from 'test/test-utils'; + +describe('useGetUser', () => { + it('should get user', async () => { + // ARRANGE + const { result } = renderHook(() => useGetUser({ userId: 1 })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.id).toEqual(1); + }); +}); diff --git a/src/common/api/__tests__/useGetUserTokens.test.ts b/src/common/api/__tests__/useGetUserTokens.test.ts new file mode 100644 index 0000000..e696647 --- /dev/null +++ b/src/common/api/__tests__/useGetUserTokens.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import dayjs from 'dayjs'; + +import { renderHook, waitFor } from 'test/test-utils'; +import WithQueryClientProvider from 'test/wrappers/WithQueryClientProvider'; +import storage from 'common/utils/storage'; +import { userTokensFixture } from '__fixtures__/tokens'; + +import { UserTokens, useGetUserTokens } from 'common/api/useGetUserTokens'; + +describe('useGetTokens', () => { + const getItemSpy = vi.spyOn(storage, 'getItem'); + + beforeEach(() => { + const token: UserTokens = { + ...userTokensFixture, + expires_at: dayjs().add(1, 'hours').toISOString(), + }; + getItemSpy.mockReturnValue(JSON.stringify(token)); + }); + + it('should get user tokens', async () => { + // ARRANGE + const { result } = renderHook(() => useGetUserTokens()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.id_token).toBe('id-token'); + }); + + it('should error if token is expired', async () => { + // ARRANGE + const token: UserTokens = { + ...userTokensFixture, + expires_at: dayjs('2024-01-01').toISOString(), + }; + getItemSpy.mockReturnValue(JSON.stringify(token)); + // use a specific wrapper to avoid test side effects from "AuthProvider" + const { result } = renderHook(() => useGetUserTokens(), { wrapper: WithQueryClientProvider }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.error).toBeInstanceOf(Error); + }); + + it('should error if token is not found', async () => { + // ARRANGE + getItemSpy.mockReturnValue(null); + // use a specific wrapper to avoid test side effects from "AuthProvider" + const { result } = renderHook(() => useGetUserTokens(), { wrapper: WithQueryClientProvider }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/src/common/api/__tests__/useSetSettings.test.ts b/src/common/api/__tests__/useSetSettings.test.ts new file mode 100644 index 0000000..5733517 --- /dev/null +++ b/src/common/api/__tests__/useSetSettings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import storage from 'common/utils/storage'; +import { Settings } from 'common/api/useGetSettings'; + +import { useSetSettings } from 'common/api/useSetSettings'; + +describe('useSetSettings', () => { + const setItemSpy = vi.spyOn(storage, 'setItem'); + + it('should update settings', async () => { + // ARRANGE + let isSuccess = false; + const updatedSettings: Settings = { theme: 'dark' }; + const { result } = renderHook(() => useSetSettings()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate(updatedSettings, { + onSuccess: () => { + isSuccess = true; + }, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(setItemSpy).toHaveBeenCalled(); + expect(isSuccess).toBe(true); + }); + + it('should error on failure to set settings', async () => { + // ARRANGE + setItemSpy.mockImplementation(() => { + throw new Error('test'); + }); + const updatedSettings: Settings = { theme: 'light' }; + const { result } = renderHook(() => useSetSettings()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate(updatedSettings); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(setItemSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/common/api/useGetCurrentUser.ts b/src/common/api/useGetCurrentUser.ts new file mode 100644 index 0000000..515f646 --- /dev/null +++ b/src/common/api/useGetCurrentUser.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; + +import storage from 'common/utils/storage'; +import { User } from './useGetUser'; +import { QueryKeys, StorageKeys } from 'common/utils/constants'; + +/** + * An API hook which fetches the currently authenticated `User`. + * @returns Returns a `UseQueryResult` with `User` data. + */ +export const useGetCurrentUser = () => { + const getCurrentUser = (): Promise => { + return new Promise((resolve, reject) => { + try { + const storedUser = storage.getItem(StorageKeys.User); + if (storedUser) { + const user = JSON.parse(storedUser) as unknown as User; + return resolve(user); + } + return reject(new Error('Not found')); + } catch (err) { + return reject(err); + } + }); + }; + + return useQuery({ + queryKey: [QueryKeys.Users, 'current'], + queryFn: getCurrentUser, + }); +}; diff --git a/src/common/api/useGetSettings.ts b/src/common/api/useGetSettings.ts new file mode 100644 index 0000000..98c7249 --- /dev/null +++ b/src/common/api/useGetSettings.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; + +import { DEFAULT_SETTINGS, QueryKeys, StorageKeys } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * The `Settings` are user preferences. + * @param theme - The application theme. + * @param [updatedAt] - Optional. The timestamp the settings were last updated. + */ +export interface Settings { + theme: 'light' | 'dark'; + updatedAt?: string; +} + +/** + * An API hook which fetches the user `Settings`. + * @returns Returns a `UseQueryResult` with `Settings` data. + */ +export const useGetSettings = () => { + const getSettings = (): Promise => { + return new Promise((resolve, reject) => { + try { + const storedSettings = JSON.parse(storage.getItem(StorageKeys.Settings) || '{}'); + const settings = { + ...DEFAULT_SETTINGS, + ...storedSettings, + }; + return resolve(settings); + } catch (err) { + return reject(err); + } + }); + }; + + return useQuery({ + queryKey: [QueryKeys.Settings], + queryFn: getSettings, + }); +}; diff --git a/src/common/api/useGetUser.ts b/src/common/api/useGetUser.ts new file mode 100644 index 0000000..45c2743 --- /dev/null +++ b/src/common/api/useGetUser.ts @@ -0,0 +1,73 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; + +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import { QueryKeys } from 'common/utils/constants'; + +/** + * The `Address` type. + */ +export type Address = { + street: string; + suite: string; + city: string; + zipcode: string; + geo: { + lat: string; + lng: string; + }; +}; + +/** + * The `Company` type. + */ +export type Company = { + name: string; + catchPhrase: string; + bs: string; +}; + +/** + * The `User` type. + */ +export type User = { + id: number; + name: string; + username: string; + email: string; + phone: string; + website: string; + address: Address; + company: Company; +}; + +/** + * The request properties for `useGetUser`. + * @param {number} userId - A `User` identifier. + */ +interface UseGetUserProps { + userId: number; +} + +/** + * An API hook which fetches a `User` by the identifier. + * @param {UseGetUserProps} props - The hook properties. + * @returns Returns a `UseQueryResult` with `User` data. + */ +export const useGetUser = ({ userId }: UseGetUserProps): UseQueryResult => { + const axios = useAxios(); + const config = useConfig(); + + const getUser = async (): Promise => { + const response = await axios.request({ + url: `${config.VITE_BASE_URL_API}/users/${userId}`, + }); + return response.data; + }; + + return useQuery({ + queryKey: [QueryKeys.Users, userId], + queryFn: () => getUser(), + enabled: !!userId, + }); +}; diff --git a/src/common/api/useGetUserTokens.ts b/src/common/api/useGetUserTokens.ts new file mode 100644 index 0000000..8e322ec --- /dev/null +++ b/src/common/api/useGetUserTokens.ts @@ -0,0 +1,62 @@ +import { UseQueryOptions, UseQueryResult, useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; + +import { QueryKeys, StorageKeys } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * A `UserTokens` object contains OAuth access, id, and refresh tokens + * and related metadata. + */ +export interface UserTokens { + access_token: string; + id_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + expires_at: string; +} + +/** + * An API hook which fetches OAuth tokens from the application IdP.. + * @param [options] - Optional. A UseQueryOptions object to supply additional + * configuration to `useQuery`. + * @returns {UserTokens} Returns a `UseQueryResult` with `UserTokens` data. + */ +export const useGetUserTokens = ( + options?: Partial>, +): UseQueryResult => { + /** + * Fetch `UserTokens` from the Identity Provider (IdP). + * @returns The `UserTokens` if successful. + */ + const getUserTokens = async (): Promise => { + // REPLACE: fetch tokens from your IdP + return new Promise((resolve, reject) => { + const storedTokens = storage.getItem(StorageKeys.UserTokens); + + if (storedTokens) { + // tokens found + const tokens = JSON.parse(storedTokens) as unknown as UserTokens; + const now = dayjs(); + if (now.isBefore(tokens.expires_at)) { + // tokens not expired + return resolve(tokens); + } else { + // tokens expired + return reject(new Error('Tokens expired.')); + } + } + + // tokens not found + return reject(new Error('Tokens not found.')); + }); + }; + + return useQuery({ + queryKey: [QueryKeys.UserTokens], + queryFn: () => getUserTokens(), + retry: 0, + ...options, + }); +}; diff --git a/src/common/api/useSetSettings.ts b/src/common/api/useSetSettings.ts new file mode 100644 index 0000000..0d5262d --- /dev/null +++ b/src/common/api/useSetSettings.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import storage from 'common/utils/storage'; +import { DEFAULT_SETTINGS, QueryKeys, StorageKeys } from 'common/utils/constants'; +import { Settings } from './useGetSettings'; + +/** + * An API hook which mutates the user `Settings`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to save the user `Settings`. + * + * The supplied `Settings` are merged with existing stored values so a partial + * `Settings` object may be supplied. + * @returns Returns a `UseMutationResult`. + * @see {@link Settings} + */ +export const useSetSettings = () => { + const queryClient = useQueryClient(); + + /** + * Saves the user `Settings`. Creates a merged object using the default settings, the + * currently stored settings, and the supplied changes. + * @param settings - A partical `Settings` object containing the attributes to be saved. + * @returns The updated `Settings` object. + */ + const setSettings = async (settings: Partial): Promise => { + return new Promise((resolve, reject) => { + try { + // create or update user settings + const storedSettings = JSON.parse(storage.getItem(StorageKeys.Settings) || '{}'); + const updatedSettings = { + ...DEFAULT_SETTINGS, + ...storedSettings, + ...settings, + updatedAt: new Date().toISOString(), + }; + storage.setItem(StorageKeys.Settings, JSON.stringify(updatedSettings)); + + return resolve(updatedSettings); + } catch (err) { + return reject(err); + } + }); + }; + + return useMutation({ + mutationFn: setSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.Settings] }); + }, + }); +}; diff --git a/src/common/components/Badge/Badge.tsx b/src/common/components/Badge/Badge.tsx new file mode 100644 index 0000000..022a400 --- /dev/null +++ b/src/common/components/Badge/Badge.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +interface BadgeProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +const Badge = ({ children, className, testId = 'badge' }: BadgeProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default Badge; diff --git a/src/common/components/Badge/__tests__/Badge.test.tsx b/src/common/components/Badge/__tests__/Badge.test.tsx new file mode 100644 index 0000000..64bef26 --- /dev/null +++ b/src/common/components/Badge/__tests__/Badge.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import Badge from '../Badge'; + +describe('Badge', () => { + it('should render successfully', async () => { + // ARRANGE + render(0); + await screen.findAllByTestId('badge'); + + // ASSERT + expect(screen.getByTestId('badge')).toBeDefined(); + expect(screen.getByTestId('badge').textContent).toEqual('0'); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(0); + await screen.findAllByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(0); + await screen.findAllByTestId('badge'); + + // ASSERT + expect(screen.getByTestId('badge').classList).toContain('custom-className'); + }); +}); diff --git a/src/common/components/Button/Button.tsx b/src/common/components/Button/Button.tsx new file mode 100644 index 0000000..ffe0354 --- /dev/null +++ b/src/common/components/Button/Button.tsx @@ -0,0 +1,20 @@ +import { Button as CommonButton, ButtonProps } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * The `Button` React component wraps the `Button` component from `@leanstacks/react-common` + * to refine the base CSS classes. + * @param {ButtonProps} props - Component properties, `ButtonProps`. + * @returns {JSX.Element} JSX + * @see {@link ButtonProps} + */ +const Button = ({ className, ...props }: ButtonProps): JSX.Element => { + return ( + + ); +}; + +export default Button; diff --git a/src/common/components/Button/LanguageToggle.tsx b/src/common/components/Button/LanguageToggle.tsx new file mode 100644 index 0000000..3c7ee37 --- /dev/null +++ b/src/common/components/Button/LanguageToggle.tsx @@ -0,0 +1,68 @@ +import { ButtonVariant, PropsWithClassName } from '@leanstacks/react-common'; +import { useTranslation } from 'react-i18next'; + +import { StorageKeys } from 'common/utils/constants'; +import storage from 'common/utils/storage'; +import Dropdown from 'common/components/Dropdown/Dropdown'; +import FAIcon from 'common/components/Icon/FAIcon'; +import DropdownContent from 'common/components/Dropdown/DropdownContent'; +import DropdownItem from 'common/components/Dropdown/DropdownItem'; +import Button from './Button'; + +/** + * Properties for the `LanguageToggle` component. + * @see {@link PropsWithClassName} + */ +interface LanguageToggleProps extends PropsWithClassName {} + +/** + * The `LanguageToggle` component renders a `Dropdown` which allows users + * to select the language in which they wish to view the application. + * @param {LanguageToggleProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const LanguageToggle = ({ className }: LanguageToggleProps): JSX.Element => { + const { i18n } = useTranslation(); + + /** + * Set the application-wide langague code used for i18n. + * @param {string} lng - A langage code, e.g. `en` or `es`. + */ + const setLanguage = (lng: string) => { + storage.setItem(StorageKeys.Language, lng); + i18n.changeLanguage(lng); + }; + + return ( + + + + } + content={ + + setLanguage('en')} testId="dropdown-item-en"> + + + setLanguage('fr')} testId="dropdown-item-fr"> + + + setLanguage('es')} testId="dropdown-item-es"> + + + + } + className={className} + testId="dropdown-language" + /> + ); +}; + +export default LanguageToggle; diff --git a/src/common/components/Button/ThemeToggle.tsx b/src/common/components/Button/ThemeToggle.tsx new file mode 100644 index 0000000..0ba1d33 --- /dev/null +++ b/src/common/components/Button/ThemeToggle.tsx @@ -0,0 +1,52 @@ +import { ButtonVariant, PropsWithClassName } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import { useSetSettings } from 'common/api/useSetSettings'; +import { useSettings } from 'common/hooks/useSettings'; +import Button from './Button'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `ThemeToggle` component. + */ +interface ThemeToggleProps extends PropsWithClassName {} + +/** + * The `ThemeToggle` React component renders a `Button` which allows users + * to toggle between light and dark themes. + * @param {ThemeToggleProps} [props] - Component properties, `ThemeProps`. + * @returns {JSX.Element} JSX + * @see {@link ThemeToggleProps} + */ +const ThemeToggle = ({ className }: ThemeToggleProps): JSX.Element => { + const settings = useSettings(); + const { mutate: setSettings } = useSetSettings(); + + return ( + <> + {settings?.theme === 'light' ? ( + + ) : ( + + )} + + ); +}; + +export default ThemeToggle; diff --git a/src/common/components/Button/__tests__/Button.test.tsx b/src/common/components/Button/__tests__/Button.test.tsx new file mode 100644 index 0000000..491dce5 --- /dev/null +++ b/src/common/components/Button/__tests__/Button.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import Button from '../Button'; + +describe('Button', () => { + it('should render successfully', async () => { + // ARRANGE + render( + {isMenuOpen && setIsMenuOpen(false)} />} + + ); +}; + +export default MenuButton; diff --git a/src/common/components/Menu/MenuCloseButton.tsx b/src/common/components/Menu/MenuCloseButton.tsx new file mode 100644 index 0000000..c2d8a0a --- /dev/null +++ b/src/common/components/Menu/MenuCloseButton.tsx @@ -0,0 +1,42 @@ +import { ButtonVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import Button from 'common/components/Button/Button'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `MenuCloseButton` component. + * @param {function} [close] - Optional. If provided, invoked when the user + * clicks the close button. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface MenuCloseButtonProps extends PropsWithClassName, PropsWithTestId { + close?: () => void; +} + +/** + * The `MenuCloseButton` component renders a `Button` which closes a Menu by + * invoking the supplied `close` function. + * @param {MenuCloseButtonProps} props - Component properties, `MenuCloseButtonProps`. + * @returns {JSX.Element} JSX + */ +const MenuCloseButton = ({ + className, + close, + testId = 'menu-close-button', +}: MenuCloseButtonProps): JSX.Element => { + return ( + + ); +}; + +export default MenuCloseButton; diff --git a/src/common/components/Menu/MenuNavLink.tsx b/src/common/components/Menu/MenuNavLink.tsx new file mode 100644 index 0000000..45b19ff --- /dev/null +++ b/src/common/components/Menu/MenuNavLink.tsx @@ -0,0 +1,81 @@ +import { NavLink, NavLinkProps } from 'react-router-dom'; +import { PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import FAIcon, { FAIconProps } from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `MenuNavLink` component. + * @param {string} [iconClassName] - Optional. Additional CSS classes to apply + * to the `Icon`. + * @param {boolean} [styleActive] - Optional. Indicates if active `NavLink` styles + * should be applied. Default: `false`. + * @see {@link NavLinkProps} + * @see {@link FAIconProps} + * @see {@link PropsWithTestId} + */ +interface MenuNavLinkProps + extends NavLinkProps, + Partial>, + PropsWithTestId { + iconClassName?: string; + styleActive?: boolean; +} + +/** + * `NavLinkRenderProps` from `react-router-dom`. Not exported. + */ +type NavLinkRenderProps = { + isActive: boolean; + isPending: boolean; + isTransitioning: boolean; +}; + +/** + * The `MenuNavLink` React component renders a `NavLink` with optional `Icon`. + * For use within a `Menu` implementation, e.g. `SideMenu`. + * @param {MenuNavLinkProps} props - Component properties, `MenuNavLinkProps`. + * @returns {JSX.Element} JSX + */ +const MenuNavLink = ({ + children, + className, + icon, + iconClassName, + styleActive = false, + testId = 'menu-navlink', + ...props +}: MenuNavLinkProps): JSX.Element => { + /** + * Returns the list of class names to apply to the `NavLink`. + * @param props - A `NavLinkRenderProps` object indicating the current state of the `NavLink`. + * @returns {string | undefined} A string or `undefined` space separated list of class names. + */ + const getClassName = (props: NavLinkRenderProps): string => { + const classNameFromProps = typeof className === 'function' ? className(props) : className; + + return classNames( + 'flex items-center rounded-md px-2 py-1.5 text-sm hover:bg-neutral-500/25', + { 'bg-neutral-500/10 hover:!bg-neutral-500/10': styleActive && props.isActive }, + classNameFromProps, + ); + }; + + return ( + + <> + {icon && ( + + )} + {children} + + + ); +}; + +export default MenuNavLink; diff --git a/src/common/components/Menu/MenuSeparator.tsx b/src/common/components/Menu/MenuSeparator.tsx new file mode 100644 index 0000000..d55b735 --- /dev/null +++ b/src/common/components/Menu/MenuSeparator.tsx @@ -0,0 +1,29 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `MenuSeparator` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface MenuSeparatorProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `MenuSeparator` component renders a separator to deliniate the boundary + * between sections of a `Menu`. + * @param {MenuSeparatorProps} props - Component properties, `MenuSeparatorProps`. + * @returns {JSX.Element} JSX + */ +const MenuSeparator = ({ + className, + testId = 'menu-separator', +}: MenuSeparatorProps): JSX.Element => { + return ( +
+ ); +}; + +export default MenuSeparator; diff --git a/src/common/components/Menu/SideMenu/SideMenu.tsx b/src/common/components/Menu/SideMenu/SideMenu.tsx new file mode 100644 index 0000000..70264ef --- /dev/null +++ b/src/common/components/Menu/SideMenu/SideMenu.tsx @@ -0,0 +1,89 @@ +import { PropsWithChildren, ReactNode } from 'react'; +import classNames from 'classnames'; +import { animated, useSpring } from '@react-spring/web'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +import { MenuProps } from '../Menu'; +import SideMenuHeader from './SideMenuHeader'; + +/** + * Properties for the `SideMenu` component. + * @param {ReactNode} [headerContent] - Optional. A `ReactNode` containing the content + * of the menu header. + * @param {'left'|'right'} [string] - Optional. The side of the viewport where the + * menu should display when shown. Default: `left`. + * @see {@link MenuProps} + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface SideMenuProps + extends MenuProps, + PropsWithChildren, + PropsWithClassName, + PropsWithTestId { + headerContent?: ReactNode; + side?: 'left' | 'right'; +} + +/** + * The `SideMenu` component renders menu which slides out from the side + * of the viewport when rendered. The menu content is provided in the + * `children` attribute. The optional menu header is provided in the + * `headerContent` attribute. + * @param {SideMenuProps} props - Component properties, `SideMenuProps`. + * @returns {JSX.Element} JSX + */ +const SideMenu = ({ + children, + className, + close, + headerContent, + side = 'left', + testId = 'menu-side', +}: SideMenuProps): JSX.Element => { + const isLeft = side === 'left'; + const isRight = side === 'right'; + + const springs = useSpring({ + from: { x: isLeft ? -320 : 320 }, + to: { x: 0 }, + }); + + return ( +
+
close?.()} data-testid={`${testId}-backdrop`}>
+ + + {headerContent} + +
+
close?.()}> + {children} +
+
+
+
+ ); +}; + +export default SideMenu; diff --git a/src/common/components/Menu/SideMenu/SideMenuHeader.tsx b/src/common/components/Menu/SideMenu/SideMenuHeader.tsx new file mode 100644 index 0000000..b21167a --- /dev/null +++ b/src/common/components/Menu/SideMenu/SideMenuHeader.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import MenuCloseButton from '../MenuCloseButton'; + +/** + * Properties for the `SideMenuHeader` component. + * @param {function} [close] - Optional. If provided, invoked when the user + * clicks the close button. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface SideMenuHeaderProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId { + close?: () => void; +} + +/** + * The `SideMenuHeader` React component renders a block which appears at the + * top of a `SideMenu`. + * + * The `children` content are rendered on the left side of the header. A + * `MenuCloseButton` is rendered on the right side. + * @param {SideMenuHeaderProps} props - Component properties, `SideMenuHeaderProps`. + * @returns {JSX.Element} JSX + */ +const SideMenuHeader = ({ + children, + className, + close, + testId = 'menu-header', +}: SideMenuHeaderProps): JSX.Element => { + return ( +
+
{children}
+ +
+ ); +}; + +export default SideMenuHeader; diff --git a/src/common/components/Menu/SideMenu/__tests__/SideMenu.test.tsx b/src/common/components/Menu/SideMenu/__tests__/SideMenu.test.tsx new file mode 100644 index 0000000..347c45f --- /dev/null +++ b/src/common/components/Menu/SideMenu/__tests__/SideMenu.test.tsx @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; + +import SideMenu from '../SideMenu'; + +describe('SideMenu', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-side'); + + // ASSERT + expect(screen.getByTestId('menu-side')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testid'); + + // ASSERT + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-side'); + + // ASSERT + expect(screen.getByTestId('menu-side').classList).toContain('custom-class'); + }); + + it('should render headerContent', async () => { + // ARRANGE + render( + HeaderContent
} />, + ); + await screen.findByTestId('custom-header-content'); + + // ASSERT + expect(screen.getByTestId('custom-header-content')).toBeDefined(); + expect(screen.getByText('HeaderContent')).toBeDefined(); + }); + + it('should render menu content', async () => { + // ARRANGE + render( + +
MenuContent
+
, + ); + await screen.findByTestId('custom-menu-content'); + + // ASSERT + expect(screen.getByTestId('custom-menu-content')).toBeDefined(); + expect(screen.getByText('MenuContent')).toBeDefined(); + }); + + it('should render left side by default', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-side'); + + // ASSERT + expect(screen.getByTestId('menu-side')).toBeDefined(); + expect(screen.getByTestId('menu-side').classList).toContain('justify-start'); + expect(screen.getByTestId('menu-side-content').classList).toContain('rounded-r-lg'); + expect(screen.getByTestId('menu-side-content').classList).toContain('border-r'); + }); + + it('should render left side', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-side'); + + // ASSERT + expect(screen.getByTestId('menu-side')).toBeDefined(); + expect(screen.getByTestId('menu-side').classList).toContain('justify-start'); + expect(screen.getByTestId('menu-side-content').classList).toContain('rounded-r-lg'); + expect(screen.getByTestId('menu-side-content').classList).toContain('border-r'); + }); + + it('should render right side', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-side'); + + // ASSERT + expect(screen.getByTestId('menu-side')).toBeDefined(); + expect(screen.getByTestId('menu-side').classList).toContain('justify-end'); + expect(screen.getByTestId('menu-side-content').classList).toContain('rounded-l-lg'); + expect(screen.getByTestId('menu-side-content').classList).toContain('border-l'); + }); + + it('should call close function when backdrop clicked', async () => { + // ARRANGE + const mockClose = vi.fn(); + render(); + await screen.findByTestId('menu-side'); + + // ACT + await userEvent.click(screen.getByTestId('menu-side-backdrop')); + + // ASSERT + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it('should call close function when body clicked', async () => { + // ARRANGE + const mockClose = vi.fn(); + render(Body); + await screen.findByText('Body'); + + // ACT + await userEvent.click(screen.getByText('Body')); + + // ASSERT + expect(mockClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/common/components/Menu/SideMenu/__tests__/SideMenuHeader.test.tsx b/src/common/components/Menu/SideMenu/__tests__/SideMenuHeader.test.tsx new file mode 100644 index 0000000..6a947ed --- /dev/null +++ b/src/common/components/Menu/SideMenu/__tests__/SideMenuHeader.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; + +import SideMenuHeader from '../SideMenuHeader'; + +describe('SideMenuHeader', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
Content
+
, + ); + await screen.findByTestId('menu-header'); + + // ASSERT + expect(screen.getByTestId('menu-header')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render( + +
Content
+
, + ); + await screen.findByTestId('custom-testid'); + + // ASSERT + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render( + +
Content
+
, + ); + await screen.findByTestId('menu-header'); + + // ASSERT + expect(screen.getByTestId('menu-header').classList).toContain('custom-class'); + }); + + it('should render header content', async () => { + // ARRANGE + render( + +
Content
+
, + ); + await screen.findByTestId('menu-header-content'); + + // ASSERT + expect(screen.getByTestId('menu-header-content')).toBeDefined(); + }); + + it('should call close function when close button clicked', async () => { + // ARRANGE + const mockClose = vi.fn(); + render( + +
Content
+
, + ); + await screen.findByTestId('menu-header'); + + // ACT + await userEvent.click(screen.getByTestId('menu-header-close-button')); + + // ASSERT + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when no close function and close button clicked', async () => { + // ARRANGE + render( + +
Content
+
, + ); + await screen.findByTestId('menu-header'); + + // ACT + await userEvent.click(screen.getByTestId('menu-header-close-button')); + + // ASSERT + expect(screen.getByTestId('menu-header-close-button')).toBeDefined(); + }); +}); diff --git a/src/common/components/Menu/__tests__/MenuButton.test.tsx b/src/common/components/Menu/__tests__/MenuButton.test.tsx new file mode 100644 index 0000000..e81aba1 --- /dev/null +++ b/src/common/components/Menu/__tests__/MenuButton.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; + +import SideMenu from '../SideMenu/SideMenu'; + +import MenuButton from '../MenuButton'; + +describe('MenuButton', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('button-menu'); + + // ASSERT + expect(screen.getByTestId('button-menu')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testid'); + + // ASSERT + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('button-menu'); + + // ASSERT + expect(screen.getByTestId('button-menu').classList).toContain('custom-class'); + }); + + it('should not render Menu until clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('button-menu'); + + // ASSERT + expect(screen.getByTestId('button-menu')).toBeDefined(); + expect(screen.queryByTestId('menu-side')).toBeNull(); + }); + + it('should not render Menu when clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('button-menu'); + + // ACT + await userEvent.click(screen.getByTestId('button-menu')); + + // ASSERT + expect(screen.getByTestId('menu-side')).toBeDefined(); + }); + + it('should not render Menu after close function called', async () => { + // ARRANGE + render(); + await screen.findByTestId('button-menu'); + + // ACT + await userEvent.click(screen.getByTestId('button-menu')); + // expect Menu to be rendered after clicking MenuButton + expect(screen.getByTestId('menu-side')).toBeDefined(); + // click the MenuCloseButton on the rendered Menu + await userEvent.click(screen.getByTestId('menu-side-header-close-button')); + + // ASSERT + // expect Menu NOT to be rendered + expect(screen.queryByTestId('menu-side')).toBeNull(); + }); +}); diff --git a/src/common/components/Menu/__tests__/MenuCloseButton.test.tsx b/src/common/components/Menu/__tests__/MenuCloseButton.test.tsx new file mode 100644 index 0000000..d6f42ee --- /dev/null +++ b/src/common/components/Menu/__tests__/MenuCloseButton.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; + +import MenuCloseButton from '../MenuCloseButton'; + +describe('MenuCloseButton', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-close-button'); + + // ASSERT + expect(screen.getByTestId('menu-close-button')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testid'); + + // ASSERT + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-close-button'); + + // ASSERT + expect(screen.getByTestId('menu-close-button').classList).toContain('custom-class'); + }); + + it('should call close function when clicked', async () => { + // ARRANGE + const mockClose = vi.fn(); + render(); + await screen.findByTestId('menu-close-button'); + + // ACT + await userEvent.click(screen.getByTestId('menu-close-button')); + + // ASSERT + expect(mockClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/common/components/Menu/__tests__/MenuNavLink.test.tsx b/src/common/components/Menu/__tests__/MenuNavLink.test.tsx new file mode 100644 index 0000000..4c0fa49 --- /dev/null +++ b/src/common/components/Menu/__tests__/MenuNavLink.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import MenuNavLink from '../MenuNavLink'; + +describe('MenuNavLink', () => { + it('should render successfully', async () => { + // ARRANGE + render(LinkText); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.getByTestId('menu-navlink')).toBeDefined(); + expect(screen.getByTestId('menu-navlink').textContent).toBe('LinkText'); + expect(screen.getByTestId('menu-navlink').getAttribute('href')).toBe('/'); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-menu-navlink'); + + // ASSERT + expect(screen.getByTestId('custom-menu-navlink')).toBeDefined(); + }); + + it('should use custom classes when provided', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.getByTestId('menu-navlink').classList).toContain('custom-class'); + }); + + it('should render Icon when iconName provided', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-navlink-icon'); + + // ASSERT + expect(screen.getByTestId('menu-navlink-icon')).toBeDefined(); + expect(screen.getByTestId('menu-navlink-icon')).toHaveAttribute('data-icon', 'bars'); + }); + + it('should not render Icon when iconName omitted', async () => { + // ARRANGE + render(LinkText); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.queryByTestId('menu-navlink-icon')).toBeNull(); + }); + + it('should use custom icon classes when provided', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-navlink-icon'); + + // ASSERT + expect(screen.getByTestId('menu-navlink-icon')).toBeDefined(); + expect(screen.getByTestId('menu-navlink-icon').classList).toContain('custom-class'); + }); + + it('should use active styles', async () => { + // ARRANGE + render( + + LinkText + , + ); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.getByTestId('menu-navlink')).toBeDefined(); + expect(screen.getByTestId('menu-navlink').classList).toContain('bg-neutral-500/10'); + }); + + it('should only use active styles on active route', async () => { + // ARRANGE + render( + + LinkText + , + ); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.getByTestId('menu-navlink')).toBeDefined(); + expect(screen.getByTestId('menu-navlink').classList).not.toContain('bg-neutral-500/10'); + }); + + it('should use className function', async () => { + // ARRANGE + render( + [isActive ? 'some-active-class' : ''].join(' ')} + > + LinkText + , + ); + await screen.findByTestId('menu-navlink'); + + // ASSERT + expect(screen.getByTestId('menu-navlink')).toBeDefined(); + expect(screen.getByTestId('menu-navlink').classList).toContain('some-active-class'); + }); +}); diff --git a/src/common/components/Menu/__tests__/MenuSeparator.test.tsx b/src/common/components/Menu/__tests__/MenuSeparator.test.tsx new file mode 100644 index 0000000..e6a6b9f --- /dev/null +++ b/src/common/components/Menu/__tests__/MenuSeparator.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import MenuSeparator from '../MenuSeparator'; + +describe('MenuSeparator', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-separator'); + + // ASSERT + expect(screen.getByTestId('menu-separator')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testid'); + + // ASSERT + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('menu-separator'); + + // ASSERT + expect(screen.getByTestId('menu-separator').classList).toContain('custom-class'); + }); +}); diff --git a/src/common/components/Page/Page.tsx b/src/common/components/Page/Page.tsx new file mode 100644 index 0000000..818289f --- /dev/null +++ b/src/common/components/Page/Page.tsx @@ -0,0 +1,26 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; +import { PropsWithChildren } from 'react'; + +/** + * Properties for the `Page` React component. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface PageProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +/** + * The `Page` component renders a standardized styled page-level block. + * @param {PageProps} - Component properties. + * @returns {JSX.Element} JSX + */ +const Page = ({ children, className, testId = 'page' }: PageProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default Page; diff --git a/src/common/components/Page/__tests__/Page.test.tsx b/src/common/components/Page/__tests__/Page.test.tsx new file mode 100644 index 0000000..8ebfa11 --- /dev/null +++ b/src/common/components/Page/__tests__/Page.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import Page from '../Page'; + +describe('Page', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
+ , + ); + await screen.findByTestId('page'); + + // ASSERT + expect(screen.getByTestId('page')).toBeDefined(); + expect(screen.getByTestId('content')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render( + +
+ , + ); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render( + +
+ , + ); + await screen.findByTestId('page'); + + // ASSERT + expect(screen.getByTestId('page').classList).toContain('custom-className'); + }); +}); diff --git a/src/common/components/Router/PrivateOutlet.tsx b/src/common/components/Router/PrivateOutlet.tsx new file mode 100644 index 0000000..a94b909 --- /dev/null +++ b/src/common/components/Router/PrivateOutlet.tsx @@ -0,0 +1,24 @@ +import { useAuth } from 'common/hooks/useAuth'; +import { Navigate, Outlet } from 'react-router-dom'; + +/** + * The `PrivateOutlet` determines if the user is authenticated. It is typically + * used within the router configuration as the parent of hierarchy of routes + * which require authentication. + * + * If authenticated, the element specified by the route is rendered. + * + * If not authenticated, the application navigates to the sign in page. + * @returns {JSX.Element} JSX + */ +const PrivateOutlet = (): JSX.Element => { + const authContext = useAuth(); + + if (authContext.isAuthenticated) { + return ; + } else { + return ; + } +}; + +export default PrivateOutlet; diff --git a/src/common/components/Router/Router.tsx b/src/common/components/Router/Router.tsx new file mode 100644 index 0000000..ad1e089 --- /dev/null +++ b/src/common/components/Router/Router.tsx @@ -0,0 +1,130 @@ +import { Navigate, RouteObject, createBrowserRouter } from 'react-router-dom'; + +import StandardLayout from 'common/components/Layout/StandardLayout'; +import ErrorPage from 'pages/ErrorPage/ErrorPage'; +import PrivateOutlet from './PrivateOutlet'; +import LandingPage from 'pages/LandingPage/LandingPage'; +import SigninPage from 'pages/SigninPage/SigninPage'; +import SignoutPage from 'pages/SignoutPage/SignoutPage'; +import DashboardPage from 'pages/DashboardPage/DashboardPage'; +import SettingsPage from 'pages/SettingsPage/SettingsPage'; +import AppearanceSettings from 'pages/SettingsPage/components/AppearanceSettings'; +import ComponentsPage from 'pages/ComponentsPage/ComponentsPage'; +import AvatarComponents from 'pages/ComponentsPage/components/AvatarComponents'; +import TextComponents from 'pages/ComponentsPage/components/TextComponents'; +import ButtonComponents from 'pages/ComponentsPage/components/ButtonComponents'; +import BadgeComponents from 'pages/ComponentsPage/components/BadgeComponents'; +import CardComponents from 'pages/ComponentsPage/components/CardComponents'; +import UsersPage from 'pages/UsersPage/UsersPage'; +import UserDetailLayout from 'pages/UsersPage/components/UserDetailLayout'; +import UserDetail from 'pages/UsersPage/components/UserDetail'; +import UserDetailEmpty from 'pages/UsersPage/components/UserDetailEmpty'; +import UserTaskList from 'pages/UsersPage/components/UserTaskList'; +import TaskDetail from 'pages/UsersPage/Tasks/components/TaskDetail'; + +/** + * The React Router configuration. An array of `RouteObject`. + * @see {@link RouteObject} + */ +export const routes: RouteObject[] = [ + { + path: '/', + element: , + errorElement: , + children: [ + { + index: true, + element: , + }, + { + path: 'auth/signin', + element: , + }, + { + path: 'auth/signout', + element: , + }, + { + path: 'app', + element: , + children: [ + { index: true, element: }, + { + path: 'settings', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'appearance', + element: , + }, + ], + }, + { + path: 'components', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'avatar', + element: , + }, + { + path: 'badge', + element: , + }, + { + path: 'button', + element: , + }, + { + path: 'card', + element: , + }, + { + path: 'text', + element: , + }, + ], + }, + { + path: 'users', + element: , + children: [ + { + index: true, + element: , + }, + { + path: ':userId', + element: , + children: [ + { index: true, element: }, + { + path: 'tasks', + element: , + }, + { + path: 'tasks/:taskId', + element: , + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +/** + * The application `Router`. A React Router instance. + */ +export const router = createBrowserRouter(routes); diff --git a/src/common/components/Router/__tests__/PrivateOutlet.test.tsx b/src/common/components/Router/__tests__/PrivateOutlet.test.tsx new file mode 100644 index 0000000..ee6c75f --- /dev/null +++ b/src/common/components/Router/__tests__/PrivateOutlet.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { render, screen } from 'test/test-utils'; +import * as UseAuth from 'common/hooks/useAuth'; + +import PrivateOutlet from '../PrivateOutlet'; + +describe('PrivateOutlet', () => { + const useAuthSpy = vi.spyOn(UseAuth, 'useAuth'); + + beforeEach(() => { + useAuthSpy.mockReturnValue({ isAuthenticated: true }); + }); + + it('should render successfully', async () => { + // ARRANGE + render( + + } /> + } /> + }> + } /> + + , + ); + await screen.findByTestId('authenticated'); + + // ASSERT + expect(screen.getByTestId('authenticated')).toBeDefined(); + }); + + it('should render private route when authenticated', async () => { + // ARRANGE + render( + + } /> + } /> + }> + } /> + + , + ); + await screen.findByTestId('authenticated'); + + // ASSERT + expect(screen.getByTestId('authenticated')).toBeDefined(); + }); + + it('should redirect to signin route when not authenticated', async () => { + // ARRANGE + useAuthSpy.mockReturnValueOnce({ isAuthenticated: false }); + render( + + } /> + } /> + }> + } /> + + , + ); + await screen.findByTestId('not-authenticated'); + + // ASSERT + expect(screen.getByTestId('not-authenticated')).toBeDefined(); + }); +}); diff --git a/src/common/components/Table/Table.tsx b/src/common/components/Table/Table.tsx new file mode 100644 index 0000000..2908e04 --- /dev/null +++ b/src/common/components/Table/Table.tsx @@ -0,0 +1,73 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import classNames from 'classnames'; + +/** + * Properties for the `Table` React component. + * @template TData - The type of the table data object. + * @param {ColumnDef[]} columns - An array of `ColumnDef`, column definition, objects. + * @param {TData[]} data - An array of data objects, of type `TData`, + * which are used to populate the rows of the table. + */ +interface TableProps + extends PropsWithClassName, + PropsWithTestId { + columns: ColumnDef[]; + data: TData[]; +} + +/** + * The `Table` component renders a `table` element using the column definitions + * and data supplied in the properties. + * + * Uses TanStack Table. + * @template TData - The type of the table data object. + * @param {TableProps} props - Component properteis. + * @returns {JSX.Element} JSX + * @see {@link https://tanstack.com/table/latest TanStack Table} + */ +const Table = ({ + className, + columns, + data, + testId = 'table', +}: TableProps): JSX.Element => { + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); +}; + +export default Table; diff --git a/src/common/components/Table/__tests__/Table.test.tsx b/src/common/components/Table/__tests__/Table.test.tsx new file mode 100644 index 0000000..bebb078 --- /dev/null +++ b/src/common/components/Table/__tests__/Table.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { createColumnHelper } from '@tanstack/react-table'; +import { render, screen } from 'test/test-utils'; +import Table from '../Table'; + +describe('Table', () => { + type Foo = { + bar: string; + baz: number; + max: number; + }; + const data: Foo[] = [ + { + bar: 'one', + baz: 1, + max: 20, + }, + { + bar: 'two', + baz: 2, + max: 40, + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.group({ + id: 'grouped', + header: 'Grouped', + columns: [ + columnHelper.accessor('bar', { cell: (info) => info.renderValue(), header: 'Bar' }), + columnHelper.accessor('baz', { cell: (info) => info.renderValue(), header: 'Baz' }), + ], + }), + columnHelper.accessor('max', { cell: (info) => info.renderValue(), header: 'Max' }), + ]; + + it('should render successfully', async () => { + // ARRANGE + render( data={data} columns={columns} />); + await screen.findByTestId('table'); + + // ASSERT + expect(screen.getByTestId('table')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render( data={data} columns={columns} testId="custom-testId" />); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render( data={data} columns={columns} className="custom-className" />); + await screen.findByTestId('table'); + + // ASSERT + expect(screen.getByTestId('table').classList).toContain('custom-className'); + }); +}); diff --git a/src/common/components/Tabs/Tab.tsx b/src/common/components/Tabs/Tab.tsx new file mode 100644 index 0000000..e91d05c --- /dev/null +++ b/src/common/components/Tabs/Tab.tsx @@ -0,0 +1,64 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `Tab` React component. + * @param {boolean} [isActive=false] - Optional. Indicates if this tab is the + * active tab. + * @param {string} label - The tab label. + * @param {function} [onClick] - Optional. A function to be invoked when the + * tab is clicked. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface TabProps extends PropsWithClassName, PropsWithTestId { + isActive?: boolean; + label: string; + onClick?: () => void; +} + +/** + * The `Tab` component renders a single tab for the display of tabbed content. + * + * A `Tab` is typically not rendered outside of the `Tabs` component, but rather + * the `TabProps` are supplied to the `Tabs` component so that the `Tabs` component + * may render one or more `Tab` components. + * + * @param {TabProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const Tab = ({ + className, + isActive = false, + label, + onClick, + testId = 'tab', +}: TabProps): JSX.Element => { + /** + * Handle tab click events. + */ + const handleClick = () => { + onClick?.(); + }; + + return ( +
+ {label} +
+ ); +}; + +export default Tab; diff --git a/src/common/components/Tabs/TabContent.tsx b/src/common/components/Tabs/TabContent.tsx new file mode 100644 index 0000000..63e5758 --- /dev/null +++ b/src/common/components/Tabs/TabContent.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +/** + * Properties for the `TabContent` React component. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface TabContentProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +/** + * The `TabContent` component renders a single block of tabbed content. + * + * A `TabContent` is typically not rendered outside of the `Tabs` component, but + * rather the `TabContentProps` are supplied to the `Tabs` component. The `Tabs` + * component renders one or more `TabContent` components. + */ +const TabContent = ({ + children, + className, + testId = 'tab-content', +}: TabContentProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default TabContent; diff --git a/src/common/components/Tabs/Tabs.tsx b/src/common/components/Tabs/Tabs.tsx new file mode 100644 index 0000000..a5068a9 --- /dev/null +++ b/src/common/components/Tabs/Tabs.tsx @@ -0,0 +1,96 @@ +import { PropsWithTestId } from '@leanstacks/react-common'; +import { useSearchParams } from 'react-router-dom'; +import classNames from 'classnames'; + +import { toNumberBetween } from 'common/utils/numbers'; +import { SearchParam } from 'common/utils/constants'; +import Tab, { TabProps } from './Tab'; +import TabContent, { TabContentProps } from './TabContent'; + +/** + * The `TabVariant` describes variations of display behavior for `Tabs`. + */ +type TabVariant = 'fullWidth' | 'standard'; + +/** + * Properties for the `Tabs` React component. + * @param {TabProps[]} tabs - An array of `Tab` component properties. + * @param {TabConent[]} tabContents - An array of `TabContent` component properties. + * @param {TabVariant} [variant='standard'] - Optional. The tab display behavior. + * Default: `standard`. + * @see {@link PropsWithTestId} + */ +interface TabsProps extends PropsWithTestId { + tabs: Omit[]; + tabContents: TabContentProps[]; + variant?: TabVariant; +} + +/** + * The `Tabs` component is a wrapper for rendering tabbed content. + * + * Supply one to many `TabProps` objects in the `tabs` property describing each + * `Tab` to render. Supply one to many `TabContentProps` objects in the `tabContents` property + * describing each `TabContent` to render. + * + * The number of `tabs` and `tabContents` items should be equal. The order of each array + * matters. The first item in the `tabs` array should correspond to content in the first + * item in the `tabContents` array and so on. + * + * *Example:* + * ``` + * }, { children: , className: 'my-6' }]} + * /> + * ``` + * @param {TabsProps} - Component properties + * @returns {JSX.Element} JSX + */ +const Tabs = ({ + tabs, + tabContents, + testId = 'tabs', + variant = 'standard', +}: TabsProps): JSX.Element => { + const [searchParams, setSearchParams] = useSearchParams(); + + // obtain activeTabIndex from query string + const activeTabIndex = toNumberBetween(searchParams.get(SearchParam.tab), 0, tabs.length - 1, 0); + + /** + * Set the active tab index. + * @param {number} index - A tab index. + */ + const setTab = (index: number = 0): void => { + const tabIndex = toNumberBetween(index, 0, tabs.length - 1, 0); + if (tabIndex !== activeTabIndex) { + searchParams.set(SearchParam.tab, tabIndex.toString()); + setSearchParams(searchParams); + } + }; + + return ( +
+
+ {tabs.map(({ className, ...tabProps }, index) => ( + setTab(index)} + key={index} + /> + ))} +
+
+ +
+
+ ); +}; + +export default Tabs; diff --git a/src/common/components/Tabs/__tests__/Tab.test.tsx b/src/common/components/Tabs/__tests__/Tab.test.tsx new file mode 100644 index 0000000..bcd18ce --- /dev/null +++ b/src/common/components/Tabs/__tests__/Tab.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; + +import Tab from '../Tab'; + +describe('Tab', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab').classList).toContain('custom-className'); + }); + + it('should render label', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab').textContent).toBe('Label'); + }); + + it('should render active state', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab').classList).toContain('border-b-blue-300'); + }); + + it('should call click handler', async () => { + // ARRANGE + const mockClickFn = vi.fn(); + render(); + await screen.findByTestId('tab'); + + // ACT + await userEvent.click(screen.getByTestId('tab')); + + // ASSERT + expect(mockClickFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/common/components/Tabs/__tests__/TabContent.test.tsx b/src/common/components/Tabs/__tests__/TabContent.test.tsx new file mode 100644 index 0000000..f516324 --- /dev/null +++ b/src/common/components/Tabs/__tests__/TabContent.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import TabContent from '../TabContent'; + +describe('TabContent', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab-content'); + + // ASSERT + expect(screen.getByTestId('tab-content')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab-content'); + + // ASSERT + expect(screen.getByTestId('tab-content').classList).toContain('custom-className'); + }); + + it('should render children', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('tab-content-children'); + + // ASSERT + expect(screen.getByTestId('tab-content-children')).toBeDefined(); + }); +}); diff --git a/src/common/components/Tabs/__tests__/Tabs.test.tsx b/src/common/components/Tabs/__tests__/Tabs.test.tsx new file mode 100644 index 0000000..e4d8b1c --- /dev/null +++ b/src/common/components/Tabs/__tests__/Tabs.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { TabProps } from '../Tab'; +import { TabContentProps } from '../TabContent'; + +import Tabs from '../Tabs'; + +describe('Tabs', () => { + const tabs: TabProps[] = [ + { label: 'One', testId: 'tab-one' }, + { label: 'Two', testId: 'tab-two' }, + ]; + const tabContents: TabContentProps[] = [ + { + children:
, + }, + { + children:
, + }, + ]; + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ASSERT + expect(screen.getByTestId('tabs')).toBeDefined(); + expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length); + expect(screen.getByTestId('tab-content-one')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should show tab content when tab is clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ACT + await userEvent.click(screen.getByTestId('tab-two')); + + // ASSERT + expect(screen.getByTestId('tab-content-two')).toBeDefined(); + }); + + it('should render full width variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ASSERT + expect(screen.getByTestId('tabs')).toBeDefined(); + expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length); + expect(screen.getByTestId('tab-content-one')).toBeDefined(); + expect(screen.getByTestId('tab-one').classList).toContain('flex-grow'); + }); +}); diff --git a/src/common/components/Text/CodeSnippet.tsx b/src/common/components/Text/CodeSnippet.tsx new file mode 100644 index 0000000..71bbafc --- /dev/null +++ b/src/common/components/Text/CodeSnippet.tsx @@ -0,0 +1,81 @@ +import { SandpackProvider, SandpackLayout, SandpackCodeEditor } from '@codesandbox/sandpack-react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { useSettings } from 'common/hooks/useSettings'; + +/** + * The GitHub light Sandpack theme. + * @see {@link https://sandpack.codesandbox.io/theme} + */ +const themeLight = { + colors: { + surface1: '#ffffff', + surface2: '#F3F3F3', + surface3: '#f5f5f5', + clickable: '#959da5', + base: '#24292e', + disabled: '#d1d4d8', + hover: '#24292e', + accent: '#24292e', + }, + syntax: { + keyword: '#d73a49', + property: '#005cc5', + plain: '#24292e', + static: '#032f62', + string: '#032f62', + definition: '#6f42c1', + punctuation: '#24292e', + tag: '#22863a', + comment: { + color: '#6a737d', + }, + }, +}; + +/** + * Properties for the `CodeSnippet` React component. + * @param {string} code - The code snippet. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface CodeSnippetProps extends PropsWithClassName, PropsWithTestId { + code: string; +} + +/** + * The `CodeSnippet` component renders a read only block which highlights + * a small amount of application code such as JavaScript, JSX, or CSS. + * @param {CodeSnippetProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const CodeSnippet = ({ + className, + code, + testId = 'code-snippet', +}: CodeSnippetProps): JSX.Element => { + const { theme } = useSettings(); + const files = { + '/App.js': code, + }; + + return ( +
+ + + + + +
+ ); +}; + +export default CodeSnippet; diff --git a/src/common/components/Text/Text.tsx b/src/common/components/Text/Text.tsx new file mode 100644 index 0000000..a8045c0 --- /dev/null +++ b/src/common/components/Text/Text.tsx @@ -0,0 +1,48 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; +import { PropsWithChildren } from 'react'; + +/** + * The variations of `Text` components. + */ +export type TextVariant = 'heading1' | 'heading2' | 'heading3'; + +/** + * Properties for the `Text` React component. + * @param {TextVariant} [variant] - Optional. The variant. Default: `none` + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface TextProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId { + variant?: TextVariant; +} + +/** + * The `Text` component renders a block of text. The text is styled to match + * the supplied `variant`. + * @param {TextProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const Text = ({ children, className, testId = 'text', variant }: TextProps): JSX.Element => { + const getVariantClasses = (variant?: TextVariant): string => { + switch (variant) { + case 'heading1': + return 'text-4xl'; + case 'heading2': + return 'text-2xl'; + case 'heading3': + return 'text-xl font-bold'; + default: + return ''; + } + }; + + return ( +
+ {children} +
+ ); +}; + +export default Text; diff --git a/src/common/components/Text/__tests__/CodeSnippet.test.tsx b/src/common/components/Text/__tests__/CodeSnippet.test.tsx new file mode 100644 index 0000000..c99aa40 --- /dev/null +++ b/src/common/components/Text/__tests__/CodeSnippet.test.tsx @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import * as UseSettings from 'common/hooks/useSettings'; + +import CodeSnippet from '../CodeSnippet'; + +describe('CodeSnippet', () => { + const useSettingsSpy = vi.spyOn(UseSettings, 'useSettings'); + + beforeEach(() => { + useSettingsSpy.mockReturnValue({ theme: 'light' }); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('code-snippet'); + + // ASSERT + expect(screen.getByTestId('code-snippet')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('code-snippet'); + + // ASSERT + expect(screen.getByTestId('code-snippet').classList).toContain('custom-className'); + }); + + it('should display code', async () => { + // ARRANGE + render(); + await screen.findByTestId('code-snippet'); + + // ASSERT + expect(screen.getByTestId('code-snippet').textContent).toBe('
content
'); + }); + + it('should use dark theme', async () => { + // ARRANGE + useSettingsSpy.mockReturnValue({ theme: 'dark' }); + render(); + await screen.findByTestId('code-snippet'); + + // ASSERT + expect(screen.getByTestId('code-snippet').querySelector('.dark')).not.toBeNull(); + }); +}); diff --git a/src/common/components/Text/__tests__/Text.test.tsx b/src/common/components/Text/__tests__/Text.test.tsx new file mode 100644 index 0000000..e684cf4 --- /dev/null +++ b/src/common/components/Text/__tests__/Text.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import Text from '../Text'; + +describe('Text', () => { + it('should render successfully', async () => { + // ARRANGE + render(content); + await screen.findByTestId('text'); + + // ASSERT + expect(screen.getByTestId('text')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(content); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(content); + await screen.findByTestId('text'); + + // ASSERT + expect(screen.getByTestId('text').classList).toContain('custom-className'); + }); + + it('should render the heading1 variant', async () => { + // ARRANGE + render(content); + await screen.findByTestId('text'); + + // ASSERT + expect(screen.getByTestId('text').classList).toContain('text-4xl'); + }); + + it('should render the heading2 variant', async () => { + // ARRANGE + render(content); + await screen.findByTestId('text'); + + // ASSERT + expect(screen.getByTestId('text').classList).toContain('text-2xl'); + }); + + it('should render the heading3 variant', async () => { + // ARRANGE + render(content); + await screen.findByTestId('text'); + + // ASSERT + expect(screen.getByTestId('text').classList).toContain('text-xl'); + }); +}); diff --git a/src/common/components/Theme/Theme.tsx b/src/common/components/Theme/Theme.tsx new file mode 100644 index 0000000..aae269d --- /dev/null +++ b/src/common/components/Theme/Theme.tsx @@ -0,0 +1,38 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { PropsWithChildren } from 'react'; +import classNames from 'classnames'; + +import { useSettings } from 'common/hooks/useSettings'; + +/** + * Properties for the `Theme` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + * @see {@link PropsWithChildren} + */ +interface ThemeProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +/** + * The `Theme` component uses the user settings (preferences) and renders the + * CSS for the preferred theme. + * @param props - Component properties, `ThemeProps`. + * @returns {JSX.Element} JSX + */ +const Theme = ({ className, children, testId = 'theme' }: ThemeProps): JSX.Element => { + const settings = useSettings(); + + return ( +
+
+ {children} +
+
+ ); +}; + +export default Theme; diff --git a/src/common/components/Theme/__tests__/Theme.test.tsx b/src/common/components/Theme/__tests__/Theme.test.tsx new file mode 100644 index 0000000..35eca71 --- /dev/null +++ b/src/common/components/Theme/__tests__/Theme.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import Theme from '../Theme'; + +describe('Theme', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('theme'); + + // ASSERT + expect(screen.getByTestId('theme')).toBeDefined(); + }); + + it('should have default theme', async () => { + // ARRANGE + render(); + await screen.findByTestId('theme'); + + // ASSERT + expect(screen.getByTestId('theme').classList).toContain('light'); + }); +}); diff --git a/src/common/components/Toast/Toast.tsx b/src/common/components/Toast/Toast.tsx new file mode 100644 index 0000000..a9b67e3 --- /dev/null +++ b/src/common/components/Toast/Toast.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react'; +import { ButtonVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import { animated, useSpring } from '@react-spring/web'; + +import { ToastDetail } from 'common/providers/ToastsContext'; +import { useConfig } from 'common/hooks/useConfig'; + +import Button from 'common/components/Button/Button'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `Toast` component. + * @param {function} dismiss - A function called when the `Toast` dismisses. + * @param {ToastDetail} toast - The `Toast`. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface ToastProps extends PropsWithClassName, PropsWithTestId { + dismiss: () => void; + toast: ToastDetail; +} + +/** + * The `Toast` component renders a small, dismissible message to the user. + * + * Toast messages are typically used to inform the user of something that + * happened in the background such as saving information. Or they are + * used when some adverse action happens, such as an error. + * @param {ToastProps} props - Component properties, `ToastProps`. + * @returns {JSX.Element} JSX + */ +const Toast = ({ className, dismiss, testId = 'toast', toast }: ToastProps): JSX.Element => { + const config = useConfig(); + + const [springs, api] = useSpring(() => ({ + from: { opacity: 1, x: 0 }, + })); + + const doDismiss = (): void => { + api.start({ + to: { opacity: 0, x: -1000 }, + onRest: () => { + dismiss(); + }, + }); + }; + + useEffect(() => { + if (toast.isAutoDismiss) { + const dismissInterval = setInterval(() => { + const dismissAt = dayjs(toast.createdAt).add( + config.VITE_TOAST_AUTO_DISMISS_MILLIS, + 'millisecond', + ); + if (dayjs().isAfter(dismissAt)) { + doDismiss(); + } + }, 500); + + return () => clearInterval(dismissInterval); + } + }, [toast, config.VITE_TOAST_AUTO_DISMISS_MILLIS]); + + return ( + +
+
+ {toast.text} +
+ +
+
+ ); +}; + +export default Toast; diff --git a/src/common/components/Toast/Toasts.tsx b/src/common/components/Toast/Toasts.tsx new file mode 100644 index 0000000..ff41d6b --- /dev/null +++ b/src/common/components/Toast/Toasts.tsx @@ -0,0 +1,36 @@ +import { PropsWithTestId } from '@leanstacks/react-common'; + +import { useToasts } from 'common/hooks/useToasts'; +import Toast from './Toast'; + +/** + * Properties for the `Toasts` component. + * @see {@link PropsWithTestId} + */ +interface ToastsProps extends PropsWithTestId {} + +/** + * The `Toasts` component renders a container for a list of `Toast` + * components. + * @param {ToastsProps} props - Component properties, `ToastsProps`. + * @returns {JSX.Element} JSX + */ +const Toasts = ({ testId = 'toasts' }: ToastsProps): JSX.Element => { + const { removeToast, toasts } = useToasts(); + + return ( +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + className="mx-8 mb-4" + testId={`toast-${toast.id}`} + /> + ))} +
+ ); +}; + +export default Toasts; diff --git a/src/common/components/Toast/__tests__/Toast.test.tsx b/src/common/components/Toast/__tests__/Toast.test.tsx new file mode 100644 index 0000000..87e177e --- /dev/null +++ b/src/common/components/Toast/__tests__/Toast.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import dayjs from 'dayjs'; +import userEvent from '@testing-library/user-event'; + +import { render, screen, waitFor } from 'test/test-utils'; + +import { ToastDetail } from 'common/providers/ToastsContext'; +import { toastFixture } from '__fixtures__/toasts'; + +import Toast from '../Toast'; + +describe('Toast', () => { + const mockDismiss = vi.fn(); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('toast'); + + // ASSERT + expect(screen.getByTestId('toast')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('toast'); + + // ASSERT + expect(screen.getByTestId('toast').classList).toContain('custom-className'); + }); + + it('should call dismiss function when button clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('toast-button-dismiss'); + + // ACT + await userEvent.click(screen.getByTestId('toast-button-dismiss')); + + // ASSERT + await waitFor(() => expect(mockDismiss).toHaveBeenCalled()); + }); + + it('should auto dismiss', async () => { + // ARRANGE + const toast: ToastDetail = { + ...toastFixture, + createdAt: dayjs().toISOString(), + isAutoDismiss: true, + }; + render(); + + // ASSERT + await waitFor(() => expect(mockDismiss).toHaveBeenCalled(), { timeout: 5000 }); + }); +}); diff --git a/src/common/components/Toast/__tests__/Toasts.test.tsx b/src/common/components/Toast/__tests__/Toasts.test.tsx new file mode 100644 index 0000000..69fdab5 --- /dev/null +++ b/src/common/components/Toast/__tests__/Toasts.test.tsx @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen, waitFor } from 'test/test-utils'; +import * as UseToasts from 'common/hooks/useToasts'; +import { toastFixture } from '__fixtures__/toasts'; + +import Toasts from '../Toasts'; + +describe('Toasts', () => { + const useToastsSpy = vi.spyOn(UseToasts, 'useToasts'); + const mockRemoveToast = vi.fn(); + + beforeEach(() => { + useToastsSpy.mockReturnValue({ + createToast: vi.fn(), + removeToast: mockRemoveToast, + toasts: [toastFixture], + }); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('toasts'); + + // ASSERT + expect(screen.getByTestId('toasts')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should render toasts', async () => { + // ARRANGE + render(); + await screen.findByTestId(`toast-${toastFixture.id}`); + + // ASSERT + expect(screen.getByTestId(`toast-${toastFixture.id}`)).toBeDefined(); + }); + + it('should call removeToast with id', async () => { + // ARRANGE + render(); + await screen.findByTestId(`toast-${toastFixture.id}-button-dismiss`); + + // ACT + await userEvent.click(screen.getByTestId(`toast-${toastFixture.id}-button-dismiss`)); + + // ASSERT + await waitFor(() => expect(mockRemoveToast).toHaveBeenCalledWith(toastFixture.id)); + }); +}); diff --git a/src/common/hooks/__tests__/useAuth.test.ts b/src/common/hooks/__tests__/useAuth.test.ts new file mode 100644 index 0000000..bea4150 --- /dev/null +++ b/src/common/hooks/__tests__/useAuth.test.ts @@ -0,0 +1,31 @@ +import { UseQueryResult } from '@tanstack/react-query'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { userTokensFixture } from '__fixtures__/tokens'; +import * as UseGetUserTokens from 'common/api/useGetUserTokens'; + +import { useAuth } from 'common/hooks/useAuth'; + +describe('useAuth', () => { + const useGetUserTokensSpy = vi.spyOn(UseGetUserTokens, 'useGetUserTokens'); + + beforeEach(() => { + useGetUserTokensSpy.mockReturnValue({ + data: userTokensFixture, + isSuccess: true, + isPending: false, + } as unknown as UseQueryResult); + }); + + it('should return the context', async () => { + // ARRANGE + const { result } = renderHook(() => useAuth()); + await waitFor(() => expect(result.current.isAuthenticated).toBeDefined()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.isAuthenticated).toBe(true); + }); +}); diff --git a/src/common/hooks/__tests__/useAxios.test.ts b/src/common/hooks/__tests__/useAxios.test.ts new file mode 100644 index 0000000..529ae36 --- /dev/null +++ b/src/common/hooks/__tests__/useAxios.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import * as UseAuth from 'common/hooks/useAuth'; +import { userTokensFixture } from '__fixtures__/tokens'; + +import { useAxios } from 'common/hooks/useAxios'; + +describe('useAxios', () => { + const useAuthSpy = vi.spyOn(UseAuth, 'useAuth'); + const refetchUserTokensMock = vi.fn(); + + beforeEach(() => { + useAuthSpy.mockReturnValue({ + isAuthenticated: true, + userToken: userTokensFixture, + refetchUserTokens: refetchUserTokensMock, + }); + }); + + it('should return context', async () => { + // ARRANGE + const { result } = renderHook(() => useAxios()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.request).toBeDefined(); + }); +}); diff --git a/src/common/hooks/__tests__/useConfig.test.ts b/src/common/hooks/__tests__/useConfig.test.ts new file mode 100644 index 0000000..eab2bb9 --- /dev/null +++ b/src/common/hooks/__tests__/useConfig.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook as renderHookWithoutWrapper } from '@testing-library/react'; + +import { renderHook, waitFor } from 'test/test-utils'; + +import { useConfig } from 'common/hooks/useConfig'; + +describe('useConfig', () => { + it('should return the context', async () => { + // ARRANGE + const { result } = renderHook(() => useConfig()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.VITE_BUILD_ENV_CODE).toBe('test'); + }); + + it('should throw error when not within provider', () => { + // ASSERT + expect(() => renderHookWithoutWrapper(() => useConfig())).toThrow(/hook must be used within/); + }); +}); diff --git a/src/common/hooks/__tests__/useDebounce.test.ts b/src/common/hooks/__tests__/useDebounce.test.ts new file mode 100644 index 0000000..9816f0e --- /dev/null +++ b/src/common/hooks/__tests__/useDebounce.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useDebounce } from 'common/hooks/useDebounce'; + +describe('useDebounce', () => { + it('should return debounced value', async () => { + // ARRANGE + const value = 1; + const updatedValue = 2; + // render hook with `value` + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value }, + }); + + // ASSERT + // expect hook to render initial value immediately + expect(result.current).toBe(value); + + // ARRANGE + // rerender hook with `updatedValue` + rerender({ value: updatedValue }); + + // ASSERT + // expect the hook value does NOT change immediately + expect(result.current).toBe(value); + // wait for hook value to debounce + await waitFor(() => expect(result.current).toBe(updatedValue)); + // expect the hook value changes after debounce time elapsed + expect(result.current).toBe(updatedValue); + }); + + it('should return debounced value after specified delay', async () => { + // ARRANGE + const value = 1; + const updatedValue = 2; + // render hook with `value` + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 250), { + initialProps: { value }, + }); + + // ASSERT + // expect hook to render initial value immediately + expect(result.current).toBe(value); + + // ARRANGE + // rerender hook with `updatedValue` + rerender({ value: updatedValue }); + + // ASSERT + // expect the hook value does NOT change immediately + expect(result.current).toBe(value); + // wait for hook value to debounce + await waitFor(() => expect(result.current).toBe(updatedValue), { timeout: 500 }); + // expect the hook value changes after debounce time elapsed + expect(result.current).toBe(updatedValue); + }); +}); diff --git a/src/common/hooks/__tests__/useSettings.test.ts b/src/common/hooks/__tests__/useSettings.test.ts new file mode 100644 index 0000000..26c57f6 --- /dev/null +++ b/src/common/hooks/__tests__/useSettings.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook as renderHookWithoutWrapper } from '@testing-library/react'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { renderHook, waitFor } from 'test/test-utils'; +import * as UseGetSettings from 'common/api/useGetSettings'; +import { settingsFixture } from '__fixtures__/settings'; + +import { useSettings } from 'common/hooks/useSettings'; + +describe('useSettings', () => { + const useGetSettingsSpy = vi.spyOn(UseGetSettings, 'useGetSettings'); + + beforeEach(() => { + useGetSettingsSpy.mockReturnValue({ + data: settingsFixture, + isSuccess: true, + } as unknown as UseQueryResult); + }); + + it('should return default value', async () => { + // ARRANGE + const { result } = renderHook(() => UseGetSettings.useGetSettings()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.data).toBeDefined(); + expect(result.current.data?.theme).toBe(settingsFixture.theme); + }); + + it('should throw error when not within provider', () => { + // ASSERT + expect(() => renderHookWithoutWrapper(() => useSettings())).toThrow(/hook must be used within/); + }); +}); diff --git a/src/common/hooks/__tests__/useToasts.test.ts b/src/common/hooks/__tests__/useToasts.test.ts new file mode 100644 index 0000000..09fa73c --- /dev/null +++ b/src/common/hooks/__tests__/useToasts.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook as renderHookWithoutWrapper } from '@testing-library/react'; + +import { act, renderHook, waitFor } from 'test/test-utils'; + +import { useToasts } from 'common/hooks/useToasts'; + +describe('useToasts', () => { + it('should return the context', async () => { + // ARRANGE + const { result } = renderHook(() => useToasts()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).not.toBeNull(); + expect(Array.isArray(result.current.toasts)).toBe(true); + expect(result.current.toasts.length).toBe(0); + expect(typeof result.current.createToast).toBe('function'); + expect(typeof result.current.removeToast).toBe('function'); + }); + + it('should create a toast', async () => { + // ARRANGE + const { result } = renderHook(() => useToasts()); + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current.toasts.length).toBe(0); + + // ACT + act(() => result.current.createToast({ text: 'toast', isAutoDismiss: false })); + + // ASSERT + await waitFor(() => expect(result.current.toasts.length).toBe(1)); + }); + + it('should remove a toast', async () => { + // ARRANGE + const { result } = renderHook(() => useToasts()); + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current.toasts.length).toBe(0); + + // ACT + act(() => result.current.createToast({ text: 'toast', isAutoDismiss: false })); + await waitFor(() => expect(result.current.toasts.length).toBe(1)); + act(() => result.current.removeToast(result.current.toasts[0].id)); + + // ASSERT + await waitFor(() => expect(result.current.toasts.length).toBe(0)); + }); + + it('should throw error when not within provider', async () => { + // ASSERT + expect(() => renderHookWithoutWrapper(() => useToasts())).toThrow( + /useToasts hook must be used within/, + ); + }); +}); diff --git a/src/common/hooks/useAuth.ts b/src/common/hooks/useAuth.ts new file mode 100644 index 0000000..e2956bf --- /dev/null +++ b/src/common/hooks/useAuth.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { AuthContext, AuthContextValue } from 'common/providers/AuthContext'; + +/** + * The `useAuth` hook returns the current `AuthContext` value. + * @returns {AuthContextValue} The current `AuthContext` value, `AuthContextValue`. + */ +export const useAuth = (): AuthContextValue => { + return useContext(AuthContext); +}; diff --git a/src/common/hooks/useAxios.ts b/src/common/hooks/useAxios.ts new file mode 100644 index 0000000..96b08b4 --- /dev/null +++ b/src/common/hooks/useAxios.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { AxiosInstance } from 'axios'; + +import { AxiosContext } from 'common/providers/AxiosContext'; + +/** + * The `useAxios` hook returns the current `AxiosContext` value. + * @returns {AxiosInstance} The current `AxiosContext` value, an `AxiosInstance`. + */ +export const useAxios = (): AxiosInstance => { + return useContext(AxiosContext); +}; diff --git a/src/common/hooks/useConfig.ts b/src/common/hooks/useConfig.ts new file mode 100644 index 0000000..6be331e --- /dev/null +++ b/src/common/hooks/useConfig.ts @@ -0,0 +1,15 @@ +import { Config, ConfigContext } from 'common/providers/ConfigContext'; +import { useContext } from 'react'; + +/** + * The `useConfig` hook returns the current `ConfigContext` value. + * @returns {Config} The current `ConfigContext` value, `Config`. + */ +export const useConfig = (): Config => { + const context = useContext(ConfigContext); + if (!context) { + throw new Error('useConfig hook must be used within a ConfigContextProvider'); + } + + return context; +}; diff --git a/src/common/hooks/useDebounce.ts b/src/common/hooks/useDebounce.ts new file mode 100644 index 0000000..5e77175 --- /dev/null +++ b/src/common/hooks/useDebounce.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +/** + * A utility hook to debounce any type of `value` for `delayMillis` + * milliseconds. + * + * The hook returns the most recent `value` supplied when no changes + * to `value` have been received for `delayMillis`. + * @template T - The type of value to be debounced. + * @param {T} value - The value to be debounced. + * @param {number} delayMillis - The number of milliseconds to delay + * before returning the latest value received. + * @returns {T} The debounced `value`. + */ +export const useDebounce = (value: T, delayMillis: number = 500): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedValue(value); + }, delayMillis); + + return () => clearTimeout(timeout); + }, [value, delayMillis]); + + return debouncedValue; +}; diff --git a/src/common/hooks/useSettings.ts b/src/common/hooks/useSettings.ts new file mode 100644 index 0000000..9bdff34 --- /dev/null +++ b/src/common/hooks/useSettings.ts @@ -0,0 +1,18 @@ +import { useContext } from 'react'; + +import { Settings } from 'common/api/useGetSettings'; +import { SettingsContext } from 'common/providers/SettingsContext'; + +/** + * The `useSettings` hook returns the current `SettingsContext` value. + * @returns {Settings} The current `SettingsContext` value, `Settings`. + * @see {@link Settings} + */ +export const useSettings = (): Settings => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettings hook must be used within a SettingsContextProvider'); + } + + return context; +}; diff --git a/src/common/hooks/useToasts.ts b/src/common/hooks/useToasts.ts new file mode 100644 index 0000000..5b83170 --- /dev/null +++ b/src/common/hooks/useToasts.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react'; + +import { ToastsContext, ToastsContextValue } from 'common/providers/ToastsContext'; + +/** + * The `useToasts` hook returns the current `ToastsContext` value. + * @returns {ToastsContextValue} The current `ToastContext` value, `ToastsContextValue`. + */ +export const useToasts = (): ToastsContextValue => { + const context = useContext(ToastsContext); + if (!context) { + throw new Error('useToasts hook must be used within a ToastsProvider'); + } + + return context; +}; diff --git a/src/common/providers/AuthContext.ts b/src/common/providers/AuthContext.ts new file mode 100644 index 0000000..cf255d1 --- /dev/null +++ b/src/common/providers/AuthContext.ts @@ -0,0 +1,25 @@ +import { createContext } from 'react'; +import { QueryObserverBaseResult } from '@tanstack/react-query'; + +import { UserTokens } from 'common/api/useGetUserTokens'; + +/** + * The `value` provided by the `AuthContext`. + */ +export interface AuthContextValue { + isAuthenticated: boolean; + userToken?: UserTokens; + refetchUserTokens?: () => Promise>; +} + +/** + * The default/initial `AuthContext` value. + */ +const DEFAULT_CONTEXT_VALUE: AuthContextValue = { + isAuthenticated: false, +}; + +/** + * The `AuthContext` instance. + */ +export const AuthContext = createContext(DEFAULT_CONTEXT_VALUE); diff --git a/src/common/providers/AuthProvider.tsx b/src/common/providers/AuthProvider.tsx new file mode 100644 index 0000000..87ac220 --- /dev/null +++ b/src/common/providers/AuthProvider.tsx @@ -0,0 +1,39 @@ +import { PropsWithChildren } from 'react'; + +import { AuthContext, AuthContextValue } from './AuthContext'; +import { useGetUserTokens } from 'common/api/useGetUserTokens'; +import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; + +/** + * The `AuthContextProvider` React component creates, maintains, and provides + * access to the `AuthContext` value. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const AuthContextProvider = ({ children }: PropsWithChildren): JSX.Element => { + // REPLACE: use a query hook to fetch authentication details from an IdP + const { data: userTokens, isPending, isSuccess, refetch: refetchUserTokens } = useGetUserTokens(); + + const value: AuthContextValue = { + isAuthenticated: isSuccess, + userToken: userTokens, + refetchUserTokens, + }; + + const isReady = !isPending; + + return ( + + {isReady && <>{children}} + {!isReady && ( +
+
+ +
+
+ )} +
+ ); +}; + +export default AuthContextProvider; diff --git a/src/common/providers/AxiosContext.ts b/src/common/providers/AxiosContext.ts new file mode 100644 index 0000000..e119c94 --- /dev/null +++ b/src/common/providers/AxiosContext.ts @@ -0,0 +1,17 @@ +import { createContext } from 'react'; +import axios, { AxiosInstance } from 'axios'; + +/** + * Custom `Axios` instance. + */ +export const customAxios = axios.create({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}); + +/** + * The `AxiosContext` instance. + */ +export const AxiosContext = createContext(customAxios); diff --git a/src/common/providers/AxiosProvider.tsx b/src/common/providers/AxiosProvider.tsx new file mode 100644 index 0000000..322f743 --- /dev/null +++ b/src/common/providers/AxiosProvider.tsx @@ -0,0 +1,92 @@ +import { PropsWithChildren, useEffect, useState } from 'react'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; + +import { AxiosContext, customAxios } from './AxiosContext'; +import { AuthContextValue } from './AuthContext'; +import { useAuth } from 'common/hooks/useAuth'; + +/** + * An Axios request interceptor that adds user authentication headers to + * the request. + * @param config {InternalAxiosRequestConfig} config - The Axios config object for the request. + * @param authContext {AuthContextValue} authContext - The `AuthContextValue` containing + * the current user authentication state. + * @returns {InternalAxiosRequestConfig} The modified Axios config object. + */ +const authRequestInterceptor = async ( + config: InternalAxiosRequestConfig, + authContext: AuthContextValue, +): Promise => { + const { headers } = config; + if (authContext.isAuthenticated) { + headers['Authorization'] = `Bearer ${authContext.userToken?.id_token}`; + headers['X-Access-Token'] = authContext.userToken?.access_token; + } + return config; +}; + +/** + * An Axios response interceptor called for responses in error. If the http status + * code is `401`, attempts to refresh the authentication tokens and retry the request. + * @param {AxiosError} error - The AxiosError instance. + * @param {AuthContextValue} authContext - The `AuthContextValue` containing + * the current user authentication state. + * @returns {AxiosResponse} An AxiosResponse representing the retried request. + */ +const notAuthenticatedErrorInterceptor = async ( + error: AxiosError, + authContext: AuthContextValue, +) => { + const config = error.config; + if (error.response?.status === 401) { + if (authContext.isAuthenticated && authContext.refetchUserTokens) { + const { data } = await authContext.refetchUserTokens(); + + return axios({ + ...config, + headers: { + ...config?.headers, + Authorization: `Bearer ${data?.id_token}`, + 'X-Access-Token': data?.access_token, + }, + }); + } + } + + return Promise.reject(error); +}; + +/** + * The `AxiosContextProvider` React component creates, maintains, and provides + * access to the `AxiosContext` value. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const AxiosContextProvider = ({ children }: PropsWithChildren): JSX.Element => { + const [isReady, setIsReady] = useState(false); + const authContext = useAuth(); + + useEffect(() => { + const authRequestInterceptorId = customAxios.interceptors.request.use(async (config) => + authRequestInterceptor(config, authContext), + ); + + const notAuthenticatedErrorInterceptorId = customAxios.interceptors.response.use( + (response) => response, + async (error) => notAuthenticatedErrorInterceptor(error, authContext), + ); + + setIsReady(true); + + return () => { + customAxios.interceptors.request.eject(authRequestInterceptorId); + customAxios.interceptors.response.eject(notAuthenticatedErrorInterceptorId); + }; + }, [authContext]); + + return ( + {isReady && <>{children}} + ); +}; + +export default AxiosContextProvider; diff --git a/src/common/providers/ConfigContext.ts b/src/common/providers/ConfigContext.ts new file mode 100644 index 0000000..8269957 --- /dev/null +++ b/src/common/providers/ConfigContext.ts @@ -0,0 +1,22 @@ +import { createContext } from 'react'; + +/** + * The application configuration. The `value` provided by the `ConfigContext`. + */ +export interface Config { + VITE_BASE_URL_API: string; + VITE_BUILD_DATE: string; + VITE_BUILD_TIME: string; + VITE_BUILD_TS: string; + VITE_BUILD_COMMIT_SHA: string; + VITE_BUILD_ENV_CODE: string; + VITE_BUILD_WORKFLOW_NAME: string; + VITE_BUILD_WORKFLOW_RUN_NUMBER: number; + VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number; + VITE_TOAST_AUTO_DISMISS_MILLIS: number; +} + +/** + * The `ConfigContext` instance. + */ +export const ConfigContext = createContext(undefined); diff --git a/src/common/providers/ConfigProvider.tsx b/src/common/providers/ConfigProvider.tsx new file mode 100644 index 0000000..68dcd99 --- /dev/null +++ b/src/common/providers/ConfigProvider.tsx @@ -0,0 +1,56 @@ +import { PropsWithChildren, useEffect, useState } from 'react'; +import { number, object, ObjectSchema, string, ValidationError } from 'yup'; + +import { Config, ConfigContext } from './ConfigContext'; + +/** + * The configuration validation schema. + * @see {@link https://github.com/jquense/yup | Yup} + */ +const configSchema: ObjectSchema = object({ + VITE_BASE_URL_API: string().url().required(), + VITE_BUILD_DATE: string().default('1970-01-01'), + VITE_BUILD_TIME: string().default('00:00:00'), + VITE_BUILD_TS: string().default('1970-01-01T00:00:00+0000'), + VITE_BUILD_COMMIT_SHA: string().default('local'), + VITE_BUILD_ENV_CODE: string().default('local'), + VITE_BUILD_WORKFLOW_NAME: string().default('local'), + VITE_BUILD_WORKFLOW_RUN_NUMBER: number().default(1), + VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number().default(1), + VITE_TOAST_AUTO_DISMISS_MILLIS: number().default(5000), +}); + +/** + * The `ConfigContextProvider` React component creates, maintains, and provides + * access to the `ConfigContext` value. + * Validates the React application configuration values from `import.meta.env`. + * Throws an `Error` when the configuration is invalid, preventing application + * startup. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const ConfigContextProvider = ({ children }: PropsWithChildren): JSX.Element => { + const [isReady, setIsReady] = useState(false); + const [config, setConfig] = useState(); + + useEffect(() => { + try { + const validatedConfig = configSchema.validateSync(import.meta.env, { + abortEarly: false, + stripUnknown: true, + }); + setConfig(validatedConfig); + setIsReady(true); + } catch (err) { + if (err instanceof ValidationError) throw new Error(`${err}::${err.errors}`); + if (err instanceof Error) throw new Error(`Configuration error: ${err.message}`); + throw err; + } + }, []); + + return ( + {isReady && <>{children}} + ); +}; + +export default ConfigContextProvider; diff --git a/src/common/providers/SettingsContext.ts b/src/common/providers/SettingsContext.ts new file mode 100644 index 0000000..31652e0 --- /dev/null +++ b/src/common/providers/SettingsContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +import { Settings } from 'common/api/useGetSettings'; + +/** + * The `SettingsContext` instance. + */ +export const SettingsContext = createContext(undefined); diff --git a/src/common/providers/SettingsProvider.tsx b/src/common/providers/SettingsProvider.tsx new file mode 100644 index 0000000..8519ab7 --- /dev/null +++ b/src/common/providers/SettingsProvider.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren, useMemo } from 'react'; + +import { useGetSettings } from 'common/api/useGetSettings'; +import { SettingsContext } from './SettingsContext'; + +/** + * The `SettingsContextProvider` React component creates, maintains, and provides + * access to the `SettingsContext` value. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const SettingsContextProvider = ({ children }: PropsWithChildren) => { + const { data: settings, isLoading } = useGetSettings(); + + const value = useMemo(() => settings, [settings]); + + return ( + + {!isLoading && <>{children}} + + ); +}; + +export default SettingsContextProvider; diff --git a/src/common/providers/ToastsContext.ts b/src/common/providers/ToastsContext.ts new file mode 100644 index 0000000..97ff185 --- /dev/null +++ b/src/common/providers/ToastsContext.ts @@ -0,0 +1,30 @@ +import { createContext } from 'react'; + +/** + * Describes the attributes of a single Toast. + */ +export interface ToastDetail { + id: string; + text: string; + createdAt: string; + isAutoDismiss: boolean; +} + +/** + * A DTO type which describes the attributes to create a new Toast. + */ +export type CreateToastDTO = Pick; + +/** + * The `value` provided by the `ToastsContext`. + */ +export interface ToastsContextValue { + toasts: ToastDetail[]; + createToast: (toast: CreateToastDTO) => void; + removeToast: (id: string) => void; +} + +/** + * The `ToastsContext` instance. + */ +export const ToastsContext = createContext(undefined); diff --git a/src/common/providers/ToastsProvider.tsx b/src/common/providers/ToastsProvider.tsx new file mode 100644 index 0000000..1e07240 --- /dev/null +++ b/src/common/providers/ToastsProvider.tsx @@ -0,0 +1,118 @@ +import { Dispatch, PropsWithChildren, useMemo, useReducer } from 'react'; +import { v4 as uuid } from 'uuid'; +import dayjs from 'dayjs'; + +import { CreateToastDTO, ToastDetail, ToastsContext, ToastsContextValue } from './ToastsContext'; + +/** + * The `ToastsContext` reducer `state`. + */ +type ToastsContextState = { + toasts: ToastDetail[]; +}; + +/** + * The available `ToastsContext` action types. + */ +enum ToastAction { + Create = 'Create', + Remove = 'Remove', +} + +/** + * The `ToastContext` action type definitions. Each action consists + * of a `type` and a `payload`. + * + * The `type` indicates the specific action to be performed. + * + * The `payload` contains information specific to the `type` of action + * requested. + */ +type ToastsContextAction = + | { type: ToastAction.Create; payload: ToastDetail } + | { type: ToastAction.Remove; payload: string }; + +/** + * The default `state` of the reducer. + */ +const DEFAULT_STATE: ToastsContextState = { toasts: [] }; + +/** + * The reducer function mutates the state as actions are dispatched. + * @param {ToastsContextState} state - The current reducer state. + * @param {ToastsContextAction} action - The action to be applied to the state. + * @returns {ToastsContextState} The updated state. + */ +const reducer = (state: ToastsContextState, action: ToastsContextAction): ToastsContextState => { + const { payload, type } = action; + + switch (type) { + case ToastAction.Create: + return { + ...state, + toasts: [...state.toasts, payload], + }; + case ToastAction.Remove: + return { + ...state, + toasts: state.toasts.filter((toast) => toast.id !== payload), + }; + default: + return state; + } +}; + +/** + * Creates the action functions which may be used to request mutations to the + * `state`. + * @param dispatch - The reducer dispatch function. + * @returns An object whose properties are action functions. + */ +const actions = (dispatch: Dispatch) => { + const createToast = ({ text, isAutoDismiss }: CreateToastDTO): void => { + dispatch({ + type: ToastAction.Create, + payload: { + id: uuid(), + createdAt: dayjs().toISOString(), + isAutoDismiss, + text, + }, + }); + }; + + const removeToast = (id: string): void => { + dispatch({ + type: ToastAction.Remove, + payload: id, + }); + }; + + return { + createToast, + removeToast, + }; +}; + +/** + * The `ToastsProvider` React component creates, maintains, and provides + * access to the `ToastsContext` value. + * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. + * @returns {JSX.Element} JSX + */ +const ToastsProvider = ({ children }: PropsWithChildren): JSX.Element => { + const [{ toasts }, dispatch] = useReducer(reducer, DEFAULT_STATE); + + const value = useMemo(() => { + const { createToast, removeToast } = actions(dispatch); + return { + toasts, + createToast, + removeToast, + }; + }, [toasts]); + + return {children}; +}; + +export default ToastsProvider; diff --git a/src/common/providers/__tests__/AuthProvider.test.tsx b/src/common/providers/__tests__/AuthProvider.test.tsx new file mode 100644 index 0000000..a7106fe --- /dev/null +++ b/src/common/providers/__tests__/AuthProvider.test.tsx @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; + +import { userTokensFixture } from '__fixtures__/tokens'; +import * as UseGetUserTokens from 'common/api/useGetUserTokens'; + +import AuthContextProvider from 'common/providers/AuthProvider'; + +describe('AuthProvider', () => { + const useGetUserTokensSpy = vi.spyOn(UseGetUserTokens, 'useGetUserTokens'); + + beforeEach(() => { + useGetUserTokensSpy.mockReturnValue({ + data: userTokensFixture, + isPending: false, + isSuccess: true, + } as unknown as UseQueryResult); + }); + + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('provider-auth-ready'); + + // ASSERT + expect(screen.getByTestId('provider-auth-ready')).toBeDefined(); + }); + + it('should render children when ready', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('provider-auth-ready'); + + // ASSERT + expect(screen.getByTestId('provider-auth-ready')).toBeDefined(); + }); + + it('should render loader when not ready', async () => { + // ARRANGE + useGetUserTokensSpy.mockReturnValue({ + data: undefined, + isPending: true, + isSuccess: false, + } as unknown as UseQueryResult); + render( + +
+
, + ); + await screen.findByTestId('provider-auth'); + + // ASSERT + expect(screen.getByTestId('provider-auth')).toBeDefined(); + }); +}); diff --git a/src/common/providers/__tests__/AxiosProvider.test.tsx b/src/common/providers/__tests__/AxiosProvider.test.tsx new file mode 100644 index 0000000..e88c7d5 --- /dev/null +++ b/src/common/providers/__tests__/AxiosProvider.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEffect, useState } from 'react'; +import { InternalAxiosRequestConfig } from 'axios'; +import { render, screen } from 'test/test-utils'; + +import { useAxios } from 'common/hooks/useAxios'; +import * as UseAuth from 'common/hooks/useAuth'; +import { userTokensFixture } from '__fixtures__/tokens'; + +import AxiosContextProvider from 'common/providers/AxiosProvider'; + +describe('AxiosProvider', () => { + const useAuthSpy = vi.spyOn(UseAuth, 'useAuth'); + const refetchUserTokensMock = vi.fn(); + + beforeEach(() => { + refetchUserTokensMock.mockReturnValue({ + data: userTokensFixture, + }); + useAuthSpy.mockReturnValue({ + isAuthenticated: true, + userToken: userTokensFixture, + refetchUserTokens: refetchUserTokensMock, + }); + }); + + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('provider-axios-ready'); + + // ASSERT + expect(screen.getByTestId('provider-axios-ready')).toBeDefined(); + }); + + it('should add user token authentication headers to requests', async () => { + // ARRANGE + function AxiosTester() { + const [user, setUser] = useState(); + const [config, setConfig] = useState(); + const axios = useAxios(); + useEffect(() => { + axios.request({ url: 'https://jsonplaceholder.typicode.com/users/1' }).then((response) => { + setConfig(response.config); + setUser(response.data); + }); + }, [axios]); + + return ( + <> + {user && ( +
+
{config?.headers['Authorization']}
+
{config?.headers['X-Access-Token']}
+
+ )} + + ); + } + + render( + + + , + ); + await screen.findByTestId('provider-axios-ready'); + + // ASSERT + expect(screen.getByTestId('provider-axios-ready')).toBeDefined(); + expect(screen.getByText('Bearer id-token')).toBeDefined(); + expect(screen.getByText('access-token')).toBeDefined(); + expect(refetchUserTokensMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/common/providers/__tests__/ConfigProvider.test.tsx b/src/common/providers/__tests__/ConfigProvider.test.tsx new file mode 100644 index 0000000..d07e30b --- /dev/null +++ b/src/common/providers/__tests__/ConfigProvider.test.tsx @@ -0,0 +1,46 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import ConfigContextProvider from 'common/providers/ConfigProvider'; + +describe('ConfigProvider', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('provider-config'); + + // ASSERT + expect(screen.getByTestId('provider-config')).toBeDefined(); + }); +}); + +describe.skip('ConfigProvider error', () => { + const originalEnv = process.env; + + beforeAll(() => { + process.env = { NODE_ENV: 'test', PUBLIC_URL: 'localhost' }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should throw configuration validation error', () => { + // ARRANGE + function renderContextProvider() { + render( + +
+
, + ); + } + + // ASSERT + expect(renderContextProvider).toThrow(/is a required field/); + }); +}); diff --git a/src/common/providers/__tests__/SettingsProvider.test.tsx b/src/common/providers/__tests__/SettingsProvider.test.tsx new file mode 100644 index 0000000..89a43f0 --- /dev/null +++ b/src/common/providers/__tests__/SettingsProvider.test.tsx @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render as renderWithoutWrapper } from '@testing-library/react'; + +import { screen } from 'test/test-utils'; + +import SettingsContextProvider from 'common/providers/SettingsProvider'; +import { settingsFixture } from '__fixtures__/settings'; +import * as UseGetSettings from 'common/api/useGetSettings'; +import { UseQueryResult } from '@tanstack/react-query'; + +const useGetSettingsSpy = vi.spyOn(UseGetSettings, 'useGetSettings'); + +describe('SettingsProvider', () => { + beforeEach(() => { + useGetSettingsSpy.mockReturnValue({ + data: settingsFixture, + isSuccess: true, + } as unknown as UseQueryResult); + }); + + it('should render successfully', async () => { + // ARRANGE + renderWithoutWrapper( + +
+
, + ); + await screen.findByTestId('provider-settings'); + + // ASSERT + expect(screen.getByTestId('provider-settings')).toBeDefined(); + }); +}); diff --git a/src/common/providers/__tests__/ToastsProvider.test.tsx b/src/common/providers/__tests__/ToastsProvider.test.tsx new file mode 100644 index 0000000..1019dd6 --- /dev/null +++ b/src/common/providers/__tests__/ToastsProvider.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { render as renderWithoutWrapper } from '@testing-library/react'; + +import { screen } from 'test/test-utils'; + +import ToastsProvider from 'common/providers/ToastsProvider'; + +describe('ToastsProvider', () => { + it('should render successfully', async () => { + // ARRANGE + renderWithoutWrapper( + +
+
, + ); + await screen.findByTestId('provider-toasts'); + + // ASSERT + expect(screen.getByTestId('provider-toasts')).toBeDefined(); + }); +}); diff --git a/src/common/utils/__tests__/numbers.test.ts b/src/common/utils/__tests__/numbers.test.ts new file mode 100644 index 0000000..9743d55 --- /dev/null +++ b/src/common/utils/__tests__/numbers.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { toNumberBetween } from 'common/utils/numbers'; + +describe('numbers', () => { + it('should convert to number successfully', () => { + // ARRANGE + const value: string = '1'; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(1); + }); + + it('should convert boolean to number successfully', () => { + // ARRANGE + const falseValue: boolean = false; + const trueValue: boolean = true; + const falseResult = toNumberBetween(falseValue, 0, 2, 1); + const trueResult = toNumberBetween(trueValue, 0, 2, 0); + + // ASSERT + expect(falseResult).toBe(0); + expect(trueResult).toBe(1); + }); + + it('should convert number to number successfully', () => { + // ARRANGE + const value: number = 1; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(1); + }); + + it('should convert string to default', () => { + // ARRANGE + const value: string = 'a'; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(0); + }); + + it('should return min when number equal to min', () => { + // ARRANGE + const value: string = '10'; + const result = toNumberBetween(value, 10, 20, 15); + + // ASSERT + expect(result).toBe(10); + }); + + it('should return default when number less than min', () => { + // ARRANGE + const value: string = '0'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(10); + }); + + it('should return max when number equal to max', () => { + // ARRANGE + const value: string = '20'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(20); + }); + + it('should return default when number greater than max', () => { + // ARRANGE + const value: string = '500'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(10); + }); +}); diff --git a/src/common/utils/__tests__/storage.test.ts b/src/common/utils/__tests__/storage.test.ts new file mode 100644 index 0000000..5d35243 --- /dev/null +++ b/src/common/utils/__tests__/storage.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import storage from 'common/utils/storage'; + +describe('storage', () => { + const originalLocalStorage = global.localStorage; + const mockGetItem = vi.fn(); + const mockSetItem = vi.fn(); + const mockRemoveItem = vi.fn(); + + beforeAll(() => { + Object.defineProperty(global, 'localStorage', { + value: { getItem: mockGetItem, removeItem: mockRemoveItem, setItem: mockSetItem }, + }); + }); + + afterAll(() => { + Object.defineProperty(global, 'localStorage', { value: originalLocalStorage }); + }); + + it('should get an item', () => { + // ARRANGE + mockGetItem.mockReturnValueOnce('value'); + + // ACT + const value = storage.getItem('key'); + + // ASSERT + expect(value).toBe('value'); + expect(mockGetItem).toHaveBeenCalledTimes(1); + expect(mockGetItem).toHaveBeenCalledWith('key'); + }); + + it('should set an item', () => { + // ACT + storage.setItem('key', 'value'); + + // ASSERT + expect(mockSetItem).toHaveBeenCalledTimes(1); + expect(mockSetItem).toHaveBeenCalledWith('key', 'value'); + }); + + it('should remove an item', () => { + // ACT + storage.removeItem('key'); + + // ASSERT + expect(mockRemoveItem).toHaveBeenCalledTimes(1); + expect(mockRemoveItem).toHaveBeenCalledWith('key'); + }); +}); diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts new file mode 100644 index 0000000..43bf2b6 --- /dev/null +++ b/src/common/utils/constants.ts @@ -0,0 +1,35 @@ +import { Settings } from 'common/api/useGetSettings'; + +/** + * Keys used with React Query cache. + */ +export enum QueryKeys { + Settings = 'Settings', + Tasks = 'Tasks', + Users = 'Users', + UserTokens = 'UserTokens', +} + +/** + * Keys used for browser local storage. + */ +export enum StorageKeys { + Language = 'react-starter.language', + Settings = 'react-starter.settings', + User = 'react-starter.user', + UserTokens = 'react-starter.user-tokens', +} + +/** + * Default `Settings`, i.e. user preferences. + */ +export const DEFAULT_SETTINGS: Settings = { + theme: 'light', +}; + +/** + * URL search parameter, i.e. query string, keys. + */ +export enum SearchParam { + tab = 'tab', +} diff --git a/src/common/utils/i18n/__tests__/i18n.test.ts b/src/common/utils/i18n/__tests__/i18n.test.ts new file mode 100644 index 0000000..a63c52d --- /dev/null +++ b/src/common/utils/i18n/__tests__/i18n.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import i18n from 'common/utils/i18n'; + +describe('i18n', () => { + it('should initialize i18n', () => { + // ASSERT + expect(i18n).toBeDefined(); + }); +}); diff --git a/src/common/utils/i18n/index.ts b/src/common/utils/i18n/index.ts new file mode 100644 index 0000000..fcb008a --- /dev/null +++ b/src/common/utils/i18n/index.ts @@ -0,0 +1,41 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import { StorageKeys } from 'common/utils/constants'; + +// translation resources +import en from './locales/en'; +import es from './locales/es'; +import fr from './locales/fr'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + // logging + debug: true, + + // languages, namespaces, and resources + supportedLngs: ['en', 'es', 'fr'], + fallbackLng: 'en', + ns: ['common', 'users'], + defaultNS: 'common', + resources: { + en, + es, + fr, + }, + + // translation defaults + interpolation: { + escapeValue: false, + }, + + // plugin - language detector + detection: { + lookupLocalStorage: StorageKeys.Language, + }, + }); + +export default i18n; diff --git a/src/common/utils/i18n/locales/en/common.json b/src/common/utils/i18n/locales/en/common.json new file mode 100644 index 0000000..0f1343d --- /dev/null +++ b/src/common/utils/i18n/locales/en/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "Creating React apps just got a lot simpler", + "letsGetStarted": "Let's get started", + "privacy": "Privacy", + "privacyPolicy": "Privacy policy", + "terms": "Terms", + "termsAndConditions": "Terms and conditions", + "welcome": "Welcome" +} diff --git a/src/common/utils/i18n/locales/en/index.ts b/src/common/utils/i18n/locales/en/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/common/utils/i18n/locales/en/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/common/utils/i18n/locales/en/users.json b/src/common/utils/i18n/locales/en/users.json new file mode 100644 index 0000000..444f6a7 --- /dev/null +++ b/src/common/utils/i18n/locales/en/users.json @@ -0,0 +1,16 @@ +{ + "user": {}, + "task": { + "allComplete": "All your tasks are complete!", + "errors": { + "fetchingList": "A problem occurred fetching your tasks." + }, + "markComplete": "Mark complete", + "markIncomplete": "Mark incomplete", + "markedComplete": "Marked task complete", + "markedIncomplete": "Marked task incomplete", + "tasks": "tasks", + "toComplete_one": "You have {{count}} task to complete.", + "toComplete_other": "You have {{count}} tasks to complete." + } +} diff --git a/src/common/utils/i18n/locales/es/common.json b/src/common/utils/i18n/locales/es/common.json new file mode 100644 index 0000000..fe2e9a4 --- /dev/null +++ b/src/common/utils/i18n/locales/es/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "Crear aplicaciones React ahora es mucho más sencillo", + "letsGetStarted": "Empecemos", + "privacy": "Privacidad", + "privacyPolicy": "Política de privacidad", + "terms": "Términos", + "termsAndConditions": "Términos y condiciones", + "welcome": "Bienvenido" +} diff --git a/src/common/utils/i18n/locales/es/index.ts b/src/common/utils/i18n/locales/es/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/common/utils/i18n/locales/es/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/common/utils/i18n/locales/es/users.json b/src/common/utils/i18n/locales/es/users.json new file mode 100644 index 0000000..cd44933 --- /dev/null +++ b/src/common/utils/i18n/locales/es/users.json @@ -0,0 +1,16 @@ +{ + "user": {}, + "task": { + "allComplete": "¡Todas tus tareas están completas!", + "errors": { + "fetchingList": "Ocurrió un problema al recuperar tus tareas." + }, + "markComplete": "Márcalo completo", + "markIncomplete": "Márcalo como incompleto", + "markedComplete": "Tarea marcada completada", + "markedIncomplete": "Tarea marcada como incompleta", + "tasks": "tareas", + "toComplete_one": "Tienes {{count}} tarea para completar.", + "toComplete_other": "Tienes {{count}} tareas para completar." + } +} diff --git a/src/common/utils/i18n/locales/fr/common.json b/src/common/utils/i18n/locales/fr/common.json new file mode 100644 index 0000000..4c73302 --- /dev/null +++ b/src/common/utils/i18n/locales/fr/common.json @@ -0,0 +1,9 @@ +{ + "creatingReactApps": "La création d'applications React est devenue beaucoup plus simple", + "letsGetStarted": "Commençons", + "privacy": "Confidentialité", + "privacyPolicy": "Politique de confidentialité", + "terms": "Termes", + "termsAndConditions": "Termes et conditions", + "welcome": "Bienvenue" +} diff --git a/src/common/utils/i18n/locales/fr/index.ts b/src/common/utils/i18n/locales/fr/index.ts new file mode 100644 index 0000000..d762461 --- /dev/null +++ b/src/common/utils/i18n/locales/fr/index.ts @@ -0,0 +1,4 @@ +import common from './common.json'; +import users from './users.json'; + +export default { common, users }; diff --git a/src/common/utils/i18n/locales/fr/users.json b/src/common/utils/i18n/locales/fr/users.json new file mode 100644 index 0000000..ac5538d --- /dev/null +++ b/src/common/utils/i18n/locales/fr/users.json @@ -0,0 +1,16 @@ +{ + "user": {}, + "task": { + "allComplete": "Toutes vos tâches sont terminées!", + "errors": { + "fetchingList": "Un problème est survenu lors de la récupération de vos tâches." + }, + "markComplete": "Marquez-le comme terminé", + "markIncomplete": "Marquez-le comme incomplet", + "markedComplete": "Tâche marquée terminée", + "markedIncomplete": "Tâche marquée incomplète", + "tasks": "tâches", + "toComplete_one": "Vous avez {{count}} tâche à accomplir.", + "toComplete_other": "Vous avez {{count}} tâches à accomplir." + } +} diff --git a/src/common/utils/numbers.ts b/src/common/utils/numbers.ts new file mode 100644 index 0000000..1e34ed5 --- /dev/null +++ b/src/common/utils/numbers.ts @@ -0,0 +1,37 @@ +import isNaN from 'lodash/isNaN'; +import toNumber from 'lodash/toNumber'; + +/** + * Converts the supplied `value` to a `number`. + * + * If type conversion + * fails, returns the supplied default value, `defaultValue`. + * + * If value exceeds the `minimum` or `maxaximum`, returns the supplied default + * value. + * @param value - The value to be converted. + * @param minimum - The minimum allowed value. + * @param maximum - The maximum allowed value. + * @param defaultValue - The default value. + * @returns {number} The resulting `number` value after type conversion and + * validation. + */ +export const toNumberBetween = ( + value: boolean | number | string | null, + minimum: number, + maximum: number, + defaultValue: number, +): number => { + // convert value to a number + const numVal = toNumber(value); + // if result is not a number (NaN), return default + if (isNaN(numVal)) { + return defaultValue; + } + // if result is outside the boundaries, return default + if (numVal < minimum || numVal > maximum) { + return defaultValue; + } + // otherwise return result + return numVal; +}; diff --git a/src/common/utils/storage.ts b/src/common/utils/storage.ts new file mode 100644 index 0000000..cefc9ae --- /dev/null +++ b/src/common/utils/storage.ts @@ -0,0 +1,46 @@ +/** + * Returns the current value associated with the given `key`, or `null` if + * the given key does not exist. + * @param {string} key - The storage `key`. + * @returns {string | null} Returns the value if found, otherwise `null`. + * @see {@link localStorage.getItem} + */ +const getItem = (key: string): string | null => { + return localStorage.getItem(key); +}; + +/** + * Removes the key/value pair with the given `key`, if a key/value pair with the given key exists. + * + * Dispatches a storage event on Window objects holding an equivalent Storage object. + * @param {string} key - The storage `key`. + * @see {@link localstorage.removeItem} + */ +const removeItem = (key: string): void => { + localStorage.removeItem(key); +}; + +/** + * Sets the value of the pair identified by `key` to `value`, creating a new + * key/value pair if none existed for key previously. + * + * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. + * (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) + * + * Dispatches a storage event on Window objects holding an equivalent Storage + * object. + * @param {string} key - The storage `key`. + * @param {string} value - The `value` to be stored. + * @see {@link localStorage.setItem} + */ +const setItem = (key: string, value: string): void => { + localStorage.setItem(key, value); +}; + +const storage = { + getItem, + removeItem, + setItem, +}; + +export default storage; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..9b4662c --- /dev/null +++ b/src/index.css @@ -0,0 +1,31 @@ +@tailwind base; + +@layer base { + html { + -webkit-tap-highlight-color: transparent; + } + *::-webkit-scrollbar { + width: theme('spacing[2.5]'); + height: theme('spacing[1.5]'); + } + *::-webkit-scrollbar-track { + background-color: theme('colors.neutral.300'); + } + .dark *::-webkit-scrollbar-track { + background-color: theme('colors.neutral.900'); + } + *::-webkit-scrollbar-thumb { + background-color: theme('colors.neutral.400'); + border-color: theme('colors.neutral.300'); + border-radius: theme('spacing.1'); + border-style: solid; + border-width: theme('spacing.px'); + } + .dark *::-webkit-scrollbar-thumb { + background-color: theme('colors.neutral.700'); + border-color: theme('colors.neutral.900'); + } +} + +@tailwind components; +@tailwind utilities; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..f917a2a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import 'common/utils/i18n'; +import App from './App.tsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/pages/ComponentsPage/ComponentsPage.tsx b/src/pages/ComponentsPage/ComponentsPage.tsx new file mode 100644 index 0000000..c344972 --- /dev/null +++ b/src/pages/ComponentsPage/ComponentsPage.tsx @@ -0,0 +1,48 @@ +import { Outlet } from 'react-router-dom'; + +import Page from 'common/components/Page/Page'; +import Text from 'common/components/Text/Text'; +import MenuNavLink from 'common/components/Menu/MenuNavLink'; + +/** + * The `ComponentsPage` component renders the layout for the components page. + * It provides an `Outlet` for displaying a variety of sub-pages containing + * React component variations. + * @returns {JSX.Element} JSX + */ +const ComponentsPage = (): JSX.Element => { + return ( + +
+ + Components + + +
+
+ + Avatar + + + Badge + + + Button + + + Card + + + Text + +
+
+ +
+
+
+
+ ); +}; + +export default ComponentsPage; diff --git a/src/pages/ComponentsPage/__tests__/ComponentsPage.test.tsx b/src/pages/ComponentsPage/__tests__/ComponentsPage.test.tsx new file mode 100644 index 0000000..f2912e5 --- /dev/null +++ b/src/pages/ComponentsPage/__tests__/ComponentsPage.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import ComponentsPage from '../ComponentsPage'; + +describe('ComponentsPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-components'); + + // ASSERT + expect(screen.getByTestId('page-components')).toBeDefined(); + }); +}); diff --git a/src/pages/ComponentsPage/components/AvatarComponents.tsx b/src/pages/ComponentsPage/components/AvatarComponents.tsx new file mode 100644 index 0000000..67e4511 --- /dev/null +++ b/src/pages/ComponentsPage/components/AvatarComponents.tsx @@ -0,0 +1,123 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import avatarPicture from './avatar-picture.png'; +import { ComponentProperty } from '../model/components'; +import Text from 'common/components/Text/Text'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Avatar from 'common/components/Icon/Avatar'; + +/** + * Properties for the `AvatarComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface AvatarComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `AvatarComponents` React component renders a set of examples illustrating + * the use of the `Avatar` component. + * @param {AvatarComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const AvatarComponents = ({ + className, + testId = 'components-avatar', +}: AvatarComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'picture', + description: 'The URL or base64 encoded image source.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'value', + description: + 'The short name to associate with the Avatar. Displayed as alternative text for the image or when hovered.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Avatar Component + + +
+ The Avatar component displays a circular image + or placeholder. Usually used to represent a user, group, or team, but may be used to + represent any named object. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ +
+ `} /> +
+ +
+
+ +
+ `} + /> +
+ +
+
+ +
+ `} + /> +
+
+ ); +}; + +export default AvatarComponents; diff --git a/src/pages/ComponentsPage/components/BadgeComponents.tsx b/src/pages/ComponentsPage/components/BadgeComponents.tsx new file mode 100644 index 0000000..cc29fe2 --- /dev/null +++ b/src/pages/ComponentsPage/components/BadgeComponents.tsx @@ -0,0 +1,124 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import { ComponentProperty } from '../model/components'; +import Text from 'common/components/Text/Text'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Badge from 'common/components/Badge/Badge'; + +/** + * Properties for the `BadgeComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface BadgeComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `BadgeComponents` React component renders a set of examples illustrating + * the use of the `Badge` component. + * @param {BadgeComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const BadgeComponents = ({ + className, + testId = 'components-badge', +}: BadgeComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Badge Component + + +
+ The Badge component displays a stylized + counter. Useful for displaying the number of items of a specific type, for example, the + number of notifications. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ 3 +
+ 3`} /> +
+ +
+
+ 999+ +
+ 999+`} /> +
+ +
+
+ + 19 + +
+ 19`} + /> +
+ +
+
+ + Closed + +
+ + Closed +`} + /> +
+
+ ); +}; + +export default BadgeComponents; diff --git a/src/pages/ComponentsPage/components/ButtonComponents.tsx b/src/pages/ComponentsPage/components/ButtonComponents.tsx new file mode 100644 index 0000000..eb10b28 --- /dev/null +++ b/src/pages/ComponentsPage/components/ButtonComponents.tsx @@ -0,0 +1,146 @@ +import { ButtonVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +import Button from 'common/components/Button/Button'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Text from 'common/components/Text/Text'; +import { ComponentProperty } from '../model/components'; +import { createColumnHelper } from '@tanstack/react-table'; +import Table from 'common/components/Table/Table'; + +/** + * Properties for the `ButtonComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface ButtonComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `ButtonComponents` React component renders a set of examples illustrating + * the use of the `Button` component. + * @param {ButtonComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const ButtonComponents = ({ + className, + testId = 'components-button', +}: ButtonComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'onClick', + description: 'Optional. Click event handler function.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'variant', + description: 'Optional. Applies default styling. Default: solid', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Button Component + + +
+ The Button component displays a clickable + button which is styled in a standardized way. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ +
+ Default button`} /> +
+ +
+
+ +
+ Outline button`} + /> +
+ +
+
+ +
+ Solid button`} + /> +
+ +
+
+ +
+ Text button`} + /> +
+ +
+
+ +
+ alert('Hey! You clicked me!')} + testId="click-me-button" +> + Click me +`} + /> +
+
+ ); +}; + +export default ButtonComponents; diff --git a/src/pages/ComponentsPage/components/CardComponents.tsx b/src/pages/ComponentsPage/components/CardComponents.tsx new file mode 100644 index 0000000..43ec3f5 --- /dev/null +++ b/src/pages/ComponentsPage/components/CardComponents.tsx @@ -0,0 +1,188 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import { ComponentProperty } from '../model/components'; +import Text from 'common/components/Text/Text'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Card from 'common/components/Card/Card'; +import MessageCard from 'common/components/Card/MessageCard'; + +/** + * Properties for the `CardComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface CardComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `CardComponents` React component renders a set of examples illustrating + * the use of the `Card` family of components. + * @param {CardComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const CardComponents = ({ + className, + testId = 'components-card', +}: CardComponentsProps): JSX.Element => { + const columnHelper = createColumnHelper(); + const cardData: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const messageCardData: ComponentProperty[] = [ + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'iconProps', + description: 'Optional. Icon properties object.', + }, + { + name: 'message', + description: 'Messasge text.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'title', + description: 'Optional. Title text.', + }, + ]; + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+
+ + Card Component + + +
+ The Card component displays block container + for a group of related content. +
+ +
+ + Properties + + data={cardData} columns={columns} /> +
+ + Examples +
+
+ I am the card content. +
+ I am the card content.`} /> +
+ +
+
+ + I am the card content. + +
+ + I am the card content. +`} + /> +
+
+ +
+ + MessageCard Component + + +
+ The MessageCard component displays block + container for displaying messages. The card consists of a message with optional title and + icon. +
+ +
+ + Properties + + data={messageCardData} columns={columns} /> +
+ + Examples +
+
+ +
+ `} /> +
+ +
+
+ +
+ `} + /> +
+ +
+
+ +
+ `} + /> +
+
+
+ ); +}; + +export default CardComponents; diff --git a/src/pages/ComponentsPage/components/TextComponents.tsx b/src/pages/ComponentsPage/components/TextComponents.tsx new file mode 100644 index 0000000..b164b3a --- /dev/null +++ b/src/pages/ComponentsPage/components/TextComponents.tsx @@ -0,0 +1,121 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Text from 'common/components/Text/Text'; +import { ComponentProperty } from '../model/components'; +import Table from 'common/components/Table/Table'; + +/** + * Properties for the `TextComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface TextComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `TextComponents` React component renders a set of examples illustrating + * the use of the `Text` component. + * @param {TextComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const TextComponents = ({ + className, + testId = 'components-text', +}: TextComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'variant', + description: 'Optional. Applies default styling. Default: body copy', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Text Component + + +
+ The Text component displays blocks of text + which is styled in a standardized way. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ Heading 1 +
+ Heading 1`} /> +
+ +
+
+ Heading 2 +
+ Heading 2`} /> +
+ +
+
+ Heading 3 +
+ Heading 3`} /> +
+ +
+
+ + This is standard body copy text. It may be styled in various ways such as{' '} + bold or italic, as{' '} + underlined or{' '} + strikethrough. + +
+ + This is the standard body copy text. It may be styled in various ways such as{' '} + bold or italic, as{' '} + underlined or{' '} + strikethrough. +`} + /> +
+
+ ); +}; + +export default TextComponents; diff --git a/src/pages/ComponentsPage/components/__tests__/AvatarComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/AvatarComponents.test.tsx new file mode 100644 index 0000000..97f4267 --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/AvatarComponents.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import AvatarComponents from '../AvatarComponents'; + +describe('AvatarComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-avatar'); + + // ASSERT + expect(screen.getByTestId('components-avatar')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-avatar'); + + // ASSERT + expect(screen.getByTestId('components-avatar').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx new file mode 100644 index 0000000..f23435b --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import BadgeComponents from '../BadgeComponents'; + +describe('BadgeComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-badge'); + + // ASSERT + expect(screen.getByTestId('components-badge')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-badge'); + + // ASSERT + expect(screen.getByTestId('components-badge').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx new file mode 100644 index 0000000..3d14f01 --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import ButtonComponents from '../ButtonComponents'; +import userEvent from '@testing-library/user-event'; + +describe('ButtonComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-button'); + + // ASSERT + expect(screen.getByTestId('components-button')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-button'); + + // ASSERT + expect(screen.getByTestId('components-button').classList).toContain('custom-className'); + }); + + it('should display alert', async () => { + // ARRANGE + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + render(); + await screen.findByTestId('components-button'); + + // ACT + await userEvent.click(screen.getByTestId('click-me-button')); + + // ASSERT + expect(alertSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx new file mode 100644 index 0000000..954661a --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import CardComponents from '../CardComponents'; + +describe('CardComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-card'); + + // ASSERT + expect(screen.getByTestId('components-card')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-card'); + + // ASSERT + expect(screen.getByTestId('components-card').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/components/__tests__/TextComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/TextComponents.test.tsx new file mode 100644 index 0000000..a8e6111 --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/TextComponents.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import TextComponents from '../TextComponents'; + +describe('TextComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-text'); + + // ASSERT + expect(screen.getByTestId('components-text')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-text'); + + // ASSERT + expect(screen.getByTestId('components-text').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/components/avatar-picture.png b/src/pages/ComponentsPage/components/avatar-picture.png new file mode 100644 index 0000000000000000000000000000000000000000..5716c5a72daac0e8b7a0228cbd716d14ed2128cc GIT binary patch literal 6269 zcmV-@7=q`CP);xWQoFE8-AV3fxUjsNm{)hP-43JC!kBxX4+kz~Q_I)KO?%iaw+57VL<=(1u z@}XWoHpwP?At`^z0l{W>zk92Gb@ru5s*3-gi2vLh_(n-p?Vo!O-yDgE_x{hr;am0S zeruMhI_HRpz5yLpRU%sYEr6vzx!+XhEweG^n*fYOkW2p#L==G2{lo9Q7m*#E6Zf)!C z?(gXA%Cxp-TG}{3CJO4HYd?bqT5Ii6@wr=(u1hHZ)k?WEHF0fp_|mn}%cWvLRlJw! z$+4-)iR|25QmHyGB0j7tEDTn_7;D*NT3fsN`ZsOavSaVT2Om1pKQP$V-eHUdKu`sU z)?i;%RYU@BZ&H?f0a#T;7UpI?e(&wqe*MeyU!RQQ8Z(OuGpB7TvXL%|!=e@o5wQ{g zWm1MEQja*U#o`7Aw?6&C_a6P$)7y9LiPBBPOl#q=h=>S)ThZPv;P9+>ZgOn&?LYqh z_rLzd%;ZEmN)sEP4pAXt6CffG8j1i=V`Tz}09675E=j`FShZNppZxrzGpD|E;o$EVsX}uL3-7=A#;5PU<6L~xQUPr}Y+Qw9TPI)wrpK>+ z{pII{T()j}?4M00#NK=F{fb<@;Z^HcUR5iV(%F+=oIibvmbjVw zM+6w)@`dy7yzyFYVOCWefz+LC@~yN!4(rV1#MfVbUd-i~&3zQ-B}AZNp>XD_&o5s* z=bZCi0@_tda)Z_b*d^}jmCIL#FB)U87+T&})CwpRDpTX*=g)jyDi&0giC47Ax6+y) zP}O`scj^4u`Po@E<{K-Bg)_}9ESx=ca(Z%tnTXe;Mr#8clqWksH$FNN*Q&R7Mb!9n zPcq%c8=P}vmoJT9z0B)<>zX<2l4Ndra(d#Ls@!qS#oQ63wD`}vBUL4)?A+YM=oOd5 z>wC%C025K1#8VUF(~}dxofMa#3TbW49Ne?JwWWoLm+)!L$aOJMF@|fk>eVYF3v;vU zWASPM>mtRq>fFp!F<(G~b+-@!O5~h-VB6O3KmYW>-8+q8(gxx+ZdG+j8H`kQc6xGV zdQ#VAE7x4{C>M*<6W6>;Y!uy2g^A!r+nQS*J92pMwymw{RH0Hndw$r902(&E8r4?1 zUNNdGttvAYa=Gln{91P&Xw@7J8&nkX*@8QV#=#f96xes|E}#RYqky!{HyQ3 zba3xZV;Eq7TAG>^3?-o;Cj?MEV;~5WKm;ZQ6in1GQ3^0qrCeN?nMvY!9r62W0TVQ! zsg%kK^K*;w#w`mf00^kBr|ZapeXY$I6^+>J*gW`y@4c{N+h#D5BpDj$-@Ca_yl3@F zN`Zj{Kv=;Fg$VqzfKJoC;o~~{pFq1+7L_}K$HvaUlUimjadR0k38X5k# z&ph?R6AwEdD?tE(rqii|JGL_PHfe92#QR#MoUF?;t<5bZaa_#j#LF^(6#%@dG3Lb4 z2ZuIov;=?zp!(*?8g6fHdgjFO|NN65PR-{pOica7@dH14;^DSb1o1+MYpyesdg9;% z9!dmKQVR zfzL!hWO(0>?f>ri6D^Sy=UzB^@ISrs%u^2?XpYjgYJ6^V^4RX7XAkc)%wT1BsLE37 zs1T_4UR2hOchznwRMlJ(mx~2mEV%|Kb-YAEdT95q7oK=*Lw6Sx0;wMZNU``3k$Sak zHg$EzT{beB5_w?5rlH=xXAW+E`_%BKBUj!#bKx()b-X#UzxnV>Yb-&ba)U<*MqPCs z1^U$j4sAE@olBDCEGI;?r@P~cM~-aYyvZ;FszgMnb4M4MEJ}uyKui&lCXXO1dph6v>`b9jX0nU>P_UXPVz#RcVAchgKnhV6(STDyAoU{TaA3#K zD=$8GVE3+6WQ{fFFOB~4x4)g7o>5R+Yx7^f{PO-CL&kc@VzRD)uoK5B#u_M#@&EAq zw?95R+}hOS)IYj^*Y_TMxUZw_uOC0Wv#;lWzV_~HvCOva#F#jh$wX1KCa7x!yoh^A z{7fpcX+!_>Pd<6#*n>SCZMkgz%GmsTq43U!AM880|3?S*8;`T+&;RPTziIjLkGBnM zBp~=(FWz}iWNl;sB#{66=0~|~>A(Ky71Knya_y~8zWDh&?;byR*v8cZI|hIH!ij%+ z_p{O2ftCLI`_%bjHSyk;3WZ9!vJRuQ7C%gCtVyR+s$QWW z>g(I_fLQFuU+h3`{O&q7e}A@?hhV(`o(Lr^E>w+uuLM(J5hzT zku@oZXSM?A@9ygA>HJ@>e+ZX)>92nB$csPzmruTa>gbVIzWrEBa}$veq`ST42ag|l z>G5 zRdYGt(<{y!W{#l6idO}Zss|9ecw%BElS0)czx&nCd%C;ZrBb&<}>Qc8BT3e)*N~N1y+QRYxLS)e(B`}du8XDNp)zfM$7bfRME?wHuH;{^qXkx9k zktLt_a;_0Q8ITq1+1#H_ry!J{n7(r9!nWS-=2Y|2o-ipG#EU7giimgXXm_k0uo59k zrQ16?$r@SgZ!SG!0swL@-oIn}-#+{7{%srE+U=8v_x;UZe5b9sC1PvY2%yD^lenRM z3QC|ycWnO;PoL;%H688I^ZRza`qX^8R&#I%NTbc1Z@FRu7xILauM zYU}Jy+q8;+SVg^xgjTN#DS<#>L`_eA>&e|igW2qS&xXOa4I3hxCa5S#)zxw=3^W2m zHAD&X%g>y6bk|@lsSONnZtCcaMOjJ1xPwAMb5;cqf><_e?Rp}u(3@F0_OUF56S3BI zbab?~x91nKWHeVQ&1G}qyk%480H9D2moRs1-Ptj;+Zq5QAqgO8HTKmSi7qVLm$0#HO zsK8gmow_>t*~KefyfL=9wLRUGSqETCL{Sul-g%NFp>we;w?h%HwXLmxy9+iy zJTm>^Cl~U?YRWR(sJ*+VxutbIfWw!d#-SM((B6tUNOfC#$Cjb(pQV~ql|d5wvsW(5 zc+}S3)~_9`Oblzdkr)C91u$hGiPhDV8XdSLcK4lb6aPJLO}?=!X>U$EF~_nhAf-q z`lkwoBo*}uuIGv?k}9a0B-KjHWIzJ)-b+tk@8Fg#VXNy0T>qZgTFcC`NJ|!10MVvNU z81(|8F~)?!+e)QUtHp7gI43P_9heZtIEQUrz zLc*=>slF|?cN4dDOL&$;?}?U`afQwni)Tj0OOwUwY?)MvdHe1?59~V_hV<4!E6uzx z6H%>J15rAYX>M+5YD%}Zwe8rwZ}YYtBA$pq#LTrMK0iD%Ik#{llN72UY6$ucW=>}~ z(*}jsE9?yA5VLs5b%qmaF1b84cWo|fjR~f{qq}qOp@;f54z8!11z^SGAqcY<$uzg9 zNSxGEMZDj>|PH*2bxNFOxWe&=#N{Ax8GGclM zquwF47Q_r^2ml#TU&tmOyd%ABWUUg1wj)3_=f1u=ed_ACs1Q^HJw3h0PJC-<$1V;J zuWPPkWlazfG7KVBL{XH4%xMtY+3<=7a|p%n$N%f`Ps{pbCy^k zO--4@4^uo(O+`fD7-Oo>$pZ)A-cm71M!-hGEq9}@j@AKY^Y80h1EiLWs z?cybuov&6)1ZB4QVzF2*b+otkbaq%`QWYXjWw@tXTbsG351rlG)^s)zW z2&+)6R^L2z;hobLiMgX1~zYwqSW2sTn}JXjpI0u6Cze9 z8xuuQ9M?poxhd_Fpw^5Jm}}NYR+ufIM}msQ*Up-H4Fs*bFc8O1+e!%G~J_AN=2P>-aBJVDxI>% zF1_=WBuNs-&N)?)!UCUqS8F*+x7xN&)7%0!Qq3jfmqV#Tyuc+yBtr+94nLDlMaD#n zq3J!t*`QUprNs%FP#AE|C2>qd%w%o4kxqso&+tevZ>Znqa3zK>7pYWAN_pSXYSJy@ zYbpv!;FUxOBH*S}x}~`}bQ{CU_Z;oYfEyQ`0_Jo&-RS5v5QgwGjA%FRnKjq7YGrkUi_pbZuZ`@7k366U<9uuP!aDDS5PXuV$Mm0G(^_*&fI+-L9Wwn0XNXDJh2+CQ5hBy=M!rz5jRp@ zg~AXFky>jbO{J;1oxA#cIqSl)|MyT5EIVMq0*7-^APc?Y|{5-1v!xpNn_ui@vx}o|-F7%$KKjO3xPR zCK2(@i|4pPq!3c7=LZBx7I2fn{S%j@?OI<5a3Uv!SPDQ+rcSiqoc^ z#M~8=Z|3l=l6q@okw{iPW4=+W9~~ClMP%jfu8;iof_c z08^js0obHH;2B&iatsGlrEtZ6H7D~-HWf8@HuY_7-L<3pKu^m+#3^&bsOk-DSR3Gg z_NMEYjV%%+NQ4p+my8#N&n$d8T^KFZiixjzbqXbb4Y6evIMq-w(d8;QOUE|CgGNDg zU2+8;V1ZWRV(iKb<+1bgpLJ$7Zs~YnSJ$Dg=57K2qgZTCtd8xraW=ushVhK-TYf25>cX=+Sq&7{t!#Dr|(GG zHm^ye>v58Zq*8ZIN7Rca_PfgqXDtX`eDI@y5TMG7{j{RiVn`ZfnZ5Vk)iJNQ zn-Gb3(z4VX{LKh4)7PP?O5R4Uc8a8d?zSy?4&l6W!ZArV#IEN@MSh zzBW344oXmfL8Nu9Ye+01B9oIsNh?ak%+?x(iubDv6&0!pTu#dVhCw?bFo=Y-K4DD0 zD*2DEz46ub2US;DeU8hkkz^$;YjGrddL(yR?#QXo4JcBaMPA{vx%#rZYx)@4`n5DT878`R7~D@Wg@Rc#MT<` z#fx9+lr20cE)nu5e~?_Nkc%2a)_0`Pez%q { + const { t } = useTranslation(); + const { data: user } = useGetCurrentUser(); + + return ( + +
+
+
+ {user ? ( +

+ {t('welcome', { ns: 'common' })}{' '} + {user.name} +

+ ) : ( + + )} +
+ + {user && } +
+
+
+ ); +}; + +export default DashboardPage; diff --git a/src/pages/DashboardPage/__tests__/DashboardPage.test.tsx b/src/pages/DashboardPage/__tests__/DashboardPage.test.tsx new file mode 100644 index 0000000..ecf50d6 --- /dev/null +++ b/src/pages/DashboardPage/__tests__/DashboardPage.test.tsx @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { User } from 'common/api/useGetUser'; +import { userFixture1 } from '__fixtures__/users'; +import * as UseGetCurrentUser from 'common/api/useGetCurrentUser'; + +import DashboardPage from '../DashboardPage'; + +describe('DashboardPage', () => { + const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); + + beforeEach(() => { + useGetCurrentUserSpy.mockReturnValue({ + data: userFixture1, + } as unknown as UseQueryResult); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-dashboard'); + + // ASSERT + expect(screen.getByTestId('page-dashboard')).toBeDefined(); + }); + + it('should render user information when available', async () => { + // ARRANGE + render(); + await screen.findByText(userFixture1.name); + + // ASSERT + expect(screen.getByTestId('user-display-name').textContent).toBe(userFixture1.name); + }); + + it('should render loader when user not available', async () => { + // ARRANGE + useGetCurrentUserSpy.mockReturnValueOnce({ data: undefined } as unknown as UseQueryResult< + User, + Error + >); + render(); + await screen.findByTestId('page-dashboard'); + + // ASSERT + expect(screen.getByTestId('page-dashboard-loader')).toBeDefined(); + }); +}); diff --git a/src/pages/ErrorPage/ErrorPage.tsx b/src/pages/ErrorPage/ErrorPage.tsx new file mode 100644 index 0000000..ab310e0 --- /dev/null +++ b/src/pages/ErrorPage/ErrorPage.tsx @@ -0,0 +1,45 @@ +import { Link, Navigate, isRouteErrorResponse, useRouteError } from 'react-router-dom'; + +/** + * The `ErrorPage` component is a page rendered when an uncaught error is + * thrown from a child path in the router hierarchy. + * The `ErrorPage` component is used in the `errorElement` of the React + * Router configuration. + * @returns {JSX.Element} JSX + */ +const ErrorPage = (): JSX.Element => { + const error = useRouteError(); + + const getMessage = (error: unknown): string => { + let message = 'An unknown problem has occurred.'; + if (isRouteErrorResponse(error)) { + message = error.statusText; + } else if (error instanceof Error) { + message = error.message; + } + return message; + }; + + // Route not found + if (isRouteErrorResponse(error) && error.status === 404) { + return ; + } + + return ( +
+
+
+
We seem to be experiencing a problem.
+
+
{getMessage(error)}
+
+ + Go back + +
+
+
+ ); +}; + +export default ErrorPage; diff --git a/src/pages/ErrorPage/__tests__/ErrorPage.test.tsx b/src/pages/ErrorPage/__tests__/ErrorPage.test.tsx new file mode 100644 index 0000000..92ffa31 --- /dev/null +++ b/src/pages/ErrorPage/__tests__/ErrorPage.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import ErrorPage from '../ErrorPage'; + +describe('ErrorPage', () => { + it.skip('should render successfully', async () => { + // ARRANGE + // render the React element into the DOM + render(); + // wait before throwing an error if it cannot find an element + await screen.findByTestId('page-error'); + + // ASSERT + // assert the element has rendered + expect(screen.getByTestId('page-error')).toBeDefined(); + }); +}); diff --git a/src/pages/LandingPage/LandingPage.tsx b/src/pages/LandingPage/LandingPage.tsx new file mode 100644 index 0000000..7369a13 --- /dev/null +++ b/src/pages/LandingPage/LandingPage.tsx @@ -0,0 +1,36 @@ +import { Navigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from 'common/hooks/useAuth'; +import Page from 'common/components/Page/Page'; + +/** + * The `LandingPage` component renders the content of the landing page + * for unauthenticated users. This is the public landing page. + * + * If an authenticated user navigates to this page, they are redirected to + * the `DashboardPage`. + * @returns {JSX.Element} JSX + */ +const LandingPage = (): JSX.Element => { + const { t } = useTranslation(); + const authContext = useAuth(); + + if (authContext.isAuthenticated) { + return ; + } + + return ( + +
+

+ {t('letsGetStarted', { ns: 'common' })} +

+ +
{t('creatingReactApps', { ns: 'common' })}
+
+
+ ); +}; + +export default LandingPage; diff --git a/src/pages/LandingPage/__tests__/LandingPage.test.tsx b/src/pages/LandingPage/__tests__/LandingPage.test.tsx new file mode 100644 index 0000000..63803a5 --- /dev/null +++ b/src/pages/LandingPage/__tests__/LandingPage.test.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import * as UseAuth from 'common/hooks/useAuth'; + +import LandingPage from '../LandingPage'; +import { Route, Routes } from 'react-router-dom'; + +describe('LandingPage', () => { + const useAuthSpy = vi.spyOn(UseAuth, 'useAuth'); + + beforeEach(() => { + useAuthSpy.mockReturnValue({ + isAuthenticated: false, + }); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-landing'); + + // ASSERT + expect(screen.getByTestId('page-landing')).toBeDefined(); + }); + + it('should render LandingPage when not authenticated', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-landing'); + + // ASSERT + expect(screen.getByTestId('page-landing')).toBeDefined(); + }); + + it('should navigate when authenticated', async () => { + // ARRANGE + useAuthSpy.mockReturnValueOnce({ + isAuthenticated: true, + }); + render( + + } /> +
} /> + , + ); + await screen.findByTestId('success'); + + // ASSERT + expect(screen.getByTestId('success')).toBeDefined(); + }); +}); diff --git a/src/pages/SettingsPage/SettingsPage.tsx b/src/pages/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000..349de10 --- /dev/null +++ b/src/pages/SettingsPage/SettingsPage.tsx @@ -0,0 +1,47 @@ +import { Outlet } from 'react-router-dom'; + +import { useGetCurrentUser } from 'common/api/useGetCurrentUser'; +import Avatar from 'common/components/Icon/Avatar'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import MenuNavLink from 'common/components/Menu/MenuNavLink'; +import Page from 'common/components/Page/Page'; + +/** + * The `SettingsPage` component renders the layout for the settings page. It + * provides an `Outlet` for displaying settings sub-pages. + * @returns {JSX.Element} JSX + */ +const SettingsPage = (): JSX.Element => { + const { data: user } = useGetCurrentUser(); + + return ( + +
+ {user ? ( +
+ +
+ {user.name} ({user.username}) +
+
+ ) : ( +
+ +
+ )} +
+
+ + Appearance + +
+
+ +
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/src/pages/SettingsPage/__tests__/SettingsPage.test.tsx b/src/pages/SettingsPage/__tests__/SettingsPage.test.tsx new file mode 100644 index 0000000..c016ee8 --- /dev/null +++ b/src/pages/SettingsPage/__tests__/SettingsPage.test.tsx @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; + +import { User } from 'common/api/useGetUser'; +import * as UseGetCurrentUser from 'common/api/useGetCurrentUser'; +import { userFixture1 } from '__fixtures__/users'; + +import SettingsPage from '../SettingsPage'; + +describe('SettingsPage', () => { + const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); + + beforeEach(() => { + useGetCurrentUserSpy.mockReturnValue({ + data: userFixture1, + } as unknown as UseQueryResult); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-settings'); + + // ASSERT + expect(screen.getByTestId('page-settings')).toBeDefined(); + }); + + it('should render header information', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-settings-header'); + + // ASSERT + expect(screen.getByTestId('page-settings-header')).toBeDefined(); + }); + + it('should render header loader', async () => { + // ARRANGE + useGetCurrentUserSpy.mockReturnValue({ + data: null, + } as unknown as UseQueryResult); + + render(); + await screen.findByTestId('page-settings-header-loader'); + + // ASSERT + expect(screen.getByTestId('page-settings-header-loader')).toBeDefined(); + }); +}); diff --git a/src/pages/SettingsPage/components/AppearanceSettings.tsx b/src/pages/SettingsPage/components/AppearanceSettings.tsx new file mode 100644 index 0000000..663e244 --- /dev/null +++ b/src/pages/SettingsPage/components/AppearanceSettings.tsx @@ -0,0 +1,84 @@ +import { PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import { useSettings } from 'common/hooks/useSettings'; +import { useSetSettings } from 'common/api/useSetSettings'; +import SettingsHeading from './SettingsHeading'; + +/** + * Properties for the `AppearanceSettings` component. + * @see {@link PropsWithTestId} + */ +interface AppearanceSettingsProps extends PropsWithTestId {} + +/** + * The `AppearanceSettings` React component renders a set of controls to + * allow users to update personal configuration related to how the application + * appears. + * @param {AppearanceSettingsProps} props - Component properties, `AppearanceSettingsProps`. + * @returns {JSX.Element} JSX + */ +const AppearanceSettings = ({ + testId = 'settings-appearance', +}: AppearanceSettingsProps): JSX.Element => { + const settings = useSettings(); + const { mutate: setSettings } = useSetSettings(); + + return ( +
+ Theme + +
Choose how the application looks to you.
+ +
+
setSettings({ theme: 'light' })} + data-testid={`${testId}-theme-light`} + > +
+
+
+
+
+
+
+
+
+
+
Light
+
+ +
setSettings({ theme: 'dark' })} + data-testid={`${testId}-theme-dark`} + > +
+
+
+
+
+
+
+
+
+
+
Dark
+
+
+
+ ); +}; + +export default AppearanceSettings; diff --git a/src/pages/SettingsPage/components/SettingsHeading.tsx b/src/pages/SettingsPage/components/SettingsHeading.tsx new file mode 100644 index 0000000..013ccdf --- /dev/null +++ b/src/pages/SettingsPage/components/SettingsHeading.tsx @@ -0,0 +1,28 @@ +import { PropsWithTestId } from '@leanstacks/react-common'; +import { PropsWithChildren } from 'react'; + +/** + * Properties for the `SettingsHeading` component. + * @see {@link PropsWithChildren} + * @see {@link PropsWithTestId} + */ +interface SettingsHeadingProps extends PropsWithChildren, PropsWithTestId {} + +/** + * The `SettingsHeading` component renders a standardized page/section + * heading for Settings pages. + * @param {SettingsHeadingProps} props - Component properties, `SettingsHeadingProps`. + * @returns {JSX.Element} JSX + */ +const SettingsHeading = ({ + children, + testId = 'settings-heading', +}: SettingsHeadingProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default SettingsHeading; diff --git a/src/pages/SettingsPage/components/__tests__/AppearanceSettings.test.tsx b/src/pages/SettingsPage/components/__tests__/AppearanceSettings.test.tsx new file mode 100644 index 0000000..1ef6502 --- /dev/null +++ b/src/pages/SettingsPage/components/__tests__/AppearanceSettings.test.tsx @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UseMutationResult } from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { Settings } from 'common/api/useGetSettings'; +import { DEFAULT_SETTINGS } from 'common/utils/constants'; +import * as UseSettings from 'common/hooks/useSettings'; +import * as UseSetSettings from 'common/api/useSetSettings'; + +import AppearanceSettings from '../AppearanceSettings'; + +describe('AppearanceSettings', () => { + const useSettingsSpy = vi.spyOn(UseSettings, 'useSettings'); + const useSetSettingsSpy = vi.spyOn(UseSetSettings, 'useSetSettings'); + const mockSetSettings = vi.fn(); + + beforeEach(() => { + useSettingsSpy.mockReturnValue(DEFAULT_SETTINGS); + + useSetSettingsSpy.mockReturnValue({ + mutate: mockSetSettings, + } as unknown as UseMutationResult>); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('settings-appearance'); + + // ASSERT + expect(screen.getByTestId('settings-appearance')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should set dark theme', async () => { + // ARRANGE + render(); + await screen.findByTestId('settings-appearance'); + + // ACT + await userEvent.click(screen.getByTestId('settings-appearance-theme-dark')); + + // ASSERT + expect(mockSetSettings).toHaveBeenCalled(); + expect(mockSetSettings).toHaveBeenCalledWith({ theme: 'dark' }); + }); + + it('should set light theme', async () => { + // ARRANGE + useSettingsSpy.mockReturnValue({ ...DEFAULT_SETTINGS, theme: 'dark' }); + render(); + await screen.findByTestId('settings-appearance'); + + // ACT + await userEvent.click(screen.getByTestId('settings-appearance-theme-light')); + + // ASSERT + expect(mockSetSettings).toHaveBeenCalled(); + expect(mockSetSettings).toHaveBeenCalledWith({ theme: 'light' }); + }); +}); diff --git a/src/pages/SettingsPage/components/__tests__/SettingsHeading.test.tsx b/src/pages/SettingsPage/components/__tests__/SettingsHeading.test.tsx new file mode 100644 index 0000000..302d88e --- /dev/null +++ b/src/pages/SettingsPage/components/__tests__/SettingsHeading.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import SettingsHeading from '../SettingsHeading'; + +describe('SettingsHeading', () => { + it('should render successfully', async () => { + // ARRANGE + render(Test); + await screen.findByTestId('settings-heading'); + + // ASSERT + expect(screen.getByTestId('settings-heading')).toBeDefined(); + expect(screen.getByText('Test')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(Test); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); +}); diff --git a/src/pages/SigninPage/SigninPage.tsx b/src/pages/SigninPage/SigninPage.tsx new file mode 100644 index 0000000..b5fa64f --- /dev/null +++ b/src/pages/SigninPage/SigninPage.tsx @@ -0,0 +1,22 @@ +import Page from 'common/components/Page/Page'; +import SigninForm from './components/SigninForm'; + +/** + * The `SigninPage` component renders the content for a user authentication + * page. + * @returns {JSX.Element} JSX + */ +const SigninPage = (): JSX.Element => { + return ( + +
+
+
Sign In
+ +
+
+
+ ); +}; + +export default SigninPage; diff --git a/src/pages/SigninPage/__tests__/SigninPage.test.tsx b/src/pages/SigninPage/__tests__/SigninPage.test.tsx new file mode 100644 index 0000000..1989479 --- /dev/null +++ b/src/pages/SigninPage/__tests__/SigninPage.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import SigninPage from '../SigninPage'; + +describe('SigninPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-signin'); + + // ASSERT + expect(screen.getByTestId('page-signin')).toBeDefined(); + }); +}); diff --git a/src/pages/SigninPage/api/__tests__/useSignin.test.ts b/src/pages/SigninPage/api/__tests__/useSignin.test.ts new file mode 100644 index 0000000..16977ac --- /dev/null +++ b/src/pages/SigninPage/api/__tests__/useSignin.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; +import storage from 'common/utils/storage'; + +import { useSignin } from '../useSignin'; + +describe('useSignin', () => { + const setItemSpy = vi.spyOn(storage, 'setItem'); + + it('should signin successfully', async () => { + // ARRANGE + let isSuccess = false; + const { result } = renderHook(() => useSignin()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate('Bret', { + onSuccess: () => { + isSuccess = true; + }, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ARRANGE + expect(isSuccess).toBe(true); + expect(setItemSpy).toHaveBeenCalledTimes(2); + }); + + it('should error if user not found', async () => { + // ARRANGE + const { result } = renderHook(() => useSignin()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate('NotFound'); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ARRANGE + expect(setItemSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/SigninPage/api/useSignin.ts b/src/pages/SigninPage/api/useSignin.ts new file mode 100644 index 0000000..c24d070 --- /dev/null +++ b/src/pages/SigninPage/api/useSignin.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import find from 'lodash/find'; + +import { User } from 'common/api/useGetUser'; +import { UserTokens } from 'common/api/useGetUserTokens'; +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import storage from 'common/utils/storage'; +import { QueryKeys, StorageKeys } from 'common/utils/constants'; + +/** + * An API hook which performs user authentication. + * @returns Returns a `UseMutationResult` with `User` data. + */ +export const useSignin = () => { + const queryClient = useQueryClient(); + const axios = useAxios(); + const config = useConfig(); + + /** + * Attempts to authenticate a user. + * @param username - A username value. + * @returns Returns a Promise which resolves to a `User` if successful, + * otherwise throws an Error. + */ + const signin = async (username: string): Promise => { + // REPLACE: This is a contrived "signin" approach for demonstration purposes. + // You should implement authentication functionality in accordance + // with your IdP. + + // fetch all users + const response = await axios.request({ + url: `${config.VITE_BASE_URL_API}/users`, + }); + + // if user matching 'username' is found, consider the user to be authenticated. + const user = find(response.data, { username }); + if (user) { + // store current user in localstorage + storage.setItem(StorageKeys.User, JSON.stringify(user)); + + // simlate the creation of authentication tokens + const expires_at = dayjs().add(1, 'hour').toISOString(); + const tokens: UserTokens = { + access_token: 'access-token', + id_token: 'id-token', + refresh_token: 'refresh-token', + token_type: 'bearer', + expires_in: 3600, + expires_at, + }; + storage.setItem(StorageKeys.UserTokens, JSON.stringify(tokens)); + + return user; + } else { + throw new Error('Authentication failed.'); + } + }; + + return useMutation({ + mutationFn: signin, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.UserTokens] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.Users, 'current'] }); + }, + }); +}; diff --git a/src/pages/SigninPage/components/SigninForm.tsx b/src/pages/SigninPage/components/SigninForm.tsx new file mode 100644 index 0000000..1eb7fff --- /dev/null +++ b/src/pages/SigninPage/components/SigninForm.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import classNames from 'classnames'; +import { Alert, AlertVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { Form, Formik } from 'formik'; +import { object, string } from 'yup'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@leanstacks/react-common'; + +import { useSignin } from '../api/useSignin'; +import TextField from 'common/components/Form/TextField'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `SigninForm` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface SigninFormProps extends PropsWithClassName, PropsWithTestId {} + +/** + * Signin form values. + */ +interface SigninFormValues { + username: string; + password: string; +} + +/** + * Signin form validation schema. + */ +const validationSchema = object({ + password: string() + .matches(/[0-9]/, 'Must have a number. ') + .matches(/[a-z]/, 'Must have a lowercase letter. ') + .matches(/[A-Z]/, 'Must have an uppercase letter. ') + .matches(/[$*.[{}()?"!@#%&/,><':;|_~`^\]\\]/, 'Must have a special character. ') + .min(12, 'Must have at least 12 characters. ') + .required('Required. '), + username: string().required('Required.'), +}); + +/** + * The `SigninForm` component renders a form for user authentication. + * + * Upon successful authentication, navigates the user to the authenticated + * landing page of the application. + * + * Upon error, displays messages. + * + * @param {SigninFormProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const SigninForm = ({ className, testId = 'form-signin' }: SigninFormProps): JSX.Element => { + const [error, setError] = useState(''); + const { mutate: signin } = useSignin(); + const navigate = useNavigate(); + + return ( +
+ {error && ( + + + {error} + + )} + + initialValues={{ username: '', password: '' }} + validationSchema={validationSchema} + onSubmit={(values, { setSubmitting }) => { + setError(''); + signin(values.username, { + onSuccess: () => { + setSubmitting(false); + navigate('/'); + }, + onError: (err: Error) => { + setError(err.message); + setSubmitting(false); + }, + }); + }} + > + {({ dirty, isSubmitting }) => ( +
+ + + + + )} + +
+ ); +}; + +export default SigninForm; diff --git a/src/pages/SigninPage/components/__tests__/SigninForm.test.tsx b/src/pages/SigninPage/components/__tests__/SigninForm.test.tsx new file mode 100644 index 0000000..94a2926 --- /dev/null +++ b/src/pages/SigninPage/components/__tests__/SigninForm.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; + +import SigninForm from '../SigninForm'; + +// mock select functions from react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +describe('SigninForm', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-signin'); + + // ASSERT + expect(screen.getByTestId('form-signin')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-signin'); + + // ASSERT + expect(screen.getByTestId('form-signin').classList).toContain('custom-className'); + }); + + it('should navigate upon successful signin', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-signin'); + + // ACT + await userEvent.type(screen.getByLabelText('Username'), 'Bret'); + await userEvent.type(screen.getByLabelText('Password'), 'aB1!12345678'); + await userEvent.click(screen.getByTestId('form-signin-button-submit')); + + // ASSERT + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('should display alert on error', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-signin'); + + // ACT + await userEvent.type(screen.getByLabelText('Username'), 'NotAUser'); + await userEvent.type(screen.getByLabelText('Password'), 'aB1!12345678'); + await userEvent.click(screen.getByTestId('form-signin-button-submit')); + await screen.findByTestId('form-signin-alert'); + + // ASSERT + expect(screen.getByTestId('form-signin-alert')).toBeDefined(); + }); +}); diff --git a/src/pages/SignoutPage/SignoutPage.tsx b/src/pages/SignoutPage/SignoutPage.tsx new file mode 100644 index 0000000..4e2172c --- /dev/null +++ b/src/pages/SignoutPage/SignoutPage.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useSignout } from './api/useSignout'; +import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; +import Page from 'common/components/Page/Page'; + +/** + * The `SignoutPage` component deauthenticates the current user and redirects + * to the base URL. + * @returns {JSX.Element} JSX + */ +const SignoutPage = (): JSX.Element => { + const navigate = useNavigate(); + const { mutate: signout } = useSignout(); + + useEffect(() => { + signout(undefined, { + onSuccess: () => { + // setTimeout to simulate network latency or OAuth IdP redirect + setTimeout(() => { + navigate('/'); + }, 1000); + }, + }); + }, [signout, navigate]); + + return ( + +
+
+ +
+
+
+ ); +}; + +export default SignoutPage; diff --git a/src/pages/SignoutPage/__tests__/SignoutPage.test.tsx b/src/pages/SignoutPage/__tests__/SignoutPage.test.tsx new file mode 100644 index 0000000..0069162 --- /dev/null +++ b/src/pages/SignoutPage/__tests__/SignoutPage.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from 'test/test-utils'; + +import SignoutPage from '../SignoutPage'; + +// mock select functions from react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +describe('SignoutPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-signout'); + + // ASSERT + expect(screen.getByTestId('page-signout')).toBeDefined(); + }); + + it('should navigate on successful signout', async () => { + // ARRANGE + render(); + await waitFor(() => expect(mockNavigate).toHaveBeenCalled(), { timeout: 3000 }); + + // ASSERT + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); +}); diff --git a/src/pages/SignoutPage/api/__tests__/useSignout.test.ts b/src/pages/SignoutPage/api/__tests__/useSignout.test.ts new file mode 100644 index 0000000..b7d9bc4 --- /dev/null +++ b/src/pages/SignoutPage/api/__tests__/useSignout.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +import storage from 'common/utils/storage'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { useSignout } from '../useSignout'; + +describe('useSignout', () => { + const removeItemSpy = vi.spyOn(storage, 'removeItem'); + + it('should signout user successfully', async () => { + // ARRANGE + let isSuccess = false; + const { result } = renderHook(() => useSignout()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate(undefined, { + onSuccess: () => { + isSuccess = true; + }, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ARRANGE + expect(isSuccess).toBe(true); + expect(removeItemSpy).toHaveBeenCalledTimes(2); + }); + + it('should error on failure to signout', async () => { + // ARRANGE + removeItemSpy.mockImplementation(() => { + throw new Error('test'); + }); + const { result } = renderHook(() => useSignout()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate(undefined); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ARRANGE + expect(removeItemSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/SignoutPage/api/useSignout.ts b/src/pages/SignoutPage/api/useSignout.ts new file mode 100644 index 0000000..dacd4cf --- /dev/null +++ b/src/pages/SignoutPage/api/useSignout.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, StorageKeys } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * An API hook which deauthenticates the currently authenticated user. + * @returns Returns a `UseMutationResult`. + */ +export const useSignout = () => { + const queryClient = useQueryClient(); + + /** + * Signs out the currently authenticated user. + * @returns Returns a Promise which resolves empty if successfully, + * otherwise rejects with an Error. + */ + const signout = async (): Promise => { + // REPLACE: This is a contrived "signout" approach for demonstration purposes. + // You should implement authentication functionality in accordance + // with your IdP. + + return new Promise((resolve, reject) => { + try { + storage.removeItem(StorageKeys.UserTokens); + storage.removeItem(StorageKeys.User); + resolve(); + } catch (err) { + reject(err); + } + }); + }; + + return useMutation({ + mutationFn: signout, + onSuccess: () => { + queryClient.resetQueries({ queryKey: [QueryKeys.UserTokens] }); + queryClient.resetQueries({ queryKey: [QueryKeys.Users] }); + }, + }); +}; diff --git a/src/pages/UsersPage/Tasks/api/__tests__/useDeleteTask.test.ts b/src/pages/UsersPage/Tasks/api/__tests__/useDeleteTask.test.ts new file mode 100644 index 0000000..ee4bf9e --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/__tests__/useDeleteTask.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { QueryKeys } from 'common/utils/constants'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { todosFixture } from '__fixtures__/todos'; +import { queryClient } from 'test/query-client'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { useDeleteTask } from '../useDeleteTask'; + +describe('useDeleteTask', () => { + it('should delete task', async () => { + // ARRANGE + const taskToDelete = todosFixture[0]; + let isSuccess = false; + const { result } = renderHook(() => useDeleteTask()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { task: taskToDelete }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + }); + + it('should remove cached data if it exists', async () => { + // ARRANGE + const taskToDelete = todosFixture[0]; + queryClient.setQueryData([QueryKeys.Tasks, { userId: taskToDelete.userId }], [taskToDelete]); + let isSuccess = false; + const { result } = renderHook(() => useDeleteTask()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { task: taskToDelete }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + expect( + queryClient.getQueryData([QueryKeys.Tasks, { userId: taskToDelete.userId }])?.length, + ).toBe(0); + }); +}); diff --git a/src/pages/UsersPage/Tasks/api/__tests__/useGetTask.test.ts b/src/pages/UsersPage/Tasks/api/__tests__/useGetTask.test.ts new file mode 100644 index 0000000..81b4da1 --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/__tests__/useGetTask.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; + +import { useGetTask } from '../useGetTask'; + +describe('useGetTask', () => { + it('should render hook successfully', async () => { + // ARRANGE + const { result } = renderHook(() => useGetTask({ taskId: 1 })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.id).toEqual(1); + }); +}); diff --git a/src/pages/UsersPage/Tasks/api/__tests__/useUpdateTask.test.ts b/src/pages/UsersPage/Tasks/api/__tests__/useUpdateTask.test.ts new file mode 100644 index 0000000..1e68530 --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/__tests__/useUpdateTask.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; +import { queryClient } from 'test/query-client'; +import { todosFixture } from '__fixtures__/todos'; +import { QueryKeys } from 'common/utils/constants'; +import { Task } from '../../../api/useGetUserTasks'; + +import { useUpdateTask } from '../useUpdateTask'; + +describe('useUpdateTask', () => { + it('should update task', async () => { + // ARRANGE + const updatedTask = todosFixture[0]; + let isSuccess = false; + const { result } = renderHook(() => useUpdateTask()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { task: updatedTask }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + }); + + it('should create cached data when none exists', async () => { + // ARRANGE + const updatedTask = todosFixture[0]; + let isSuccess = false; + const { result } = renderHook(() => useUpdateTask()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { task: updatedTask }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + expect(queryClient.getQueryData([QueryKeys.Tasks, { userId: updatedTask.userId }])).toEqual([ + updatedTask, + ]); + }); + + it('should update cached data when exists', async () => { + // ARRANGE + const updatedTask = todosFixture[0]; + queryClient.setQueryData([QueryKeys.Tasks, { userId: updatedTask.userId }], todosFixture); + let isSuccess = false; + const { result } = renderHook(() => useUpdateTask()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { task: updatedTask }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + expect( + queryClient.getQueryData([QueryKeys.Tasks, { userId: updatedTask.userId }])?.length, + ).toEqual(todosFixture.length); + }); +}); diff --git a/src/pages/UsersPage/Tasks/api/useDeleteTask.ts b/src/pages/UsersPage/Tasks/api/useDeleteTask.ts new file mode 100644 index 0000000..d20b25c --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/useDeleteTask.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import reject from 'lodash/reject'; + +import { QueryKeys } from 'common/utils/constants'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; + +/** + * The `useDeleteTask` mutation function variables. + */ +export type DeleteTaskVariables = { + task: Task; +}; + +/** + * An API hook which deletes a single `Task`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to delete a `Task`. + * + * When successful, the hook updates cached `Task` query data. + * + * @returns Returns a `UseMutationResult`. + */ +export const useDeleteTask = () => { + const queryClient = useQueryClient(); + const config = useConfig(); + const axios = useAxios(); + + /** + * Delete a `Task`. + * @param {DeleteTaskVariables} variables - The mutation function variables. + */ + const deleteTask = async ({ task }: DeleteTaskVariables): Promise => { + // throw new Error('Failed'); + await axios.request({ + method: 'delete', + url: `${config.VITE_BASE_URL_API}/todos/${task.id}`, + }); + }; + + return useMutation({ + mutationFn: deleteTask, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + [QueryKeys.Tasks, { userId: variables.task.userId }], + (cachedTasks) => (cachedTasks ? [...reject(cachedTasks, { id: variables.task.id })] : []), + ); + }, + }); +}; diff --git a/src/pages/UsersPage/Tasks/api/useGetTask.ts b/src/pages/UsersPage/Tasks/api/useGetTask.ts new file mode 100644 index 0000000..729d619 --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/useGetTask.ts @@ -0,0 +1,38 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; + +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { QueryKeys } from 'common/utils/constants'; + +/** + * Properties for the `useGetTask` hook. + * @param {number} taskId - A `Task` identifier. + */ +interface UseGetTaskProps { + taskId: number; +} + +/** + * An API hook which fetches a single `Task` object by the identifier attribute. + * @param {UseGetTaskProps} props - Hook properties. + * @returns Returns a `UseQueryResult` with `Task` data. + */ +export const useGetTask = ({ taskId }: UseGetTaskProps): UseQueryResult => { + const axios = useAxios(); + const config = useConfig(); + + const getTask = async (): Promise => { + const response = await axios.request({ + url: `${config.VITE_BASE_URL_API}/todos/${taskId}`, + }); + + return response.data; + }; + + return useQuery({ + queryKey: [QueryKeys.Tasks, taskId], + queryFn: () => getTask(), + enabled: !!taskId, + }); +}; diff --git a/src/pages/UsersPage/Tasks/api/useUpdateTask.ts b/src/pages/UsersPage/Tasks/api/useUpdateTask.ts new file mode 100644 index 0000000..0a2d054 --- /dev/null +++ b/src/pages/UsersPage/Tasks/api/useUpdateTask.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import reject from 'lodash/reject'; + +import { QueryKeys } from 'common/utils/constants'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { useConfig } from 'common/hooks/useConfig'; +import { useAxios } from 'common/hooks/useAxios'; + +/** + * The `useUpdateTask` mutation function variables. + */ +export type UpdateTaskVariables = { + task: Task; +}; + +/** + * An API hook which updates a single `Task`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to update a `Task`. + * + * When successful, the hook updates cached `Task` query data. + * @returns Returns a `UseMutationResult`. + */ +export const useUpdateTask = () => { + const queryClient = useQueryClient(); + const config = useConfig(); + const axios = useAxios(); + + /** + * Update a `Task`. + * @param {UpdateTaskVariables} variables - The mutation function variables. + * @returns The updated `Task` object. + */ + const updateTask = async ({ task }: UpdateTaskVariables): Promise => { + const response = await axios.request({ + method: 'put', + url: `${config.VITE_BASE_URL_API}/todos/${task.id}`, + data: task, + }); + return response.data; + }; + + return useMutation({ + mutationFn: updateTask, + onSuccess: (data, variables) => { + // update cached query data + queryClient.setQueryData( + [QueryKeys.Tasks, { userId: variables.task.userId }], + (cachedTasks) => (cachedTasks ? [...reject(cachedTasks, { id: data.id }), data] : [data]), + ); + }, + }); +}; diff --git a/src/pages/UsersPage/Tasks/components/TaskCompleteToggle.tsx b/src/pages/UsersPage/Tasks/components/TaskCompleteToggle.tsx new file mode 100644 index 0000000..f8ad08e --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/TaskCompleteToggle.tsx @@ -0,0 +1,86 @@ +import { + Button, + ButtonVariant, + PropsWithClassName, + PropsWithTestId, +} from '@leanstacks/react-common'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { useUpdateTask } from '../api/useUpdateTask'; +import { useToasts } from 'common/hooks/useToasts'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Propeties for the `TaskCompleteToggle` component. + * @param {Task} task - A Task object. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface TaskCompleteToggleProps extends PropsWithClassName, PropsWithTestId { + task: Task; +} + +/** + * The `TaskCompleteToggle` component renders a `Button` which allows a user + * to toggle the value of the Task `complete` attribute. + * @param {TaskCompleteToggleProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const TaskCompleteToggle = ({ + className, + task, + testId = 'toggle-task-complete', +}: TaskCompleteToggleProps): JSX.Element => { + const { t } = useTranslation(); + const { mutate: updateTask, isPending } = useUpdateTask(); + const { createToast } = useToasts(); + + const buttonTitle = task.completed + ? t('task.markIncomplete', { ns: 'users' }) + : t('task.markComplete', { ns: 'users' }); + + /** + * Actions to perform when the task complete toggle button is clicked. + */ + const handleButtonClick = () => { + updateTask( + { + task: { + ...task, + completed: !task.completed, + }, + }, + { + onSuccess: (data) => { + createToast({ + text: data.completed + ? t('task.markedComplete', { ns: 'users' }) + : t('task.markedIncomplete', { ns: 'users' }), + isAutoDismiss: true, + }); + }, + }, + ); + }; + + return ( + + ); +}; + +export default TaskCompleteToggle; diff --git a/src/pages/UsersPage/Tasks/components/TaskDeleteDialog.tsx b/src/pages/UsersPage/Tasks/components/TaskDeleteDialog.tsx new file mode 100644 index 0000000..c352685 --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/TaskDeleteDialog.tsx @@ -0,0 +1,60 @@ +import { ComponentPropsWithoutRef } from 'react'; + +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import Dialog from 'common/components/Dialog/Dialog'; +import DialogHeading from 'common/components/Dialog/DialogHeading'; +import DialogContent from 'common/components/Dialog/DialogContent'; +import Divider from 'common/components/Dialog/Divider'; +import DialogButtons from 'common/components/Dialog/DialogButtons'; +import DialogButton from 'common/components/Dialog/DialogButton'; + +/** + * Properties for the `TaskDeleteDialog` component. + * @param {function} onCancel - A function called when the cancel button is clicked. + * @param {function} onDelete - A function called when the delete button is clicked. + * @param {Task} task - The `Task` being deleted. + * @see {@link Dialog} + */ +interface TaskDeleteDialogProps extends ComponentPropsWithoutRef { + onCancel: () => void; + onDelete: () => void; + task: Task; +} + +/** + * The `TaskDeleteDialog` renders a dialog prompting for deletion confirmation + * of a `Task`. + * @param {TaskDeleteDialog} props - Component properties. + * @returns {JSX.Element} JSX + */ +const TaskDeleteDialog = ({ + onCancel, + onDelete, + task, + testId = 'dialog-task-delete', + ...dialogProps +}: TaskDeleteDialogProps): JSX.Element => { + return ( + + Are you sure? + + Deleting task {task.title} is permanent. + + + + onCancel()} testId={`${testId}-button-cancel`}> + Cancel + + onDelete()} + variant="danger" + testId={`${testId}-button-delete`} + > + Delete + + + + ); +}; + +export default TaskDeleteDialog; diff --git a/src/pages/UsersPage/Tasks/components/TaskDetail.tsx b/src/pages/UsersPage/Tasks/components/TaskDetail.tsx new file mode 100644 index 0000000..75eb241 --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/TaskDetail.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + AlertVariant, + Button, + ButtonVariant, + PropsWithClassName, + PropsWithTestId, +} from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import Text from 'common/components/Text/Text'; +import { useGetTask } from '../api/useGetTask'; +import { useDeleteTask } from '../api/useDeleteTask'; +import { useGetUser } from 'common/api/useGetUser'; +import { useToasts } from 'common/hooks/useToasts'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; +import Badge from 'common/components/Badge/Badge'; +import FAIcon from 'common/components/Icon/FAIcon'; +import TaskDeleteDialog from './TaskDeleteDialog'; + +/** + * Properties for the `TaskDetail` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface TaskDetailProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `TaskDetail` component displays the attributes of a single `Task`. + * Provides buttons and navigation to perform actions on the Task. + * @param {TaskDetailProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const TaskDetail = ({ className, testId = 'task-detail' }: TaskDetailProps): JSX.Element => { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const navigate = useNavigate(); + const { taskId } = useParams(); + const { createToast } = useToasts(); + + const { + data: task, + error: taskError, + isLoading: isLoadingTask, + } = useGetTask({ taskId: Number(taskId) }); + + const { + data: user, + error: userError, + isLoading: isLoadingUser, + } = useGetUser({ userId: Number(task?.userId) }); + + const { mutate: deleteTask, isPending: isDeletePending, error: deleteError } = useDeleteTask(); + + const doDelete = (task: Task) => { + setIsDeleteDialogOpen(false); + deleteTask( + { task }, + { + onSuccess: () => { + createToast({ + text: `Deleted task ${task.id}`, + isAutoDismiss: true, + }); + navigate(-1); + }, + }, + ); + }; + + return ( +
+
+ + Task + {isLoadingTask && } + {!!task &&
{`#${task.id}`}
} +
+ + + +
+
+ + {taskError && ( + + + {`Unable to retrieve task. Detail: ${taskError.message}`} + + )} + + {userError && ( + + + {`Unable to retrieve user. Detail: ${userError.message}`} + + )} + + {deleteError && ( + + + {`Unable to delete task. Detail: ${deleteError.message}`} + + )} + + {(isLoadingTask || isDeletePending) && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} + + {task && !isDeletePending && ( +
+
+
Title
+
+ {task.title} +
+
+ +
+
Assignee
+
+ {isLoadingUser && } + {user && {user.name}} +
+
+ +
+
Status
+ + {task.completed ? 'COMPLETE' : 'INCOMPLETE'} + +
+ + setIsDeleteDialogOpen(false)} + onClose={() => setIsDeleteDialogOpen(false)} + onDelete={() => doDelete(task)} + task={task} + /> +
+ )} +
+ ); +}; + +export default TaskDetail; diff --git a/src/pages/UsersPage/Tasks/components/__tests__/TaskCompleteToggle.test.tsx b/src/pages/UsersPage/Tasks/components/__tests__/TaskCompleteToggle.test.tsx new file mode 100644 index 0000000..0fbc9b5 --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/__tests__/TaskCompleteToggle.test.tsx @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { todosFixture } from '__fixtures__/todos'; +import * as UseToasts from 'common/hooks/useToasts'; + +import TaskCompleteToggle from '../TaskCompleteToggle'; + +describe('TaskCompleteToggle', () => { + const incompleteTask = todosFixture[0]; + const completeTask = todosFixture[1]; + + const useToastsSpy = vi.spyOn(UseToasts, 'useToasts'); + const mockCreateToast = vi.fn(); + + beforeEach(() => { + useToastsSpy.mockReturnValue({ + createToast: mockCreateToast, + removeToast: vi.fn(), + toasts: [], + }); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + + // ASSERT + expect(screen.getByTestId('toggle-task-complete')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + + // ASSERT + expect(screen.getByTestId('toggle-task-complete').classList).toContain('custom-className'); + }); + + it('should render incomplete task', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + + // ASSERT + expect(screen.getByTestId('toggle-task-complete')).toBeDefined(); + expect(screen.getByTestId('toggle-task-complete').title).toBe('Mark complete'); + expect(screen.getByTestId('toggle-task-complete-icon')).toHaveAttribute('data-icon', 'circle'); + }); + + it('should render complete task', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + + // ASSERT + expect(screen.getByTestId('toggle-task-complete')).toBeDefined(); + expect(screen.getByTestId('toggle-task-complete').title).toBe('Mark incomplete'); + expect(screen.getByTestId('toggle-task-complete-icon')).toHaveAttribute( + 'data-icon', + 'circle-check', + ); + }); + + it('should toggle task complete when clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + expect(screen.getByTestId('toggle-task-complete').title).toBe('Mark complete'); + expect(screen.getByTestId('toggle-task-complete-icon')).toHaveAttribute('data-icon', 'circle'); + + // ACT + await userEvent.click(screen.getByTestId('toggle-task-complete')); + + // ASSERT + expect(mockCreateToast).toHaveBeenCalledOnce(); + expect(mockCreateToast).toHaveBeenCalledWith({ + text: 'Marked task complete', + isAutoDismiss: true, + }); + }); + + it('should toggle task incomplete when clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('toggle-task-complete'); + expect(screen.getByTestId('toggle-task-complete').title).toBe('Mark incomplete'); + expect(screen.getByTestId('toggle-task-complete-icon')).toHaveAttribute( + 'data-icon', + 'circle-check', + ); + + // ACT + await userEvent.click(screen.getByTestId('toggle-task-complete')); + + // ASSERT + expect(mockCreateToast).toHaveBeenCalledOnce(); + expect(mockCreateToast).toHaveBeenCalledWith({ + text: 'Marked task incomplete', + isAutoDismiss: true, + }); + }); +}); diff --git a/src/pages/UsersPage/Tasks/components/__tests__/TaskDeleteDialog.test.tsx b/src/pages/UsersPage/Tasks/components/__tests__/TaskDeleteDialog.test.tsx new file mode 100644 index 0000000..1e121e7 --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/__tests__/TaskDeleteDialog.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { todosFixture } from '__fixtures__/todos'; + +import TaskDeleteDialog from '../TaskDeleteDialog'; + +describe('TaskDeleteDialog', () => { + it('should render successfully', async () => { + // ARRANGE + const task = todosFixture[0]; + const mockOnCancel = vi.fn(); + const mockOnDelete = vi.fn(); + render( + , + ); + await screen.findByTestId('dialog'); + + // ASSERT + expect(screen.getByTestId('dialog')).toBeDefined(); + }); + + it('should call onCancel when cancel button clicked', async () => { + // ARRANGE + const user = userEvent.setup(); + const task = todosFixture[0]; + const mockOnCancel = vi.fn(); + const mockOnDelete = vi.fn(); + render( + , + ); + await screen.findByTestId('dialog'); + + // ACT + await user.click(screen.getByTestId('dialog-button-cancel')); + + // ASSERT + expect(screen.getByTestId('dialog')).toBeDefined(); + expect(mockOnCancel).toHaveBeenCalledOnce(); + expect(mockOnDelete).not.toHaveBeenCalled(); + }); + + it('should call onDelete when delete button clicked', async () => { + // ARRANGE + const user = userEvent.setup(); + const task = todosFixture[0]; + const mockOnCancel = vi.fn(); + const mockOnDelete = vi.fn(); + render( + , + ); + await screen.findByTestId('dialog'); + + // ACT + await user.click(screen.getByTestId('dialog-button-delete')); + + // ASSERT + expect(screen.getByTestId('dialog')).toBeDefined(); + expect(mockOnDelete).toHaveBeenCalledOnce(); + expect(mockOnCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/UsersPage/Tasks/components/__tests__/TaskDetail.test.tsx b/src/pages/UsersPage/Tasks/components/__tests__/TaskDetail.test.tsx new file mode 100644 index 0000000..1f1e28b --- /dev/null +++ b/src/pages/UsersPage/Tasks/components/__tests__/TaskDetail.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; +import * as UseDeleteTask from 'pages/UsersPage/Tasks/api/useDeleteTask'; +import * as UseGetTask from 'pages/UsersPage/Tasks/api/useGetTask'; +import * as UseGetUser from 'common/api/useGetUser'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; +import { userFixture1 } from '__fixtures__/users'; +import { todosFixture } from '__fixtures__/todos'; + +import TaskDetail from '../TaskDetail'; + +// mock select functions from react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + useParams: () => ({ + taskId: '1', + }), + }; +}); + +describe('TaskDetail', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('task-detail'); + + // ASSERT + expect(screen.getByTestId('task-detail')).toBeDefined(); + }); + + it('should display a task', async () => { + // ARRANGE + render(); + await screen.findByTestId('task-detail-task'); + + // ASSERT + expect(screen.getByTestId('task-detail-task')).toBeDefined(); + expect(screen.getByTestId('task-detail-task-title').textContent).toBe(todosFixture[0].title); + expect(screen.getByTestId('task-detail-task-status').textContent).toBe('INCOMPLETE'); + }); + + it('should display a task user', async () => { + // ARRANGE + render(); + await screen.findByTestId('task-detail-task-user-name'); + + // ASSERT + expect(screen.getByTestId('task-detail-task-user-name')).toBeDefined(); + expect(screen.getByTestId('task-detail-task-user-name').textContent).toBe(userFixture1.name); + }); + + it('should display task status incomplete', async () => { + // ARRANGE + const task = todosFixture[0]; + task.completed = false; + const useGetTaskSpy = vi.spyOn(UseGetTask, 'useGetTask'); + useGetTaskSpy.mockReturnValue({ + data: task, + error: undefined, + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-detail-task-status'); + + // ASSERT + expect(screen.getByTestId('task-detail-task-status').textContent).toBe('INCOMPLETE'); + }); + + it('should display task status complete', async () => { + // ARRANGE + const task = todosFixture[0]; + task.completed = true; + const useGetTaskSpy = vi.spyOn(UseGetTask, 'useGetTask'); + useGetTaskSpy.mockReturnValue({ + data: task, + error: undefined, + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-detail-task-status'); + + // ASSERT + expect(screen.getByTestId('task-detail-task-status').textContent).toBe('COMPLETE'); + }); + + it('should display task error', async () => { + // ARRANGE + const useGetTaskSpy = vi.spyOn(UseGetTask, 'useGetTask'); + useGetTaskSpy.mockReturnValue({ + data: undefined, + error: new Error(), + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-detail-alert-taskError'); + + // ASSERT + expect(screen.getByTestId('task-detail-alert-taskError')).toBeDefined(); + }); + + it('should display user error', async () => { + // ARRANGE + const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser'); + useGetUserSpy.mockReturnValue({ + data: undefined, + error: new Error(), + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-detail-alert-userError'); + + // ASSERT + expect(screen.getByTestId('task-detail-alert-userError')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetTaskSpy = vi.spyOn(UseGetTask, 'useGetTask'); + useGetTaskSpy.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('task-detail-loader'); + + // ASSERT + expect(screen.getByTestId('task-detail-loader')).toBeDefined(); + }); + + it('should navigate back using close button', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('task-detail-button-close'); + + // ACT + await user.click(screen.getByTestId('task-detail-button-close')); + + // ASSERT + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + + it('should display task delete error', async () => { + // ARRANGE + const useDeleteTaskSpy = vi.spyOn(UseDeleteTask, 'useDeleteTask'); + useDeleteTaskSpy.mockReturnValue({ + data: undefined, + error: new Error(), + isLoading: false, + } as unknown as UseMutationResult); + render(); + await screen.findByTestId('task-detail-alert-deleteError'); + + // ASSERT + expect(screen.getByTestId('task-detail-alert-deleteError')).toBeDefined(); + }); + + it('should show the delete dialog', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('dialog-task-delete'); + expect(screen.getByTestId('dialog-task-delete')).toHaveClass('hidden'); + + // ACT + await user.click(screen.getByTestId('task-detail-button-delete')); + + // ASSERT + expect(screen.getByTestId('dialog-task-delete')).not.toHaveClass('hidden'); + }); + + it('should hide the delete dialog when cancel clicked', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('dialog-task-delete'); + expect(screen.getByTestId('dialog-task-delete')).toHaveClass('hidden'); + + // ACT + await user.click(screen.getByTestId('task-detail-button-delete')); + expect(screen.getByTestId('dialog-task-delete')).not.toHaveClass('hidden'); + + await user.click(screen.getByTestId('dialog-task-delete-button-cancel')); + + // ASSERT + expect(screen.getByTestId('dialog-task-delete')).toHaveClass('hidden'); + }); + + it('should hide the delete dialog when backdrop clicked', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('dialog-task-delete'); + expect(screen.getByTestId('dialog-task-delete')).toHaveClass('hidden'); + + // ACT + await user.click(screen.getByTestId('task-detail-button-delete')); + expect(screen.getByTestId('dialog-task-delete')).not.toHaveClass('hidden'); + + await user.click(screen.getByTestId('dialog-task-delete-backdrop')); + + // ASSERT + expect(screen.getByTestId('dialog-task-delete')).toHaveClass('hidden'); + }); + + it('should delete a task', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('task-detail-button-delete'); + + // ACT + await user.click(screen.getByTestId('task-detail-button-delete')); + await user.click(screen.getByTestId('dialog-task-delete-button-delete')); + + // ASSERT + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); +}); diff --git a/src/pages/UsersPage/UsersPage.tsx b/src/pages/UsersPage/UsersPage.tsx new file mode 100644 index 0000000..ee3bcac --- /dev/null +++ b/src/pages/UsersPage/UsersPage.tsx @@ -0,0 +1,46 @@ +import { Outlet } from 'react-router-dom'; + +import Page from 'common/components/Page/Page'; +import Text from 'common/components/Text/Text'; +import UserList from './components/UserList'; +import Tabs from 'common/components/Tabs/Tabs'; + +/** + * The `UsersPage` component renders the layout for the users page. It + * displays a list of selectable users. Renders the details of a selected + * `User`. + * @returns {JSX.Element} JSX + */ +const UsersPage = (): JSX.Element => { + return ( + +
+ + Users + + +
+ }, { children: , className: 'my-6' }]} + variant="fullWidth" + /> +
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; + +export default UsersPage; diff --git a/src/pages/UsersPage/__tests__/UsersPage.test.tsx b/src/pages/UsersPage/__tests__/UsersPage.test.tsx new file mode 100644 index 0000000..6af5f73 --- /dev/null +++ b/src/pages/UsersPage/__tests__/UsersPage.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import UsersPage from '../UsersPage'; + +describe('UsersPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-users'); + + // ASSERT + expect(screen.getByTestId('page-users')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/api/__tests__/useGetUserTasks.test.ts b/src/pages/UsersPage/api/__tests__/useGetUserTasks.test.ts new file mode 100644 index 0000000..b9f8cad --- /dev/null +++ b/src/pages/UsersPage/api/__tests__/useGetUserTasks.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; +import { useGetUserTasks } from '../useGetUserTasks'; + +describe('useGetUserTasks', () => { + it('should get tasks for user', async () => { + // ARRANGE + const { result } = renderHook(() => useGetUserTasks({ userId: 1 })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBe(3); + }); + + it('should get return empty array when not found', async () => { + // ARRANGE + const { result } = renderHook(() => useGetUserTasks({ userId: 9999 })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/src/pages/UsersPage/api/__tests__/useGetUsers.test.ts b/src/pages/UsersPage/api/__tests__/useGetUsers.test.ts new file mode 100644 index 0000000..8936507 --- /dev/null +++ b/src/pages/UsersPage/api/__tests__/useGetUsers.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook, waitFor } from 'test/test-utils'; + +import { useGetUsers } from '../useGetUsers'; + +describe('useGetUsers', () => { + it('should get users', async () => { + // ARRANGE + const { result } = renderHook(() => useGetUsers()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBe(2); + }); +}); diff --git a/src/pages/UsersPage/api/useGetUserTasks.ts b/src/pages/UsersPage/api/useGetUserTasks.ts new file mode 100644 index 0000000..5659294 --- /dev/null +++ b/src/pages/UsersPage/api/useGetUserTasks.ts @@ -0,0 +1,49 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; + +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import { QueryKeys } from 'common/utils/constants'; + +/** + * The `Task` type. + */ +export type Task = { + id: number; + userId: number; + title: string; + completed: boolean; +}; + +/** + * The request properties for `useGetUserTasks`. + * @param {number} userId - A `User` identifier. + */ +interface UseGetUserTasksProps { + userId?: number; +} + +/** + * An API hook which fetches a collection of `Task` objects which + * are assigned to a `User`. + * @param {UseGetUserTasksProps} props - Hook properties. + * @returns Returns a `UseQueryResult` with `Task` collection data. + */ +export const useGetUserTasks = ({ + userId, +}: UseGetUserTasksProps): UseQueryResult => { + const axios = useAxios(); + const config = useConfig(); + + const getUserTasks = async (): Promise => { + const response = await axios.request({ + url: `${config.VITE_BASE_URL_API}/users/${userId}/todos`, + }); + return response.data; + }; + + return useQuery({ + queryKey: [QueryKeys.Tasks, { userId }], + queryFn: getUserTasks, + enabled: !!userId, + }); +}; diff --git a/src/pages/UsersPage/api/useGetUsers.ts b/src/pages/UsersPage/api/useGetUsers.ts new file mode 100644 index 0000000..c9c1ae7 --- /dev/null +++ b/src/pages/UsersPage/api/useGetUsers.ts @@ -0,0 +1,27 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; +import { User } from 'common/api/useGetUser'; + +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import { QueryKeys } from 'common/utils/constants'; + +/** + * An API hook which fetches a collection of `User` objects. + * @returns Returns a `UseQueryResult` with `User` collection data. + */ +export const useGetUsers = (): UseQueryResult => { + const axios = useAxios(); + const config = useConfig(); + + const getUsers = async (): Promise => { + const response = await axios.request({ + url: `${config.VITE_BASE_URL_API}/users`, + }); + return response.data; + }; + + return useQuery({ + queryKey: [QueryKeys.Users], + queryFn: () => getUsers(), + }); +}; diff --git a/src/pages/UsersPage/components/UserDetail.tsx b/src/pages/UsersPage/components/UserDetail.tsx new file mode 100644 index 0000000..99849ce --- /dev/null +++ b/src/pages/UsersPage/components/UserDetail.tsx @@ -0,0 +1,73 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { useParams } from 'react-router-dom'; + +import { useGetUser } from 'common/api/useGetUser'; +import Text from 'common/components/Text/Text'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import FAIcon from 'common/components/Icon/FAIcon'; +import UserTasks from './UserTasks'; + +/** + * Properties for the `UserDetail` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserDetailProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `UserDetail` component renders the detailed attributes of a `User` object. + * @param {UserDetailLayoutProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserDetail = ({ className, testId = 'user-detail' }: UserDetailProps): JSX.Element => { + const { userId } = useParams(); + const { data: user, error, isPending } = useGetUser({ userId: Number(userId) }); + + return ( +
+ {isPending && ( + <> +
+ + +
+ + + )} + {!!user && ( +
+
+
+
+ + Company +
+
{user.company.name}
+
{user.company.catchPhrase}
+
{user.company.bs}
+
+
+
+ + Address +
+
{user.address.street}
+
{user.address.suite}
+
{user.address.city}
+
{user.address.zipcode}
+
+
+ + +
+ )} + {!!error && ( +
+ {error.message} +
+ )} +
+ ); +}; + +export default UserDetail; diff --git a/src/pages/UsersPage/components/UserDetailEmpty.tsx b/src/pages/UsersPage/components/UserDetailEmpty.tsx new file mode 100644 index 0000000..3c41036 --- /dev/null +++ b/src/pages/UsersPage/components/UserDetailEmpty.tsx @@ -0,0 +1,34 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +import MessageCard from 'common/components/Card/MessageCard'; + +/** + * Properties for the `UserDetailEmpty` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserDetailEmptyProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `UserDetailEmpty` component renders a message when there is no specific + * `User` selected. + * @param {UserDetailEmptyProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserDetailEmpty = ({ + className, + testId = 'user-detail-empty', +}: UserDetailEmptyProps): JSX.Element => { + return ( +
+ +
+ ); +}; + +export default UserDetailEmpty; diff --git a/src/pages/UsersPage/components/UserDetailLayout.tsx b/src/pages/UsersPage/components/UserDetailLayout.tsx new file mode 100644 index 0000000..219472b --- /dev/null +++ b/src/pages/UsersPage/components/UserDetailLayout.tsx @@ -0,0 +1,82 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { Outlet, useParams } from 'react-router-dom'; + +import { useGetUser } from 'common/api/useGetUser'; +import Text from 'common/components/Text/Text'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import Avatar from 'common/components/Icon/Avatar'; +import FAIcon from 'common/components/Icon/FAIcon'; + +/** + * Properties for the `UserDetailLayout` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserDetailLayoutProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `UserDetailLayout` component renders a layout for the display of + * the detailed attributes of a `User` object. + * @param {UserDetailLayoutProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserDetailLayout = ({ + className, + testId = 'layout-user-detail', +}: UserDetailLayoutProps): JSX.Element => { + const { userId } = useParams(); + const { data: user, error, isPending } = useGetUser({ userId: Number(userId) }); + + return ( +
+ {isPending && ( + <> + +
+ + + +
+
+ + +
+ + )} + {!!user && ( +
+
+ + + {user.name} + +
+ +
+
+ + {user.email} +
+
+ + {user.phone} +
+
+ + {user.website} +
+
+ + +
+ )} + {!!error && ( +
+ {error.message} +
+ )} +
+ ); +}; + +export default UserDetailLayout; diff --git a/src/pages/UsersPage/components/UserList.tsx b/src/pages/UsersPage/components/UserList.tsx new file mode 100644 index 0000000..10f7520 --- /dev/null +++ b/src/pages/UsersPage/components/UserList.tsx @@ -0,0 +1,58 @@ +import { useParams } from 'react-router-dom'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import { useGetUsers } from '../api/useGetUsers'; +import UserListItem from './UserListItem'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; + +/** + * Properties for the `UserList` component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserListProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `UserList` React component renders a list of `User` objects with the + * `UserListItem` component. + * @param {UserListProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserList = ({ className, testId = 'list-users' }: UserListProps): JSX.Element => { + const { userId } = useParams(); + const { data: users, error, isPending } = useGetUsers(); + + return ( +
+ {isPending && ( + <> + + + + + + + )} + {users && + users.map((user) => ( + + ))} + {!!error && ( +
+ {error.message} +
+ )} +
+ ); +}; + +export default UserList; diff --git a/src/pages/UsersPage/components/UserListItem.tsx b/src/pages/UsersPage/components/UserListItem.tsx new file mode 100644 index 0000000..31ac088 --- /dev/null +++ b/src/pages/UsersPage/components/UserListItem.tsx @@ -0,0 +1,60 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; +import { useNavigate } from 'react-router-dom'; + +import { User } from 'common/api/useGetUser'; + +/** + * Properties for the `UserListItem` component. + * @param {User} user - A `User` object. + * @param {boolean} [isActive] - Optional. Indicates if this is the currently + * selected item in the list. Default: `false`. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserListItemProps extends PropsWithClassName, PropsWithTestId { + user: User; + isActive?: boolean; +} + +/** + * The `UserListItem` React component renders select `User` attributes to + * create a selectable item within a list. + * + * When clicked, navigates to a route which displays the details of the + * clicked `User`. + * @param {UserListItemProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserListItem = ({ + className, + isActive = false, + testId = 'list-item-user', + user, +}: UserListItemProps): JSX.Element => { + const navigate = useNavigate(); + + const doClick = () => { + navigate(`${user.id}?tab=1`); + }; + + return ( +
doClick()} + data-testid={testId} + > +
+
{user.name}
+
{user.email}
+
+
+ ); +}; + +export default UserListItem; diff --git a/src/pages/UsersPage/components/UserTaskList.tsx b/src/pages/UsersPage/components/UserTaskList.tsx new file mode 100644 index 0000000..756b623 --- /dev/null +++ b/src/pages/UsersPage/components/UserTaskList.tsx @@ -0,0 +1,93 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { useParams } from 'react-router-dom'; +import filter from 'lodash/filter'; + +import { useGetUserTasks } from '../api/useGetUserTasks'; +import FAIcon from 'common/components/Icon/FAIcon'; +import Text from 'common/components/Text/Text'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import UserTaskListItem from './UserTaskListItem'; +import Badge from 'common/components/Badge/Badge'; + +/** + * Propeties for the `UserTaskList` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserTaskListProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `UserTaskList` component renders the list of `Task` objects assigned + * to a specific `User`. Tasks are organized by completion status. + * @param {UserTaskListProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserTaskList = ({ className, testId = 'user-task-list' }: UserTaskListProps): JSX.Element => { + const { userId } = useParams(); + const { data: tasks, error, isPending } = useGetUserTasks({ userId: Number(userId) }); + + const incompleteTasks = filter(tasks, { completed: false }); + const completeTasks = filter(tasks, { completed: true }); + + return ( +
+
+ + Tasks +
+ +
+ {isPending && ( + <> + + + + + + + + )} + {!!tasks && ( +
+
+
+
To Do
+ {!!incompleteTasks && ( + {incompleteTasks.length} + )} +
+ {incompleteTasks.map((task) => ( + + ))} +
+ +
+
+
Complete
+ {!!completeTasks && {completeTasks.length}} +
+ {completeTasks.map((task) => ( + + ))} +
+
+ )} + {!!error && ( +
+ {error.message} +
+ )} +
+
+ ); +}; + +export default UserTaskList; diff --git a/src/pages/UsersPage/components/UserTaskListItem.tsx b/src/pages/UsersPage/components/UserTaskListItem.tsx new file mode 100644 index 0000000..1516dc7 --- /dev/null +++ b/src/pages/UsersPage/components/UserTaskListItem.tsx @@ -0,0 +1,40 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +import { Task } from '../api/useGetUserTasks'; +import TaskCompleteToggle from '../Tasks/components/TaskCompleteToggle'; +import Link from 'common/components/Link/Link'; + +/** + * Properties for the `UserTaskListItem` React component. + * @param {Task} task - A `Task` object. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserTaskListItemProps extends PropsWithClassName, PropsWithTestId { + task: Task; +} + +/** + * The `UserTaskListItem` component renders a single `Task` item within the + * `UserTaskList`. + * @param {UserTaskListItemProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserTaskListItem = ({ + className, + task, + testId = 'user-task-list-item', +}: UserTaskListItemProps): JSX.Element => { + return ( +
+
+ +
+ {task.title} +
+
+
+ ); +}; + +export default UserTaskListItem; diff --git a/src/pages/UsersPage/components/UserTasks.tsx b/src/pages/UsersPage/components/UserTasks.tsx new file mode 100644 index 0000000..f30f9d1 --- /dev/null +++ b/src/pages/UsersPage/components/UserTasks.tsx @@ -0,0 +1,75 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +import { useGetUserTasks } from '../api/useGetUserTasks'; +import FAIcon from 'common/components/Icon/FAIcon'; +import Text from 'common/components/Text/Text'; +import Link from 'common/components/Link/Link'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import Badge from 'common/components/Badge/Badge'; + +/** + * Properties for the `UserTasks` component. + * @param {number} userId - A `User` identifier. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface UserTasksProps extends PropsWithClassName, PropsWithTestId { + userId: number; +} + +/** + * The `UserTasks` component renders a block which displays the first + * few `Task` objects assigned to a `User` and a link to view the + * full list of `Task` objects for that `User`. + * @param {UserTasksProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserTasks = ({ className, testId = 'user-tasks', userId }: UserTasksProps): JSX.Element => { + const { data: tasks, error, isPending } = useGetUserTasks({ userId }); + + return ( +
+
+ + Tasks + {!!tasks && ( + + {tasks.length} + + )} +
+ +
+ {isPending && ( + <> + + + + + )} + {!!tasks && + tasks.slice(0, 3).map((task) => ( +
+ +
{task.title}
+
+ ))} + {!!error && ( +
+ {error.message} +
+ )} +
+ + + View all + +
+ ); +}; + +export default UserTasks; diff --git a/src/pages/UsersPage/components/UserTasksCard.tsx b/src/pages/UsersPage/components/UserTasksCard.tsx new file mode 100644 index 0000000..1868a79 --- /dev/null +++ b/src/pages/UsersPage/components/UserTasksCard.tsx @@ -0,0 +1,77 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import filter from 'lodash/filter'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import { useGetUserTasks } from '../api/useGetUserTasks'; +import Card, { CardProps } from 'common/components/Card/Card'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; + +/** + * Properties for the `UserTasksCard` React component. + * @param {number} userId - A User identifier. + * @see {@link CardProps} + */ +interface UserTasksCardProps extends CardProps { + userId: number; +} + +/** + * The `UserTasksCard` component renders card which displays summary information + * about a User's tasks. + * + * When clicked, navigates to the task details page for the User. + * @param {UserTasksCardProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserTasksCard = ({ + className, + userId, + testId = 'card-user-tasks', + ...props +}: UserTasksCardProps): JSX.Element => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { data: tasks, error, isLoading } = useGetUserTasks({ userId }); + const incompleteTasks = filter(tasks, { completed: false }); + + const tasksMessage = useMemo(() => { + if (error) { + return t('task.errors.fetchingList', { ns: 'users' }); + } + + if (incompleteTasks.length === 0) { + return t('task.allComplete', { ns: 'users' }); + } + + return t('task.toComplete', { ns: 'users', count: incompleteTasks.length }); + }, [error, incompleteTasks, t]); + + return ( +
navigate(`/app/users/${userId}/tasks?tab=1`)} data-testid={testId}> + + {isLoading ? ( +
+ + +
+ ) : ( +
+
{t('task.tasks', { ns: 'users' })}
+
{tasksMessage}
+
+ )} +
+
+ ); +}; + +export default UserTasksCard; diff --git a/src/pages/UsersPage/components/__tests__/UserDetail.test.tsx b/src/pages/UsersPage/components/__tests__/UserDetail.test.tsx new file mode 100644 index 0000000..fa88de9 --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserDetail.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { render, screen } from 'test/test-utils'; + +import * as UseGetUser from 'common/api/useGetUser'; +import UserDetail from '../UserDetail'; + +// mock select functions from react-router-dom +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useParams: () => ({ + userId: '1', + }), + }; +}); + +describe('UserDetail', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-detail'); + + // ASSERT + expect(screen.getByTestId('user-detail')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-detail'); + + // ASSERT + expect(screen.getByTestId('user-detail').classList).toContain('custom-className'); + }); + + it('should render user detail', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-detail-user'); + + // ASSERT + expect(screen.getByTestId('user-detail-user')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser'); + useGetUserSpy.mockReturnValue({ + data: undefined, + error: undefined, + isPending: true, + } as unknown as UseQueryResult); + render(); + await screen.findAllByTestId('loader-skeleton'); + + // ASSERT + expect(screen.getAllByTestId('loader-skeleton')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser'); + useGetUserSpy.mockReturnValue({ + data: undefined, + error: new AxiosError(), + isPending: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('user-detail-error'); + + // ASSERT + expect(screen.getByTestId('user-detail-error')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserDetailEmpty.test.tsx b/src/pages/UsersPage/components/__tests__/UserDetailEmpty.test.tsx new file mode 100644 index 0000000..b02b6d4 --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserDetailEmpty.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import UserDetailEmpty from '../UserDetailEmpty'; + +describe('UserDetailEmpty', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-detail-empty'); + + // ASSERT + expect(screen.getByTestId('user-detail-empty')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-detail-empty'); + + // ASSERT + expect(screen.getByTestId('user-detail-empty').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserDetailLayout.test.tsx b/src/pages/UsersPage/components/__tests__/UserDetailLayout.test.tsx new file mode 100644 index 0000000..e68b66c --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserDetailLayout.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { render, screen } from 'test/test-utils'; + +import * as UseGetUser from 'common/api/useGetUser'; +import UserDetailLayout from '../UserDetailLayout'; + +// mock select functions from react-router-dom +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useParams: () => ({ + userId: '1', + }), + }; +}); + +describe('UserDetailLayout', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('layout-user-detail'); + + // ASSERT + expect(screen.getByTestId('layout-user-detail')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('layout-user-detail'); + + // ASSERT + expect(screen.getByTestId('layout-user-detail').classList).toContain('custom-className'); + }); + + it('should render user detail layout', async () => { + // ARRANGE + render(); + await screen.findByTestId('layout-user-detail-user'); + + // ASSERT + expect(screen.getByTestId('layout-user-detail-user')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser'); + useGetUserSpy.mockReturnValue({ + data: undefined, + error: undefined, + isPending: true, + } as unknown as UseQueryResult); + render(); + await screen.findAllByTestId('loader-skeleton'); + + // ASSERT + expect(screen.getAllByTestId('loader-skeleton')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser'); + useGetUserSpy.mockReturnValue({ + data: undefined, + error: new AxiosError(), + isPending: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('layout-user-detail-error'); + + // ASSERT + expect(screen.getAllByTestId('layout-user-detail-error')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserList.test.tsx b/src/pages/UsersPage/components/__tests__/UserList.test.tsx new file mode 100644 index 0000000..ebe2a21 --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserList.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import * as UseGetUser from 'common/api/useGetUser'; +import * as UseGetUsers from '../../api/useGetUsers'; +import UserList from '../UserList'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +// mock select functions from react-router-dom +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useParams: () => ({ + userId: '1', + }), + }; +}); + +describe('UserList', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-users'); + + // ASSERT + expect(screen.getByTestId('list-users')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-users'); + + // ASSERT + expect(screen.getByTestId('list-users').classList).toContain('custom-className'); + }); + + it('should render list of users', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user-1'); + + // ASSERT + expect(screen.getByTestId('list-item-user-1')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetUsersSpy = vi.spyOn(UseGetUsers, 'useGetUsers'); + useGetUsersSpy.mockReturnValue({ + data: undefined, + error: undefined, + isPending: true, + } as unknown as UseQueryResult); + render(); + await screen.findAllByTestId('loader-skeleton'); + + // ASSERT + expect(screen.getAllByTestId('loader-skeleton')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + const useGetUsersSpy = vi.spyOn(UseGetUsers, 'useGetUsers'); + useGetUsersSpy.mockReturnValue({ + data: undefined, + error: new AxiosError(), + isPending: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('list-users-error'); + + // ASSERT + expect(screen.getByTestId('list-users-error')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserListItem.test.tsx b/src/pages/UsersPage/components/__tests__/UserListItem.test.tsx new file mode 100644 index 0000000..3639523 --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserListItem.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; +import UserListItem from '../UserListItem'; +import { userFixture1 } from '__fixtures__/users'; +import userEvent from '@testing-library/user-event'; + +// mock select functions from react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +describe('UserListItem', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user'); + + // ASSERT + expect(screen.getByTestId('list-item-user')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user'); + + // ASSERT + expect(screen.getByTestId('list-item-user').classList).toContain('custom-className'); + }); + + it('should use inactive styles', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user'); + + // ASSERT + expect(screen.getByTestId('list-item-user').classList).toContain('border-b-neutral-500/10'); + }); + + it('should use active styles', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user'); + + // ASSERT + expect(screen.getByTestId('list-item-user').classList).toContain('border-b-blue-300'); + }); + + it('should navigate when clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('list-item-user'); + + // ACT + await userEvent.click(screen.getByTestId('list-item-user')); + + // ASSERT + expect(mockNavigate).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserTaskList.test.tsx b/src/pages/UsersPage/components/__tests__/UserTaskList.test.tsx new file mode 100644 index 0000000..cafb96d --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserTaskList.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import * as UseGetUserTasks from '../../api/useGetUserTasks'; +import UserTaskList from '../UserTaskList'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +// mock select functions from react-router-dom +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useParams: () => ({ + userId: '1', + }), + }; +}); + +describe('UserTaskList', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-task-list'); + + // ASSERT + expect(screen.getByTestId('user-task-list')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-task-list'); + + // ASSERT + expect(screen.getByTestId('user-task-list').classList).toContain('custom-className'); + }); + + it('should render tasks', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-task-list-tasks'); + + // ASSERT + expect(screen.getByTestId('user-task-list-tasks')).toBeDefined(); + expect(screen.getByTestId('user-task-list-tasks-incomplete-1')).toBeDefined(); + expect(screen.getByTestId('user-task-list-tasks-complete-2')).toBeDefined(); + expect(screen.getByTestId('user-task-list-tasks-incomplete-3')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); + useGetUserTasksSpy.mockReturnValue({ + data: undefined, + error: undefined, + isPending: true, + } as unknown as UseQueryResult); + render(); + await screen.findAllByTestId('loader-skeleton'); + + // ASSERT + expect(screen.getAllByTestId('loader-skeleton')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); + useGetUserTasksSpy.mockReturnValue({ + data: undefined, + error: new AxiosError(), + isPending: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('user-task-list-error'); + + // ASSERT + expect(screen.getByTestId('user-task-list-error')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserTaskListItem.test.tsx b/src/pages/UsersPage/components/__tests__/UserTaskListItem.test.tsx new file mode 100644 index 0000000..efbce1c --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserTaskListItem.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import { todosFixture } from '__fixtures__/todos'; +import UserTaskListItem from '../UserTaskListItem'; +import { Task } from 'pages/UsersPage/api/useGetUserTasks'; + +describe('UserTaskListItem', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-task-list-item'); + + // ASSERT + expect(screen.getByTestId('user-task-list-item')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-task-list-item'); + + // ASSERT + expect(screen.getByTestId('user-task-list-item').classList).toContain('custom-className'); + }); + + it('should display completed task', async () => { + // ARRANGE + const task: Task = { + ...todosFixture[0], + completed: true, + }; + render(); + await screen.findByTestId('user-task-list-item'); + + // ASSERT + expect(screen.getByTestId('user-task-list-item-toggle-complete-icon')).toHaveAttribute( + 'data-icon', + 'circle-check', + ); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserTasks.test.tsx b/src/pages/UsersPage/components/__tests__/UserTasks.test.tsx new file mode 100644 index 0000000..23b947d --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserTasks.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from 'test/test-utils'; + +import * as UseGetUserTasks from '../../api/useGetUserTasks'; +import UserTasks from '../UserTasks'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +describe('UserTasks', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-tasks'); + + // ASSERT + expect(screen.getByTestId('user-tasks')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-tasks'); + + // ASSERT + expect(screen.getByTestId('user-tasks').classList).toContain('custom-className'); + }); + + it('should display badge with task count', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-tasks-badge-task-count'); + + // ASSERT + expect(screen.getByTestId('user-tasks-badge-task-count')).toBeDefined(); + expect(screen.getByTestId('user-tasks-badge-task-count').textContent).toBe('3'); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); + useGetUserTasksSpy.mockReturnValue({ + data: undefined, + error: undefined, + isPending: true, + } as unknown as UseQueryResult); + render(); + await screen.findAllByTestId('loader-skeleton'); + + // ASSERT + expect(screen.getAllByTestId('loader-skeleton')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); + useGetUserTasksSpy.mockReturnValue({ + data: undefined, + error: new AxiosError(), + isPending: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('user-tasks-error'); + + // ASSERT + expect(screen.getByTestId('user-tasks-error')).toBeDefined(); + }); +}); diff --git a/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx b/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx new file mode 100644 index 0000000..5515a7d --- /dev/null +++ b/src/pages/UsersPage/components/__tests__/UserTasksCard.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from 'test/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; + +import * as UseGetUserTasks from '../../api/useGetUserTasks'; +import { todosFixture } from '__fixtures__/todos'; + +import UserTasksCard from '../UserTasksCard'; + +// mock select functions from react-router-dom +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const original = await vi.importActual('react-router-dom'); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +describe('UserTasksCard', () => { + const useGetUserTasksSpy = vi.spyOn(UseGetUserTasks, 'useGetUserTasks'); + + beforeEach(() => { + useGetUserTasksSpy.mockReturnValue({ + data: todosFixture, + error: null, + isLoading: false, + } as unknown as UseQueryResult); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('card-user-tasks'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('card-user-tasks'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks-card').classList).toContain('custom-className'); + }); + + it('should render loading state', async () => { + // ARRANGE + useGetUserTasksSpy.mockReturnValue({ + isLoading: true, + } as unknown as UseQueryResult); + + render(); + await screen.findByTestId('card-user-tasks-loader'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks-loader')).toBeDefined(); + }); + + it('should render message when error occurs', async () => { + // ARRANGE + useGetUserTasksSpy.mockReturnValue({ + error: new Error('test'), + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('card-user-tasks-message'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks-message').textContent).toBe( + 'A problem occurred fetching your tasks.', + ); + }); + + it('should render message for zero incomplete tasks', async () => { + // ARRANGE + useGetUserTasksSpy.mockReturnValue({ + data: [], + error: null, + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('card-user-tasks-message'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks-message').textContent).toBe( + 'All your tasks are complete!', + ); + }); + + it('should render message for incomplete tasks', async () => { + // ARRANGE + render(); + await screen.findByTestId('card-user-tasks-message'); + + // ASSERT + expect(screen.getByTestId('card-user-tasks-message').textContent).toBe( + 'You have 3 tasks to complete.', + ); + }); + + it('should navigate when clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('card-user-tasks-message'); + + // ACT + await userEvent.click(screen.getByTestId('card-user-tasks')); + + // ASSERT + expect(mockNavigate).toHaveBeenCalledWith(`/app/users/1/tasks?tab=1`); + }); +}); diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts new file mode 100644 index 0000000..88034df --- /dev/null +++ b/src/test/mocks/handlers.ts @@ -0,0 +1,57 @@ +import { HttpResponse, http } from 'msw'; +import filter from 'lodash/filter'; +import find from 'lodash/find'; + +import { usersFixture } from '__fixtures__/users'; +import { todosFixture } from '__fixtures__/todos'; + +export const handlers = [ + http.get('https://jsonplaceholder.typicode.com/users', () => { + // get all users + return HttpResponse.json(usersFixture); + }), + http.get('https://jsonplaceholder.typicode.com/users/:userId', ({ params }) => { + // get a user by identifier + const { userId } = params; + const user = find(usersFixture, { id: Number(userId) }); + if (user) { + return HttpResponse.json(user); + } + return new HttpResponse(null, { status: 404 }); + }), + http.get('https://jsonplaceholder.typicode.com/users/:userId/todos', ({ params }) => { + // get all tasks(todos) for a user + const { userId } = params; + const todos = filter(todosFixture, { userId: Number(userId) }); + return HttpResponse.json(todos); + }), + http.get('https://jsonplaceholder.typicode.com/todos/:todoId', ({ params }) => { + // get a task + const { todoId } = params; + const todo = find(todosFixture, { id: Number(todoId) }); + if (todo) { + return HttpResponse.json(todo); + } + return new HttpResponse(null, { status: 404 }); + }), + http.put('https://jsonplaceholder.typicode.com/todos/:todoId', async ({ params, request }) => { + // update a task + const { todoId } = params; + const todo = find(todosFixture, { id: Number(todoId) }); + if (todo) { + // return the request body as the response + const requestBody = await request.json(); + return HttpResponse.json(requestBody); + } + return new HttpResponse(null, { status: 404 }); + }), + http.delete('https://jsonplaceholder.typicode.com/todos/:todoId', async ({ params }) => { + // delete a task + const { todoId } = params; + const todo = find(todosFixture, { id: Number(todoId) }); + if (todo) { + return new HttpResponse(null, { status: 200 }); + } + return new HttpResponse(null, { status: 500 }); + }), +]; diff --git a/src/test/mocks/server.ts b/src/test/mocks/server.ts new file mode 100644 index 0000000..5ac9204 --- /dev/null +++ b/src/test/mocks/server.ts @@ -0,0 +1,5 @@ +import { setupServer } from 'msw/node'; + +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/src/test/query-client.ts b/src/test/query-client.ts new file mode 100644 index 0000000..dafe7d4 --- /dev/null +++ b/src/test/query-client.ts @@ -0,0 +1,9 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx new file mode 100644 index 0000000..4a3425f --- /dev/null +++ b/src/test/test-utils.tsx @@ -0,0 +1,40 @@ +import { + queries, + Queries, + render, + renderHook, + RenderHookOptions, + RenderOptions, +} from '@testing-library/react'; + +import 'common/utils/i18n'; +import WithAllProviders from './wrappers/WithAllProviders'; + +const customRender = (ui: React.ReactElement, options?: RenderOptions, { route = '/' } = {}) => { + window.history.pushState({}, 'Test page', route); + + return render(ui, { wrapper: WithAllProviders, ...options }); +}; + +function customRenderHook< + Result, + Props, + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement, + BaseElement extends Element | DocumentFragment = Container, +>( + render: (initialProps: Props) => Result, + options?: RenderHookOptions, +) { + return renderHook(render, { wrapper: WithAllProviders, ...options }); +} + +// re-export @testing-library/react +// eslint-disable-next-line +export * from '@testing-library/react'; + +// override the render function +export { customRender as render }; + +// override the renderHook function +export { customRenderHook as renderHook }; diff --git a/src/test/wrappers/AllProvidersExceptRouter.tsx b/src/test/wrappers/AllProvidersExceptRouter.tsx new file mode 100644 index 0000000..ae2a69b --- /dev/null +++ b/src/test/wrappers/AllProvidersExceptRouter.tsx @@ -0,0 +1,50 @@ +import { PropsWithChildren } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import ConfigContextProvider from 'common/providers/ConfigProvider'; +import SettingsContextProvider from 'common/providers/SettingsProvider'; +import AuthContextProvider from 'common/providers/AuthProvider'; +import ToastsProvider from 'common/providers/ToastsProvider'; + +import { queryClient } from '../query-client'; + +/** + * A React test wrapper. Wraps the component under test with a bespoke set + * of React components, typically providers. + * + * This test wrapper includes all of the providers except the Router. + * + * This component is useful when you want to render your own router component + * as part of the test. For example, to test that some specific navigation is + * triggered. + * + * Example: + * ``` + * render( + * + * + * } /> + * } /> + * + * , + * { wrapper: AllProvidersExceptRouter }, + * ); + * ``` + * @param {PropsWithChildren} props - Component properties. + * @returns {JSX.Element} JSX + */ +const AllProvidersExceptRouter = ({ children }: PropsWithChildren): JSX.Element => { + return ( + + + + + {children} + + + + + ); +}; + +export default AllProvidersExceptRouter; diff --git a/src/test/wrappers/WithAllProviders.tsx b/src/test/wrappers/WithAllProviders.tsx new file mode 100644 index 0000000..20b8461 --- /dev/null +++ b/src/test/wrappers/WithAllProviders.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import ConfigContextProvider from 'common/providers/ConfigProvider'; +import SettingsContextProvider from 'common/providers/SettingsProvider'; +import AuthContextProvider from 'common/providers/AuthProvider'; +import ToastsProvider from 'common/providers/ToastsProvider'; + +import { queryClient } from '../query-client'; + +/** + * The default React test wrapper. Wraps the component under test with a set + * of React components, typically providers. + * + * This test wrapper includes all of the providers used in the application. + * @param {PropsWithChildren} props - Component properties. + * @returns {JSX.Element} JSX + */ +const WithAllProviders = ({ children }: PropsWithChildren): JSX.Element => { + return ( + + + + + + {children} + + + + + + ); +}; + +export default WithAllProviders; diff --git a/src/test/wrappers/WithQueryClientProvider.tsx b/src/test/wrappers/WithQueryClientProvider.tsx new file mode 100644 index 0000000..4472290 --- /dev/null +++ b/src/test/wrappers/WithQueryClientProvider.tsx @@ -0,0 +1,25 @@ +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { queryClient } from '../query-client'; + +/** + * A React test wrapper. Wraps the component under test with a bespoke set + * of React components, typically providers. + * + * Wraps the component with the React Query `QueryClientProvider` and a router, + * but nothing more. Removes other providers to minimize side effects on the + * component under test. + * @param {PropsWithChildren} props - Component properties. + * @returns {JSX.Element} JSX + */ +const WithQueryClientProvider = ({ children }: PropsWithChildren): JSX.Element => { + return ( + + {children} + + ); +}; + +export default WithQueryClientProvider; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..12e03f2 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,32 @@ +import colors from 'tailwindcss/colors'; +import defaultTheme from 'tailwindcss/defaultTheme'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{js,jsx,ts,tsx}', + './node_modules/@leanstacks/**/*.{js,jsx,ts,tsx}', + ], + darkMode: 'class', + theme: { + extend: { + colors: { + dark: { + bg: colors.neutral[800], + DEFAULT: colors.neutral[800], + text: colors.neutral[300], + }, + light: { + bg: colors.white, + DEFAULT: colors.white, + text: colors.neutral[700], + }, + }, + fontFamily: { + sans: ['Noto Sans', ...defaultTheme.fontFamily.sans], + }, + }, + }, + plugins: [], +}; diff --git a/template.yml b/template.yml new file mode 100644 index 0000000..028928a --- /dev/null +++ b/template.yml @@ -0,0 +1,226 @@ +Description: React Starter UI component resources + +Parameters: + EnvironmentCode: + Type: String + Description: Select an Environment + AllowedValues: + - dev + - qa + - prod + Default: dev + ConstraintDescription: Must select a valid environment + +Mappings: + EnvironmentAttributeMap: + dev: + CertificateArn: arn:aws:acm:us-east-1:988218269141:certificate/3d110b0f-8b3d-4ddc-bbd8-fab08ae6f038 + CloudFrontOAID: E2U9SKLVDD8TPN + HostedZone: dev.leanstacks.net + qa: + CertificateArn: arn:aws:acm:us-east-1:339939222800:certificate/5cd1bce7-1323-4625-a49e-5e72d1cff7ef + CloudFrontOAID: E322H9D7WOKWXW + HostedZone: qa.leanstacks.net + prod: + CertificateArn: arn:aws:acm:us-east-1:854599584783:certificate/fc25a13b-0c9f-4c79-a20f-a13f5d2245b3 + CloudFrontOAID: EVMQ2O0M1MS7S + HostedZone: leanstacks.net + +Resources: + ## + # S3 Bucket for the React App + ## + BucketApp: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub + - 'reactstarter-ui-app.${HostedZone}-${AWS::Region}-${AWS::AccountId}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + + ## + # Bucket Policy allows access from AWS CloudFront + ## + BucketPolicyApp: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref BucketApp + PolicyDocument: + Statement: + - Action: + - s3:GetObject + Effect: Allow + Resource: !Join + - '' + - - 'arn:aws:s3:::' + - !Ref BucketApp + - '/*' + Principal: + AWS: !Sub + - 'arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOAID}' + - CloudFrontOAID: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CloudFrontOAID] + + ## + # CloudFront Distribution for the React App - SPA errors and behaviors + ## + DistributionUi: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Comment: !Sub 'React Starter UI SPA (${EnvironmentCode})' + CustomErrorResponses: + - ErrorCode: 404 + ResponsePagePath: '/index.html' + ResponseCode: 200 + - ErrorCode: 403 + ResponsePagePath: '/index.html' + ResponseCode: 200 + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + DefaultTTL: 60 + ForwardedValues: + Cookies: + Forward: none + QueryString: false + TargetOriginId: S3-APP + ViewerProtocolPolicy: redirect-to-https + DefaultRootObject: index.html + Enabled: true + HttpVersion: http2 + Origins: + - DomainName: !GetAtt BucketApp.DomainName + Id: S3-APP + S3OriginConfig: + OriginAccessIdentity: !Sub + - 'origin-access-identity/cloudfront/${CloudFrontOAID}' + - CloudFrontOAID: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CloudFrontOAID] + PriceClass: PriceClass_100 + + ## + # CloudFront Distribution for complete, full-stack APP - routing for API and UI + ## + DistributionApp: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Comment: !Sub 'React Starter UI App (${EnvironmentCode})' + Aliases: + - !Sub + - 'react-starter.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + CacheBehaviors: + - AllowedMethods: + - DELETE + - GET + - HEAD + - OPTIONS + - PATCH + - POST + - PUT + DefaultTTL: 0 + ForwardedValues: + Cookies: + Forward: none + Headers: + - Accept + - Authorization + - Content-Type + - X-Requested-With + QueryString: true + MaxTTL: 0 + MinTTL: 0 + PathPattern: /api* + TargetOriginId: CUSTOM-API + ViewerProtocolPolicy: redirect-to-https + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + DefaultTTL: 60 + ForwardedValues: + Cookies: + Forward: none + QueryString: false + TargetOriginId: CUSTOM-UI + ViewerProtocolPolicy: redirect-to-https + DefaultRootObject: index.html + Enabled: true + HttpVersion: http2 + Origins: + - CustomOriginConfig: + HTTPPort: 80 + HTTPSPort: 443 + OriginProtocolPolicy: https-only + OriginSSLProtocols: + - SSLv3 + - TLSv1 + - TLSv1.1 + - TLSv1.2 + DomainName: !GetAtt DistributionUi.DomainName + Id: CUSTOM-UI + - CustomOriginConfig: + HTTPPort: 80 + HTTPSPort: 443 + OriginProtocolPolicy: https-only + OriginSSLProtocols: + - SSLv3 + - TLSv1 + - TLSv1.1 + - TLSv1.2 + DomainName: !Sub + - 'api.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Id: CUSTOM-API + PriceClass: PriceClass_100 + ViewerCertificate: + AcmCertificateArn: + !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, CertificateArn] + SslSupportMethod: sni-only + + ## + # Route53 DNS for the 'App' CloudFront Distribution + ## + RecordSetAppA: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Sub + - '${HostedZone}.' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Name: !Sub + - 'react-starter.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt DistributionApp.DomainName + + ## + # Route53 DNS for the 'App' CloudFront Distribution + ## + RecordSetAppAAAA: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Sub + - '${HostedZone}.' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Name: !Sub + - 'react-starter.${HostedZone}' + - HostedZone: !FindInMap [EnvironmentAttributeMap, !Ref EnvironmentCode, HostedZone] + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt DistributionApp.DomainName + +Outputs: + AppBucketName: + Description: The application S3 bucket name + Value: !Ref BucketApp + + DomainName: + Description: The application domain name + Value: !Ref RecordSetAppA diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d77bbfd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Absolute imports */ + "baseUrl": ".", + "paths": { + "__fixtures__/*": ["./src/__fixtures__/*"], + "assets/*": ["./src/assets/*"], + "common/*": ["./src/common/*"], + "pages/*": ["./src/pages/*"], + "test/*": ["./src/test/*"] + } + }, + "include": ["src", "./vitest.setup.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d3498d2 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,35 @@ +/// +import { defineConfig } from 'vite'; +import { coverageConfigDefaults } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + __fixtures__: '/src/__fixtures__', + assets: '/src/assets', + common: '/src/common', + pages: '/src/pages', + test: '/src/test', + }, + }, + test: { + coverage: { + exclude: [ + '**/__fixtures__/**', + '**/__mocks__/**', + 'src/main.tsx', + 'src/test', + '**/postcss.config.js', + '**/tailwind.config.js', + ...coverageConfigDefaults.exclude, + ], + }, + environment: 'jsdom', + globals: true, + mockReset: true, + setupFiles: ['./vitest.setup.ts'], + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..ce4edf5 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,18 @@ +import { afterAll, afterEach, beforeAll } from 'vitest'; +import '@testing-library/jest-dom/vitest'; + +import { server } from './src/test/mocks/server'; +import { queryClient } from './src/test/query-client'; + +beforeAll(() => { + server.listen(); +}); + +afterEach(() => { + server.resetHandlers(); + queryClient.clear(); +}); + +afterAll(() => { + server.close(); +}); From 700f2b755a2aa3cbd2af29f78995c7ac4d4bcdef Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 11 Dec 2024 08:32:06 -0500 Subject: [PATCH 02/15] update password requirements --- README.md | 2 +- src/pages/SigninPage/components/SigninForm.tsx | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4cf8dd3..0c57ff6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This project's API integration uses the simulated REST endpoints made available ### Authentication -When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_ so long as it meets the password validation requirements. For example, try username `Bret` and password `aB123456789!`. +When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_. For example, try username `Bret` and password `abc123`. ## About diff --git a/src/pages/SigninPage/components/SigninForm.tsx b/src/pages/SigninPage/components/SigninForm.tsx index 1eb7fff..3dfe1b8 100644 --- a/src/pages/SigninPage/components/SigninForm.tsx +++ b/src/pages/SigninPage/components/SigninForm.tsx @@ -29,14 +29,8 @@ interface SigninFormValues { * Signin form validation schema. */ const validationSchema = object({ - password: string() - .matches(/[0-9]/, 'Must have a number. ') - .matches(/[a-z]/, 'Must have a lowercase letter. ') - .matches(/[A-Z]/, 'Must have an uppercase letter. ') - .matches(/[$*.[{}()?"!@#%&/,><':;|_~`^\]\\]/, 'Must have a special character. ') - .min(12, 'Must have at least 12 characters. ') - .required('Required. '), - username: string().required('Required.'), + password: string().required('Required. '), + username: string().required('Required. '), }); /** From 5da23cb9135474dd6edfb74225efb402e9c55ccd Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 11 Dec 2024 14:14:53 -0500 Subject: [PATCH 03/15] add react-common components --- src/common/components/Alert/Alert.tsx | 65 ++++++++ .../components/Alert/__tests__/Alert.test.tsx | 87 ++++++++++ src/common/components/Button/Button.tsx | 81 ++++++++- .../Button/__tests__/Button.test.tsx | 155 +++++++++++++++++- src/common/components/Text/Currency.tsx | 54 ++++++ src/common/components/Text/Date.tsx | 48 ++++++ src/common/components/Text/DayOfTheWeek.tsx | 65 ++++++++ src/common/components/Text/Decimal.tsx | 68 ++++++++ src/common/components/Text/Integer.tsx | 58 +++++++ src/common/components/Text/Percent.tsx | 53 ++++++ src/common/components/Text/Text.tsx | 10 +- .../Text/__tests__/Currency.test.tsx | 91 ++++++++++ .../components/Text/__tests__/Date.test.tsx | 87 ++++++++++ .../Text/__tests__/DayOfTheWeek.test.tsx | 74 +++++++++ .../Text/__tests__/Decimal.test.tsx | 148 +++++++++++++++++ .../Text/__tests__/Integer.test.tsx | 131 +++++++++++++++ .../Text/__tests__/Percent.test.tsx | 71 ++++++++ src/common/utils/__tests__/numbers.test.ts | 154 +++++++++-------- src/common/utils/constants.ts | 74 +++++++++ src/common/utils/numbers.ts | 16 ++ src/common/utils/types.ts | 23 +++ 21 files changed, 1529 insertions(+), 84 deletions(-) create mode 100644 src/common/components/Alert/Alert.tsx create mode 100644 src/common/components/Alert/__tests__/Alert.test.tsx create mode 100644 src/common/components/Text/Currency.tsx create mode 100644 src/common/components/Text/Date.tsx create mode 100644 src/common/components/Text/DayOfTheWeek.tsx create mode 100644 src/common/components/Text/Decimal.tsx create mode 100644 src/common/components/Text/Integer.tsx create mode 100644 src/common/components/Text/Percent.tsx create mode 100644 src/common/components/Text/__tests__/Currency.test.tsx create mode 100644 src/common/components/Text/__tests__/Date.test.tsx create mode 100644 src/common/components/Text/__tests__/DayOfTheWeek.test.tsx create mode 100644 src/common/components/Text/__tests__/Decimal.test.tsx create mode 100644 src/common/components/Text/__tests__/Integer.test.tsx create mode 100644 src/common/components/Text/__tests__/Percent.test.tsx create mode 100644 src/common/utils/types.ts diff --git a/src/common/components/Alert/Alert.tsx b/src/common/components/Alert/Alert.tsx new file mode 100644 index 0000000..71fc655 --- /dev/null +++ b/src/common/components/Alert/Alert.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '../../utils/types'; + +/** + * The `AlertVariant` enumerates types of Alerts. + */ +export enum AlertVariant { + Error = 'Error', + Info = 'Info', + Success = 'Success', + Warning = 'Warning', +} + +/** + * Properties for the `Alert` component. + * @param {AlertVariant} [variant] - Optional. The type of Alert. Default: `Info` + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface AlertProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId { + variant?: AlertVariant; +} + +/** + * The `Alert` React component formats and renders a styled message. Use the + * `variant` property to apply predefined styles. + * @param {AlertProps} props - Component properties, `AlertProps`. + * @returns {JSX.Element} JSX + */ +const Alert: React.FC = ({ + children, + className, + variant = AlertVariant.Info, + testId = 'alert', +}: AlertProps): JSX.Element => { + const getVariantClasses = (variant: AlertVariant): string => { + switch (variant) { + case AlertVariant.Error: + return 'bg-red-800/90 text-white/80'; + case AlertVariant.Warning: + return 'bg-amber-400/90 text-slate-900'; + case AlertVariant.Success: + return 'bg-green-800/90 text-white/80'; + case AlertVariant.Info: + default: + return 'bg-blue-800/90 text-white/80'; + } + }; + + return ( +
+ {children} +
+ ); +}; + +export default Alert; diff --git a/src/common/components/Alert/__tests__/Alert.test.tsx b/src/common/components/Alert/__tests__/Alert.test.tsx new file mode 100644 index 0000000..6862fd6 --- /dev/null +++ b/src/common/components/Alert/__tests__/Alert.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import Alert, { AlertVariant } from '../Alert'; + +describe('Alert', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert')).toBeDefined(); + }); + + it('should use custom test ID', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testid'); + + expect(screen.queryByTestId('alert')).toBeNull(); + expect(screen.getByTestId('custom-testid')).toBeDefined(); + }); + + it('should use classes from className property', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('custom-class'); + }); + + it('should render the Info variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('bg-blue-800/90'); + }); + + it('should render the Warning variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('bg-amber-400/90'); + }); + + it('should render the Error variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('bg-red-800/90'); + }); + + it('should render the Success variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('bg-green-800/90'); + }); + + it('should render the Info variant when variant not specified', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').classList).toContain('bg-blue-800/90'); + }); + + it('should have role=alert', async () => { + // ARRANGE + render(); + await screen.findByTestId('alert'); + + // ASSERT + expect(screen.getByTestId('alert').getAttribute('role')).toBe('alert'); + }); +}); diff --git a/src/common/components/Button/Button.tsx b/src/common/components/Button/Button.tsx index ffe0354..7a4a29b 100644 --- a/src/common/components/Button/Button.tsx +++ b/src/common/components/Button/Button.tsx @@ -1,20 +1,85 @@ -import { Button as CommonButton, ButtonProps } from '@leanstacks/react-common'; +import { ButtonHTMLAttributes } from 'react'; import classNames from 'classnames'; +import { PropsWithTestId } from 'common/utils/types'; + +/** + * The `ButtonVariant` enumerates types of Buttons. + */ +export enum ButtonVariant { + Solid = 'Solid', + Outline = 'Outline', + Text = 'Text', +} + +/** + * Properties for the `Button` component. + * @param {ButtonVariant} [variant] - Optional. The type of Button. Default: `Primary` + * @see {@link ButtonHTMLAttributes} + * @see {@link PropsWithTestId} + */ +export interface ButtonProps extends ButtonHTMLAttributes, PropsWithTestId { + variant?: ButtonVariant; +} + /** - * The `Button` React component wraps the `Button` component from `@leanstacks/react-common` - * to refine the base CSS classes. + * The `Button` React componentformats and renders a styled button. * @param {ButtonProps} props - Component properties, `ButtonProps`. * @returns {JSX.Element} JSX - * @see {@link ButtonProps} */ -const Button = ({ className, ...props }: ButtonProps): JSX.Element => { +const Button = ({ + children, + className, + role = 'button', + type = 'button', + variant = ButtonVariant.Solid, + testId = 'button', + ...props +}: ButtonProps): JSX.Element => { + const getVariantClasses = (variant: ButtonVariant): string => { + switch (variant) { + case ButtonVariant.Outline: + return 'border-neutral-700 dark:border-neutral-300'; + case ButtonVariant.Text: + return 'border-transparent'; + case ButtonVariant.Solid: + default: + return 'border-neutral-700 bg-neutral-700 text-white dark:border-neutral-300 dark:bg-neutral-300 dark:text-neutral-900'; + } + }; + return ( - + > + {children} + ); }; + +// /** +// * The `Button` React component wraps the `Button` component from `@leanstacks/react-common` +// * to refine the base CSS classes. +// * @param {ButtonProps} props - Component properties, `ButtonProps`. +// * @returns {JSX.Element} JSX +// * @see {@link ButtonProps} +// */ +// const Button = ({ className, ...props }: ButtonProps): JSX.Element => { +// return ( +// +// ); +// }; + export default Button; diff --git a/src/common/components/Button/__tests__/Button.test.tsx b/src/common/components/Button/__tests__/Button.test.tsx index 491dce5..89b2840 100644 --- a/src/common/components/Button/__tests__/Button.test.tsx +++ b/src/common/components/Button/__tests__/Button.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; -import Button from '../Button'; +import Button, { ButtonVariant } from '../Button'; describe('Button', () => { it('should render successfully', async () => { @@ -12,4 +13,154 @@ describe('Button', () => { // ASSERT expect(screen.getByTestId('button')).toBeDefined(); }); + + it('should use custom test ID', async () => { + // ARRANGE + render( } content={ setLanguage('en')} testId="dropdown-item-en"> - setLanguage('fr')} testId="dropdown-item-fr"> - setLanguage('es')} testId="dropdown-item-es"> - diff --git a/src/common/components/Button/ThemeToggle.tsx b/src/common/components/Button/ThemeToggle.tsx index 0ba1d33..c3beb61 100644 --- a/src/common/components/Button/ThemeToggle.tsx +++ b/src/common/components/Button/ThemeToggle.tsx @@ -1,6 +1,6 @@ -import { ButtonVariant, PropsWithClassName } from '@leanstacks/react-common'; import classNames from 'classnames'; +import { PropsWithClassName } from 'common/utils/types'; import { useSetSettings } from 'common/api/useSetSettings'; import { useSettings } from 'common/hooks/useSettings'; import Button from './Button'; @@ -26,7 +26,7 @@ const ThemeToggle = ({ className }: ThemeToggleProps): JSX.Element => { <> {settings?.theme === 'light' ? ( ) : (
+
- Outline button`} - /> + Outline button`} />
- +
- Solid button`} - /> + Solid button`} />
- +
- Text button`} - /> + Text button`} />
} content={ setLanguage('en')} testId="dropdown-item-en"> - setLanguage('fr')} testId="dropdown-item-fr"> - setLanguage('es')} testId="dropdown-item-es"> -