From 337b8870aa6da6119af080395c798a01e610fa11 Mon Sep 17 00:00:00 2001 From: Alan Alickovic Date: Sat, 24 Aug 2024 19:54:06 +0200 Subject: [PATCH] init nextjs-app from nextjs-pages --- apps/nextjs-app/.env.example | 4 + apps/nextjs-app/.env.example-e2e | 4 + apps/nextjs-app/.eslintrc.cjs | 154 + apps/nextjs-app/.gitignore | 43 + apps/nextjs-app/.prettierignore | 1 + apps/nextjs-app/.prettierrc | 7 + apps/nextjs-app/.storybook/main.ts | 20 + apps/nextjs-app/.storybook/preview.tsx | 8 + apps/nextjs-app/.vscode/extensions.json | 9 + apps/nextjs-app/.vscode/settings.json | 6 + apps/nextjs-app/README.md | 32 + apps/nextjs-app/__mocks__/vitest-env.d.ts | 2 + apps/nextjs-app/__mocks__/zustand.ts | 52 + apps/nextjs-app/e2e/.eslintrc.cjs | 5 + apps/nextjs-app/e2e/tests/auth.setup.ts | 42 + apps/nextjs-app/e2e/tests/profile.spec.ts | 17 + apps/nextjs-app/e2e/tests/smoke.spec.ts | 95 + .../component/component.stories.tsx.hbs | 15 + .../generators/component/component.tsx.hbs | 11 + .../nextjs-app/generators/component/index.cjs | 56 + .../generators/component/index.ts.hbs | 1 + apps/nextjs-app/index.html | 18 + apps/nextjs-app/lint-staged.config.mjs | 14 + apps/nextjs-app/mock-server.ts | 29 + apps/nextjs-app/next-env.d.ts | 6 + apps/nextjs-app/next.config.mjs | 6 + apps/nextjs-app/package.json | 119 + apps/nextjs-app/playwright.config.ts | 56 + apps/nextjs-app/plopfile.cjs | 9 + apps/nextjs-app/postcss.config.cjs | 6 + apps/nextjs-app/public/_redirects | 1 + apps/nextjs-app/public/favicon.ico | Bin 0 -> 3870 bytes apps/nextjs-app/public/logo.svg | 1 + apps/nextjs-app/public/logo192.png | Bin 0 -> 5347 bytes apps/nextjs-app/public/logo512.png | Bin 0 -> 9664 bytes apps/nextjs-app/public/mockServiceWorker.js | 284 + apps/nextjs-app/public/robots.txt | 3 + .../src/app/pages/app/dashboard.tsx | 45 + .../discussions/__tests__/discussion.test.tsx | 152 + .../__tests__/discussions.test.tsx | 99 + .../app/pages/app/discussions/discussion.tsx | 92 + .../app/pages/app/discussions/discussions.tsx | 37 + apps/nextjs-app/src/app/pages/app/profile.tsx | 57 + apps/nextjs-app/src/app/pages/app/users.tsx | 26 + apps/nextjs-app/src/app/pages/auth/login.tsx | 22 + .../src/app/pages/auth/register.tsx | 35 + apps/nextjs-app/src/app/provider.tsx | 40 + .../nextjs-app/src/components/errors/main.tsx | 18 + .../src/components/layouts/auth-layout.tsx | 49 + .../src/components/layouts/content-layout.tsx | 24 + .../components/layouts/dashboard-layout.tsx | 251 + .../src/components/layouts/index.ts | 2 + apps/nextjs-app/src/components/seo/head.tsx | 15 + apps/nextjs-app/src/components/seo/index.ts | 1 + .../components/ui/button/button.stories.tsx | 17 + .../src/components/ui/button/button.tsx | 76 + .../src/components/ui/button/index.ts | 1 + .../ui/dialog/__tests__/dialog.test.tsx | 68 + .../__tests__/confirmation-dialog.test.tsx | 37 + .../confirmation-dialog.stories.tsx | 33 + .../confirmation-dialog.tsx | 88 + .../ui/dialog/confirmation-dialog/index.ts | 1 + .../components/ui/dialog/dialog.stories.tsx | 63 + .../src/components/ui/dialog/dialog.tsx | 120 + .../src/components/ui/dialog/index.ts | 2 + .../ui/drawer/__tests__/drawer.test.tsx | 66 + .../components/ui/drawer/drawer.stories.tsx | 64 + .../src/components/ui/drawer/drawer.tsx | 139 + .../src/components/ui/drawer/index.ts | 1 + .../src/components/ui/dropdown/dropdown.tsx | 203 + .../src/components/ui/dropdown/index.ts | 1 + .../ui/form/__tests__/form.test.tsx | 74 + .../src/components/ui/form/error.tsx | 17 + .../src/components/ui/form/field-wrapper.tsx | 30 + .../src/components/ui/form/form-drawer.tsx | 69 + .../src/components/ui/form/form.stories.tsx | 87 + .../src/components/ui/form/form.tsx | 217 + .../src/components/ui/form/index.ts | 7 + .../src/components/ui/form/input.tsx | 34 + .../src/components/ui/form/label.tsx | 24 + .../src/components/ui/form/select.tsx | 41 + .../src/components/ui/form/switch.tsx | 27 + .../src/components/ui/form/textarea.tsx | 33 + .../src/components/ui/link/index.ts | 1 + .../src/components/ui/link/link.stories.tsx | 18 + .../src/components/ui/link/link.tsx | 21 + .../src/components/ui/md-preview/index.ts | 1 + .../ui/md-preview/md-preview.stories.tsx | 17 + .../components/ui/md-preview/md-preview.tsx | 17 + .../__tests__/notifications.test.ts | 28 + .../src/components/ui/notifications/index.ts | 2 + .../ui/notifications/notification.stories.tsx | 63 + .../ui/notifications/notification.tsx | 52 + .../ui/notifications/notifications-store.ts | 32 + .../ui/notifications/notifications.tsx | 21 + .../src/components/ui/spinner/index.ts | 1 + .../components/ui/spinner/spinner.stories.tsx | 17 + .../src/components/ui/spinner/spinner.tsx | 50 + .../src/components/ui/table/index.ts | 1 + .../src/components/ui/table/pagination.tsx | 188 + .../src/components/ui/table/table.stories.tsx | 63 + .../src/components/ui/table/table.tsx | 177 + apps/nextjs-app/src/config/env.ts | 39 + .../components/__tests__/login-form.test.tsx | 24 + .../__tests__/register-form.test.tsx | 30 + .../features/auth/components/login-form.tsx | 65 + .../auth/components/register-form.tsx | 119 + .../features/comments/api/create-comment.ts | 48 + .../features/comments/api/delete-comment.ts | 35 + .../src/features/comments/api/get-comments.ts | 53 + .../comments/components/comments-list.tsx | 97 + .../features/comments/components/comments.tsx | 22 + .../comments/components/create-comment.tsx | 76 + .../comments/components/delete-comment.tsx | 55 + .../discussions/api/create-discussion.ts | 47 + .../discussions/api/delete-discussion.ts | 37 + .../discussions/api/get-discussion.ts | 42 + .../discussions/api/get-discussions.ts | 44 + .../discussions/api/update-discussion.ts | 49 + .../components/create-discussion.tsx | 98 + .../components/delete-discussion.tsx | 53 + .../components/discussion-view.tsx | 73 + .../components/discussions-list.tsx | 92 + .../components/update-discussion.tsx | 108 + .../src/features/teams/api/get-teams.ts | 27 + .../src/features/users/api/delete-user.ts | 37 + .../src/features/users/api/get-users.ts | 27 + .../src/features/users/api/update-profile.ts | 40 + .../features/users/components/delete-user.tsx | 46 + .../users/components/update-profile.tsx | 91 + .../features/users/components/users-list.tsx | 61 + .../hooks/__tests__/use-disclosure.test.ts | 57 + apps/nextjs-app/src/hooks/use-disclosure.ts | 11 + .../src/lib/__tests__/authorization.test.tsx | 85 + apps/nextjs-app/src/lib/api-client.ts | 58 + apps/nextjs-app/src/lib/auth.tsx | 91 + apps/nextjs-app/src/lib/authorization.tsx | 85 + apps/nextjs-app/src/lib/react-query.ts | 26 + apps/nextjs-app/src/pages/404.tsx | 15 + apps/nextjs-app/src/pages/_app.tsx | 21 + .../pages/app/discussions/[discussionId].tsx | 1 + .../src/pages/app/discussions/index.tsx | 1 + apps/nextjs-app/src/pages/app/index.tsx | 1 + apps/nextjs-app/src/pages/app/profile.tsx | 1 + apps/nextjs-app/src/pages/app/users.tsx | 1 + apps/nextjs-app/src/pages/auth/login.tsx | 1 + apps/nextjs-app/src/pages/auth/register.tsx | 1 + apps/nextjs-app/src/pages/index.tsx | 89 + .../public/discussions/[discussionId].tsx | 4 + apps/nextjs-app/src/styles/globals.css | 90 + .../nextjs-app/src/testing/data-generators.ts | 77 + apps/nextjs-app/src/testing/mocks/browser.ts | 5 + apps/nextjs-app/src/testing/mocks/db.ts | 102 + .../src/testing/mocks/handlers/auth.ts | 165 + .../src/testing/mocks/handlers/comments.ts | 142 + .../src/testing/mocks/handlers/discussions.ts | 249 + .../src/testing/mocks/handlers/index.ts | 23 + .../src/testing/mocks/handlers/teams.ts | 22 + .../src/testing/mocks/handlers/users.ts | 104 + apps/nextjs-app/src/testing/mocks/index.ts | 10 + apps/nextjs-app/src/testing/mocks/server.ts | 5 + apps/nextjs-app/src/testing/mocks/utils.ts | 110 + apps/nextjs-app/src/testing/setup-tests.ts | 30 + apps/nextjs-app/src/testing/test-utils.tsx | 75 + apps/nextjs-app/src/types/api.ts | 51 + apps/nextjs-app/src/utils/cn.ts | 6 + apps/nextjs-app/src/utils/format.ts | 4 + apps/nextjs-app/tailwind.config.cjs | 76 + apps/nextjs-app/tsconfig.json | 27 + apps/nextjs-app/vitest.config.ts | 19 + apps/nextjs-app/yarn.lock | 12031 ++++++++++++++++ 171 files changed, 20118 insertions(+) create mode 100644 apps/nextjs-app/.env.example create mode 100644 apps/nextjs-app/.env.example-e2e create mode 100644 apps/nextjs-app/.eslintrc.cjs create mode 100644 apps/nextjs-app/.gitignore create mode 100644 apps/nextjs-app/.prettierignore create mode 100644 apps/nextjs-app/.prettierrc create mode 100644 apps/nextjs-app/.storybook/main.ts create mode 100644 apps/nextjs-app/.storybook/preview.tsx create mode 100644 apps/nextjs-app/.vscode/extensions.json create mode 100644 apps/nextjs-app/.vscode/settings.json create mode 100644 apps/nextjs-app/README.md create mode 100644 apps/nextjs-app/__mocks__/vitest-env.d.ts create mode 100644 apps/nextjs-app/__mocks__/zustand.ts create mode 100644 apps/nextjs-app/e2e/.eslintrc.cjs create mode 100644 apps/nextjs-app/e2e/tests/auth.setup.ts create mode 100644 apps/nextjs-app/e2e/tests/profile.spec.ts create mode 100644 apps/nextjs-app/e2e/tests/smoke.spec.ts create mode 100644 apps/nextjs-app/generators/component/component.stories.tsx.hbs create mode 100644 apps/nextjs-app/generators/component/component.tsx.hbs create mode 100644 apps/nextjs-app/generators/component/index.cjs create mode 100644 apps/nextjs-app/generators/component/index.ts.hbs create mode 100644 apps/nextjs-app/index.html create mode 100644 apps/nextjs-app/lint-staged.config.mjs create mode 100644 apps/nextjs-app/mock-server.ts create mode 100644 apps/nextjs-app/next-env.d.ts create mode 100644 apps/nextjs-app/next.config.mjs create mode 100644 apps/nextjs-app/package.json create mode 100644 apps/nextjs-app/playwright.config.ts create mode 100644 apps/nextjs-app/plopfile.cjs create mode 100644 apps/nextjs-app/postcss.config.cjs create mode 100644 apps/nextjs-app/public/_redirects create mode 100644 apps/nextjs-app/public/favicon.ico create mode 100644 apps/nextjs-app/public/logo.svg create mode 100644 apps/nextjs-app/public/logo192.png create mode 100644 apps/nextjs-app/public/logo512.png create mode 100644 apps/nextjs-app/public/mockServiceWorker.js create mode 100644 apps/nextjs-app/public/robots.txt create mode 100644 apps/nextjs-app/src/app/pages/app/dashboard.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussion.test.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussions.test.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/discussions/discussion.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/discussions/discussions.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/profile.tsx create mode 100644 apps/nextjs-app/src/app/pages/app/users.tsx create mode 100644 apps/nextjs-app/src/app/pages/auth/login.tsx create mode 100644 apps/nextjs-app/src/app/pages/auth/register.tsx create mode 100644 apps/nextjs-app/src/app/provider.tsx create mode 100644 apps/nextjs-app/src/components/errors/main.tsx create mode 100644 apps/nextjs-app/src/components/layouts/auth-layout.tsx create mode 100644 apps/nextjs-app/src/components/layouts/content-layout.tsx create mode 100644 apps/nextjs-app/src/components/layouts/dashboard-layout.tsx create mode 100644 apps/nextjs-app/src/components/layouts/index.ts create mode 100644 apps/nextjs-app/src/components/seo/head.tsx create mode 100644 apps/nextjs-app/src/components/seo/index.ts create mode 100644 apps/nextjs-app/src/components/ui/button/button.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/button/button.tsx create mode 100644 apps/nextjs-app/src/components/ui/button/index.ts create mode 100644 apps/nextjs-app/src/components/ui/dialog/__tests__/dialog.test.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts create mode 100644 apps/nextjs-app/src/components/ui/dialog/dialog.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/dialog.tsx create mode 100644 apps/nextjs-app/src/components/ui/dialog/index.ts create mode 100644 apps/nextjs-app/src/components/ui/drawer/__tests__/drawer.test.tsx create mode 100644 apps/nextjs-app/src/components/ui/drawer/drawer.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/drawer/drawer.tsx create mode 100644 apps/nextjs-app/src/components/ui/drawer/index.ts create mode 100644 apps/nextjs-app/src/components/ui/dropdown/dropdown.tsx create mode 100644 apps/nextjs-app/src/components/ui/dropdown/index.ts create mode 100644 apps/nextjs-app/src/components/ui/form/__tests__/form.test.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/error.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/field-wrapper.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/form-drawer.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/form.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/form.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/index.ts create mode 100644 apps/nextjs-app/src/components/ui/form/input.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/label.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/select.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/switch.tsx create mode 100644 apps/nextjs-app/src/components/ui/form/textarea.tsx create mode 100644 apps/nextjs-app/src/components/ui/link/index.ts create mode 100644 apps/nextjs-app/src/components/ui/link/link.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/link/link.tsx create mode 100644 apps/nextjs-app/src/components/ui/md-preview/index.ts create mode 100644 apps/nextjs-app/src/components/ui/md-preview/md-preview.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/md-preview/md-preview.tsx create mode 100644 apps/nextjs-app/src/components/ui/notifications/__tests__/notifications.test.ts create mode 100644 apps/nextjs-app/src/components/ui/notifications/index.ts create mode 100644 apps/nextjs-app/src/components/ui/notifications/notification.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/notifications/notification.tsx create mode 100644 apps/nextjs-app/src/components/ui/notifications/notifications-store.ts create mode 100644 apps/nextjs-app/src/components/ui/notifications/notifications.tsx create mode 100644 apps/nextjs-app/src/components/ui/spinner/index.ts create mode 100644 apps/nextjs-app/src/components/ui/spinner/spinner.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/spinner/spinner.tsx create mode 100644 apps/nextjs-app/src/components/ui/table/index.ts create mode 100644 apps/nextjs-app/src/components/ui/table/pagination.tsx create mode 100644 apps/nextjs-app/src/components/ui/table/table.stories.tsx create mode 100644 apps/nextjs-app/src/components/ui/table/table.tsx create mode 100644 apps/nextjs-app/src/config/env.ts create mode 100644 apps/nextjs-app/src/features/auth/components/__tests__/login-form.test.tsx create mode 100644 apps/nextjs-app/src/features/auth/components/__tests__/register-form.test.tsx create mode 100644 apps/nextjs-app/src/features/auth/components/login-form.tsx create mode 100644 apps/nextjs-app/src/features/auth/components/register-form.tsx create mode 100644 apps/nextjs-app/src/features/comments/api/create-comment.ts create mode 100644 apps/nextjs-app/src/features/comments/api/delete-comment.ts create mode 100644 apps/nextjs-app/src/features/comments/api/get-comments.ts create mode 100644 apps/nextjs-app/src/features/comments/components/comments-list.tsx create mode 100644 apps/nextjs-app/src/features/comments/components/comments.tsx create mode 100644 apps/nextjs-app/src/features/comments/components/create-comment.tsx create mode 100644 apps/nextjs-app/src/features/comments/components/delete-comment.tsx create mode 100644 apps/nextjs-app/src/features/discussions/api/create-discussion.ts create mode 100644 apps/nextjs-app/src/features/discussions/api/delete-discussion.ts create mode 100644 apps/nextjs-app/src/features/discussions/api/get-discussion.ts create mode 100644 apps/nextjs-app/src/features/discussions/api/get-discussions.ts create mode 100644 apps/nextjs-app/src/features/discussions/api/update-discussion.ts create mode 100644 apps/nextjs-app/src/features/discussions/components/create-discussion.tsx create mode 100644 apps/nextjs-app/src/features/discussions/components/delete-discussion.tsx create mode 100644 apps/nextjs-app/src/features/discussions/components/discussion-view.tsx create mode 100644 apps/nextjs-app/src/features/discussions/components/discussions-list.tsx create mode 100644 apps/nextjs-app/src/features/discussions/components/update-discussion.tsx create mode 100644 apps/nextjs-app/src/features/teams/api/get-teams.ts create mode 100644 apps/nextjs-app/src/features/users/api/delete-user.ts create mode 100644 apps/nextjs-app/src/features/users/api/get-users.ts create mode 100644 apps/nextjs-app/src/features/users/api/update-profile.ts create mode 100644 apps/nextjs-app/src/features/users/components/delete-user.tsx create mode 100644 apps/nextjs-app/src/features/users/components/update-profile.tsx create mode 100644 apps/nextjs-app/src/features/users/components/users-list.tsx create mode 100644 apps/nextjs-app/src/hooks/__tests__/use-disclosure.test.ts create mode 100644 apps/nextjs-app/src/hooks/use-disclosure.ts create mode 100644 apps/nextjs-app/src/lib/__tests__/authorization.test.tsx create mode 100644 apps/nextjs-app/src/lib/api-client.ts create mode 100644 apps/nextjs-app/src/lib/auth.tsx create mode 100644 apps/nextjs-app/src/lib/authorization.tsx create mode 100644 apps/nextjs-app/src/lib/react-query.ts create mode 100644 apps/nextjs-app/src/pages/404.tsx create mode 100644 apps/nextjs-app/src/pages/_app.tsx create mode 100644 apps/nextjs-app/src/pages/app/discussions/[discussionId].tsx create mode 100644 apps/nextjs-app/src/pages/app/discussions/index.tsx create mode 100644 apps/nextjs-app/src/pages/app/index.tsx create mode 100644 apps/nextjs-app/src/pages/app/profile.tsx create mode 100644 apps/nextjs-app/src/pages/app/users.tsx create mode 100644 apps/nextjs-app/src/pages/auth/login.tsx create mode 100644 apps/nextjs-app/src/pages/auth/register.tsx create mode 100644 apps/nextjs-app/src/pages/index.tsx create mode 100644 apps/nextjs-app/src/pages/public/discussions/[discussionId].tsx create mode 100644 apps/nextjs-app/src/styles/globals.css create mode 100644 apps/nextjs-app/src/testing/data-generators.ts create mode 100644 apps/nextjs-app/src/testing/mocks/browser.ts create mode 100644 apps/nextjs-app/src/testing/mocks/db.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/auth.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/comments.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/discussions.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/index.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/teams.ts create mode 100644 apps/nextjs-app/src/testing/mocks/handlers/users.ts create mode 100644 apps/nextjs-app/src/testing/mocks/index.ts create mode 100644 apps/nextjs-app/src/testing/mocks/server.ts create mode 100644 apps/nextjs-app/src/testing/mocks/utils.ts create mode 100644 apps/nextjs-app/src/testing/setup-tests.ts create mode 100644 apps/nextjs-app/src/testing/test-utils.tsx create mode 100644 apps/nextjs-app/src/types/api.ts create mode 100644 apps/nextjs-app/src/utils/cn.ts create mode 100644 apps/nextjs-app/src/utils/format.ts create mode 100644 apps/nextjs-app/tailwind.config.cjs create mode 100644 apps/nextjs-app/tsconfig.json create mode 100644 apps/nextjs-app/vitest.config.ts create mode 100644 apps/nextjs-app/yarn.lock diff --git a/apps/nextjs-app/.env.example b/apps/nextjs-app/.env.example new file mode 100644 index 00000000..1eabee36 --- /dev/null +++ b/apps/nextjs-app/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_URL=http://localhost:8080/api +NEXT_PUBLIC_ENABLE_API_MOCKING=false +NEXT_PUBLIC_MOCK_API_PORT=8080 +NEXT_PUBLIC_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/nextjs-app/.env.example-e2e b/apps/nextjs-app/.env.example-e2e new file mode 100644 index 00000000..1eabee36 --- /dev/null +++ b/apps/nextjs-app/.env.example-e2e @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_URL=http://localhost:8080/api +NEXT_PUBLIC_ENABLE_API_MOCKING=false +NEXT_PUBLIC_MOCK_API_PORT=8080 +NEXT_PUBLIC_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/nextjs-app/.eslintrc.cjs b/apps/nextjs-app/.eslintrc.cjs new file mode 100644 index 00000000..410fbe9f --- /dev/null +++ b/apps/nextjs-app/.eslintrc.cjs @@ -0,0 +1,154 @@ +module.exports = { + root: true, + env: { + node: true, + es6: true, + }, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + ignorePatterns: [ + 'node_modules/*', + 'public/mockServiceWorker.js', + 'generators/*', + ], + extends: ['eslint:recommended', 'next/core-web-vitals'], + plugins: ['check-file'], + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + parser: '@typescript-eslint/parser', + settings: { + react: { version: 'detect' }, + 'import/resolver': { + typescript: {}, + }, + }, + env: { + browser: true, + node: true, + es6: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:prettier/recommended', + 'plugin:testing-library/react', + 'plugin:jest-dom/recommended', + 'plugin:tailwindcss/recommended', + 'plugin:vitest/legacy-recommended', + ], + rules: { + '@next/next/no-img-element': 'off', + 'import/no-restricted-paths': [ + 'error', + { + zones: [ + // disables cross-feature imports: + // eg. src/features/discussions should not import from src/features/comments, etc. + { + target: './src/features/auth', + from: './src/features', + except: ['./auth'], + }, + { + target: './src/features/comments', + from: './src/features', + except: ['./comments'], + }, + { + target: './src/features/discussions', + from: './src/features', + except: ['./discussions'], + }, + { + target: './src/features/teams', + from: './src/features', + except: ['./teams'], + }, + { + target: './src/features/users', + from: './src/features', + except: ['./users'], + }, + // enforce unidirectional codebase: + + // e.g. src/app can import from src/features but not the other way around + { + target: './src/features', + from: './src/app', + }, + + // e.g src/features and src/app can import from these shared modules but not the other way around + { + target: [ + './src/components', + './src/hooks', + './src/lib', + './src/types', + './src/utils', + ], + from: ['./src/features', './src/app'], + }, + ], + }, + ], + 'import/no-cycle': 'error', + 'linebreak-style': ['error', 'unix'], + 'react/prop-types': 'off', + 'import/order': [ + 'error', + { + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + 'object', + ], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + 'import/default': 'off', + 'import/no-named-as-default-member': 'off', + 'import/no-named-as-default': 'off', + 'react/react-in-jsx-scope': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + '@typescript-eslint/no-unused-vars': ['error'], + '@typescript-eslint/explicit-function-return-type': ['off'], + '@typescript-eslint/explicit-module-boundary-types': ['off'], + '@typescript-eslint/no-empty-function': ['off'], + '@typescript-eslint/no-explicit-any': ['off'], + 'prettier/prettier': ['error', {}, { usePrettierrc: true }], + 'check-file/filename-naming-convention': [ + 'error', + { + 'src/!(pages)/*.{ts,tsx}': 'KEBAB_CASE', + }, + { + ignoreMiddleExtensions: true, + }, + ], + }, + }, + { + plugins: ['check-file'], + files: ['src/**/!(__tests__)/*'], + rules: { + 'check-file/folder-naming-convention': [ + 'error', + { + '**/*': 'KEBAB_CASE', + }, + ], + }, + }, + ], +}; diff --git a/apps/nextjs-app/.gitignore b/apps/nextjs-app/.gitignore new file mode 100644 index 00000000..dc32fea3 --- /dev/null +++ b/apps/nextjs-app/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/e2e/.auth/ + +# storybook +migration-storybook.log +storybook.log +storybook-static + + +# production +/dist + +# misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + + +# local +mocked-db.json + +/.next +/.vite +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/nextjs-app/.prettierignore b/apps/nextjs-app/.prettierignore new file mode 100644 index 00000000..5acb4c91 --- /dev/null +++ b/apps/nextjs-app/.prettierignore @@ -0,0 +1 @@ +*.hbs \ No newline at end of file diff --git a/apps/nextjs-app/.prettierrc b/apps/nextjs-app/.prettierrc new file mode 100644 index 00000000..e59f1ac5 --- /dev/null +++ b/apps/nextjs-app/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false +} diff --git a/apps/nextjs-app/.storybook/main.ts b/apps/nextjs-app/.storybook/main.ts new file mode 100644 index 00000000..808ced22 --- /dev/null +++ b/apps/nextjs-app/.storybook/main.ts @@ -0,0 +1,20 @@ +module.exports = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + + addons: [ + '@storybook/addon-actions', + '@storybook/addon-links', + '@storybook/node-logger', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-docs', + '@storybook/addon-a11y', + ], + framework: '@storybook/nextjs', + docs: { + autodocs: 'tag', + }, + typescript: { + reactDocgen: 'react-docgen-typescript', + }, +}; diff --git a/apps/nextjs-app/.storybook/preview.tsx b/apps/nextjs-app/.storybook/preview.tsx new file mode 100644 index 00000000..9d181d43 --- /dev/null +++ b/apps/nextjs-app/.storybook/preview.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import '../src/styles/globals.css'; + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, +}; + +export const decorators = [(Story) => ]; diff --git a/apps/nextjs-app/.vscode/extensions.json b/apps/nextjs-app/.vscode/extensions.json new file mode 100644 index 00000000..1cd1faf7 --- /dev/null +++ b/apps/nextjs-app/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "dsznajder.es7-react-js-snippets", + "mariusalchimavicius.json-to-ts", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/apps/nextjs-app/.vscode/settings.json b/apps/nextjs-app/.vscode/settings.json new file mode 100644 index 00000000..1947a0d0 --- /dev/null +++ b/apps/nextjs-app/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } +} diff --git a/apps/nextjs-app/README.md b/apps/nextjs-app/README.md new file mode 100644 index 00000000..b91ef588 --- /dev/null +++ b/apps/nextjs-app/README.md @@ -0,0 +1,32 @@ +# Next.js Pages Application + +## Get Started + +Prerequisites: + +- Node 20+ +- Yarn 1.22+ + +To set up the app execute the following commands. + +```bash +git clone https://github.com/alan2207/bulletproof-react.git +cd bulletproof-react +cd apps/nextjs-pages +cp .env.example .env +yarn install +``` + +#### `yarn run-mock-server` + +Make sure to start the mock server before running the app. +The mock server runs on [http://localhost:8080/api](http://localhost:8080/api). + +##### `yarn dev` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +## Project Structure + +Since the `pages` folder isn't very flexible and doesn't allow file collocation, we are keeping the `app` folder which is our application layer where we compose all the features, and then we just re-export Next.js page specific files (the pages and `getServerSideProps`) from the `pages` folder so Next.js can pick them up and serve as pages. diff --git a/apps/nextjs-app/__mocks__/vitest-env.d.ts b/apps/nextjs-app/__mocks__/vitest-env.d.ts new file mode 100644 index 00000000..97ce4524 --- /dev/null +++ b/apps/nextjs-app/__mocks__/vitest-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/nextjs-app/__mocks__/zustand.ts b/apps/nextjs-app/__mocks__/zustand.ts new file mode 100644 index 00000000..ef8fb9e8 --- /dev/null +++ b/apps/nextjs-app/__mocks__/zustand.ts @@ -0,0 +1,52 @@ +import { act } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; +import * as zustand from 'zustand'; + +const { create: actualCreate, createStore: actualCreateStore } = + await vi.importActual('zustand'); + +// a variable to hold reset functions for all stores declared in the app +export const storeResetFns = new Set<() => void>(); + +const createUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreate(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const create = ((stateCreator: zustand.StateCreator) => { + // to support curried version of create + return typeof stateCreator === 'function' + ? createUncurried(stateCreator) + : createUncurried; +}) as typeof zustand.create; + +const createStoreUncurried = (stateCreator: zustand.StateCreator) => { + const store = actualCreateStore(stateCreator); + const initialState = store.getInitialState(); + storeResetFns.add(() => { + store.setState(initialState, true); + }); + return store; +}; + +// when creating a store, we get its initial state, create a reset function and add it in the set +export const createStore = ((stateCreator: zustand.StateCreator) => { + // to support curried version of createStore + return typeof stateCreator === 'function' + ? createStoreUncurried(stateCreator) + : createStoreUncurried; +}) as typeof zustand.createStore; + +// reset all stores after each test run +afterEach(() => { + act(() => { + storeResetFns.forEach((resetFn) => { + resetFn(); + }); + }); +}); diff --git a/apps/nextjs-app/e2e/.eslintrc.cjs b/apps/nextjs-app/e2e/.eslintrc.cjs new file mode 100644 index 00000000..30644b49 --- /dev/null +++ b/apps/nextjs-app/e2e/.eslintrc.cjs @@ -0,0 +1,5 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + extends: 'plugin:playwright/recommended', +}; diff --git a/apps/nextjs-app/e2e/tests/auth.setup.ts b/apps/nextjs-app/e2e/tests/auth.setup.ts new file mode 100644 index 00000000..1117cc38 --- /dev/null +++ b/apps/nextjs-app/e2e/tests/auth.setup.ts @@ -0,0 +1,42 @@ +import { test as setup, expect } from '@playwright/test'; +import { createUser } from '../../src/testing/data-generators'; + +const authFile = 'e2e/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + const user = createUser(); + + await page.goto('/'); + await page.getByRole('button', { name: 'Get started' }).click(); + await page.waitForURL('/auth/login'); + await page.getByRole('link', { name: 'Register' }).click(); + + // registration: + await page.getByLabel('First Name').click(); + await page.getByLabel('First Name').fill(user.firstName); + await page.getByLabel('Last Name').click(); + await page.getByLabel('Last Name').fill(user.lastName); + await page.getByLabel('Email Address').click(); + await page.getByLabel('Email Address').fill(user.email); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(user.password); + await page.getByLabel('Team Name').click(); + await page.getByLabel('Team Name').fill(user.teamName); + await page.getByRole('button', { name: 'Register' }).click(); + await page.waitForURL('/app'); + + // log out: + await page.getByRole('button', { name: 'Open user menu' }).click(); + await page.getByRole('menuitem', { name: 'Sign Out' }).click(); + await page.waitForURL('/auth/login?redirectTo=%2Fapp'); + + // log in: + await page.getByLabel('Email Address').click(); + await page.getByLabel('Email Address').fill(user.email); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(user.password); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.waitForURL('/app'); + + await page.context().storageState({ path: authFile }); +}); diff --git a/apps/nextjs-app/e2e/tests/profile.spec.ts b/apps/nextjs-app/e2e/tests/profile.spec.ts new file mode 100644 index 00000000..21d48c33 --- /dev/null +++ b/apps/nextjs-app/e2e/tests/profile.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test'; + +test('profile', async ({ page }) => { + // update user: + await page.goto('/app'); + await page.getByRole('button', { name: 'Open user menu' }).click(); + await page.getByRole('menuitem', { name: 'Your Profile' }).click(); + await page.getByRole('button', { name: 'Update Profile' }).click(); + await page.getByLabel('Bio').click(); + await page.getByLabel('Bio').fill('My bio'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page + .getByLabel('Profile Updated') + .getByRole('button', { name: 'Close' }) + .click(); + await expect(page.getByText('My bio')).toBeVisible(); +}); diff --git a/apps/nextjs-app/e2e/tests/smoke.spec.ts b/apps/nextjs-app/e2e/tests/smoke.spec.ts new file mode 100644 index 00000000..9398601e --- /dev/null +++ b/apps/nextjs-app/e2e/tests/smoke.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; + +import { + createDiscussion, + createComment, +} from '../../src/testing/data-generators'; +test('smoke', async ({ page }) => { + const discussion = createDiscussion(); + const comment = createComment(); + + await page.goto('/'); + await page.getByRole('button', { name: 'Get started' }).click(); + await page.waitForURL('/app'); + + // create discussion: + await page.getByRole('link', { name: 'Discussions' }).click(); + await page.waitForURL('/app/discussions'); + + await page.getByRole('button', { name: 'Create Discussion' }).click(); + await page.getByLabel('Title').click(); + await page.getByLabel('Title').fill(discussion.title); + await page.getByLabel('Body').click(); + await page.getByLabel('Body').fill(discussion.body); + await page.getByRole('button', { name: 'Submit' }).click(); + await page + .getByLabel('Discussion Created') + .getByRole('button', { name: 'Close' }) + .click(); + + // visit discussion page: + await page.getByRole('link', { name: 'View' }).click(); + + await expect( + page.getByRole('heading', { name: discussion.title }), + ).toBeVisible(); + await expect(page.getByText(discussion.body)).toBeVisible(); + + // update discussion: + await page.getByRole('button', { name: 'Update Discussion' }).click(); + await page.getByLabel('Title').click(); + await page.getByLabel('Title').fill(`${discussion.title} - updated`); + await page.getByLabel('Body').click(); + await page.getByLabel('Body').fill(`${discussion.body} - updated`); + await page.getByRole('button', { name: 'Submit' }).click(); + await page + .getByLabel('Discussion Updated') + .getByRole('button', { name: 'Close' }) + .click(); + + await expect( + page.getByRole('heading', { name: `${discussion.title} - updated` }), + ).toBeVisible(); + await expect(page.getByText(`${discussion.body} - updated`)).toBeVisible(); + + // create comment: + await page.getByRole('button', { name: 'Create Comment' }).click(); + await page.getByLabel('Body').click(); + await page.getByLabel('Body').fill(comment.body); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText(comment.body)).toBeVisible(); + await page + .getByLabel('Comment Created') + .getByRole('button', { name: 'Close' }) + .click(); + + // delete comment: + await page.getByRole('button', { name: 'Delete Comment' }).click(); + await expect( + page.getByText('Are you sure you want to delete this comment?'), + ).toBeVisible(); + await page.getByRole('button', { name: 'Delete Comment' }).click(); + await page + .getByLabel('Comment Deleted') + .getByRole('button', { name: 'Close' }) + .click(); + await expect( + page.getByRole('heading', { name: 'No Comments Found' }), + ).toBeVisible(); + await expect(page.getByText(comment.body)).toBeHidden(); + + // go back to discussions: + await page.getByRole('link', { name: 'Discussions' }).click(); + await page.waitForURL('/app/discussions'); + + // delete discussion: + await page.getByRole('button', { name: 'Delete Discussion' }).click(); + await page.getByRole('button', { name: 'Delete Discussion' }).click(); + await page + .getByLabel('Discussion Deleted') + .getByRole('button', { name: 'Close' }) + .click(); + await expect( + page.getByRole('heading', { name: 'No Entries Found' }), + ).toBeVisible(); +}); diff --git a/apps/nextjs-app/generators/component/component.stories.tsx.hbs b/apps/nextjs-app/generators/component/component.stories.tsx.hbs new file mode 100644 index 00000000..c3198d90 --- /dev/null +++ b/apps/nextjs-app/generators/component/component.stories.tsx.hbs @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { {{ properCase name }} } from './{{ kebabCase name }}'; + +const meta: Meta = { + component: {{ properCase name }}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; diff --git a/apps/nextjs-app/generators/component/component.tsx.hbs b/apps/nextjs-app/generators/component/component.tsx.hbs new file mode 100644 index 00000000..23971155 --- /dev/null +++ b/apps/nextjs-app/generators/component/component.tsx.hbs @@ -0,0 +1,11 @@ +import * as React from "react"; + +export type {{properCase name}}Props = {}; + +export const {{properCase name}} = (props: {{properCase name}}Props) => { + return ( +
+ {{properCase name}} +
+ ); +}; \ No newline at end of file diff --git a/apps/nextjs-app/generators/component/index.cjs b/apps/nextjs-app/generators/component/index.cjs new file mode 100644 index 00000000..0aa8d068 --- /dev/null +++ b/apps/nextjs-app/generators/component/index.cjs @@ -0,0 +1,56 @@ +const path = require('path'); +const fs = require('fs'); + +const featuresDir = path.join(process.cwd(), 'src/features'); +const features = fs.readdirSync(featuresDir); + +/** + * + * @type {import('plop').PlopGenerator} + */ +module.exports = { + description: 'Component Generator', + prompts: [ + { + type: 'input', + name: 'name', + message: 'component name', + }, + { + type: 'list', + name: 'feature', + message: 'Which feature does this component belong to?', + choices: ['components', ...features], + when: () => features.length > 0, + }, + { + type: 'input', + name: 'folder', + message: 'folder in components', + when: ({ feature }) => !feature || feature === 'components', + }, + ], + actions: (answers) => { + const componentGeneratePath = + !answers.feature || answers.feature === 'components' + ? 'src/components/{{folder}}' + : 'src/features/{{feature}}/components'; + return [ + { + type: 'add', + path: componentGeneratePath + '/{{kebabCase name}}/index.ts', + templateFile: 'generators/component/index.ts.hbs', + }, + { + type: 'add', + path: componentGeneratePath + '/{{kebabCase name}}/{{kebabCase name}}.tsx', + templateFile: 'generators/component/component.tsx.hbs', + }, + { + type: 'add', + path: componentGeneratePath + '/{{kebabCase name}}/{{kebabCase name}}.stories.tsx', + templateFile: 'generators/component/component.stories.tsx.hbs', + }, + ]; + }, +}; diff --git a/apps/nextjs-app/generators/component/index.ts.hbs b/apps/nextjs-app/generators/component/index.ts.hbs new file mode 100644 index 00000000..2991d2e6 --- /dev/null +++ b/apps/nextjs-app/generators/component/index.ts.hbs @@ -0,0 +1 @@ +export * from './{{ kebabCase name }}'; diff --git a/apps/nextjs-app/index.html b/apps/nextjs-app/index.html new file mode 100644 index 00000000..7c865abb --- /dev/null +++ b/apps/nextjs-app/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + Bulletproof React + + + +
+ + + diff --git a/apps/nextjs-app/lint-staged.config.mjs b/apps/nextjs-app/lint-staged.config.mjs new file mode 100644 index 00000000..08cf0dcc --- /dev/null +++ b/apps/nextjs-app/lint-staged.config.mjs @@ -0,0 +1,14 @@ +import path from 'path'; + +const buildEslintCommand = (filenames) => { + return `next lint --fix --file ${filenames + .filter((f) => f.includes('/src/')) + .map((f) => path.relative(process.cwd(), f)) + .join(' --file ')}`; +}; + +const config = { + '*.{ts,tsx}': [buildEslintCommand, "bash -c 'yarn check-types'"], +}; + +export default config; diff --git a/apps/nextjs-app/mock-server.ts b/apps/nextjs-app/mock-server.ts new file mode 100644 index 00000000..8e217267 --- /dev/null +++ b/apps/nextjs-app/mock-server.ts @@ -0,0 +1,29 @@ +import { createMiddleware } from '@mswjs/http-middleware'; +import cors from 'cors'; +import express from 'express'; +import logger from 'pino-http'; + +import { initializeDb } from './src/testing/mocks/db'; +import { handlers } from './src/testing/mocks/handlers'; + +const app = express(); + +app.use( + cors({ + origin: process.env.NEXT_PUBLIC_URL, + credentials: true, + }), +); + +app.use(express.json()); +app.use(logger({ level: 'silent' })); +app.use(createMiddleware(...handlers)); + +initializeDb().then(() => { + console.log('Mock DB initialized'); + app.listen(process.env.NEXT_PUBLIC_MOCK_API_PORT, () => { + console.log( + `Mock API server started at http://localhost:${process.env.NEXT_PUBLIC_MOCK_API_PORT}`, + ); + }); +}); diff --git a/apps/nextjs-app/next-env.d.ts b/apps/nextjs-app/next-env.d.ts new file mode 100644 index 00000000..fd36f949 --- /dev/null +++ b/apps/nextjs-app/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/nextjs-app/next.config.mjs b/apps/nextjs-app/next.config.mjs new file mode 100644 index 00000000..d5456a15 --- /dev/null +++ b/apps/nextjs-app/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json new file mode 100644 index 00000000..f0e7f8c7 --- /dev/null +++ b/apps/nextjs-app/package.json @@ -0,0 +1,119 @@ +{ + "name": "bulletproof-react-nextjs-pages", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest", + "test-e2e": "pm2 start \"yarn run-mock-server\" --name server && yarn playwright test", + "prepare": "husky", + "check-types": "tsc --project tsconfig.json --pretty --noEmit", + "generate": "plop", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "run-mock-server": "tsx ./mock-server.ts" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@next/env": "^14.2.5", + "@ngneat/falso": "^7.2.0", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@tanstack/react-query": "^5.32.0", + "@tanstack/react-query-devtools": "^5.32.0", + "axios": "^1.6.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "eslint-plugin-check-file": "^2.8.0", + "isomorphic-dompurify": "^2.14.0", + "lucide-react": "^0.378.0", + "marked": "^12.0.2", + "nanoid": "^5.0.7", + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.51.3", + "react-query-auth": "^2.3.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.4", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.0.2", + "@mswjs/data": "^0.16.1", + "@mswjs/http-middleware": "^0.10.1", + "@playwright/test": "^1.43.1", + "@storybook/addon-a11y": "^8.0.10", + "@storybook/addon-actions": "^8.0.9", + "@storybook/addon-essentials": "^8.0.9", + "@storybook/addon-links": "^8.0.9", + "@storybook/nextjs": "^8.2.9", + "@storybook/node-logger": "^8.0.9", + "@storybook/react": "^8.0.9", + "@tailwindcss/typography": "^0.5.13", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^15.0.5", + "@testing-library/user-event": "^14.5.2", + "@types/cors": "^2.8.17", + "@types/dompurify": "^3.0.5", + "@types/js-cookie": "^3.0.6", + "@types/marked": "^6.0.0", + "@types/node": "^20.12.7", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.8.0", + "@typescript-eslint/parser": "^7.8.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "eslint": "8", + "eslint-config-next": "^14.2.5", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest-dom": "^5.4.0", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-playwright": "^1.6.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-tailwindcss": "^3.15.1", + "eslint-plugin-testing-library": "^6.2.2", + "eslint-plugin-vitest": "^0.5.4", + "express": "^4.19.2", + "husky": "^9.0.11", + "jest-environment-jsdom": "^29.7.0", + "js-cookie": "^3.0.5", + "jsdom": "^24.0.0", + "lint-staged": "^15.2.2", + "msw": "^2.2.14", + "next-router-mock": "^0.9.13", + "pino-http": "^10.1.0", + "pino-pretty": "^11.1.0", + "plop": "^4.0.1", + "pm2": "^5.4.0", + "postcss": "^8.4.38", + "prettier": "^3.2.5", + "storybook": "^8.0.9", + "tailwindcss": "^3.4.3", + "tsx": "^4.17.0", + "typescript": "^5.4.5", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^1.5.2" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/apps/nextjs-app/playwright.config.ts b/apps/nextjs-app/playwright.config.ts new file mode 100644 index 00000000..fa902a63 --- /dev/null +++ b/apps/nextjs-app/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 3000; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + testMatch: /.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `yarn dev --port ${PORT}`, + timeout: 10 * 1000, + port: PORT, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/apps/nextjs-app/plopfile.cjs b/apps/nextjs-app/plopfile.cjs new file mode 100644 index 00000000..8ef24186 --- /dev/null +++ b/apps/nextjs-app/plopfile.cjs @@ -0,0 +1,9 @@ +const componentGenerator = require('./generators/component/index'); + +/** + * + * @param {import('plop').NodePlopAPI} plop + */ +module.exports = function (plop) { + plop.setGenerator('component', componentGenerator); +}; diff --git a/apps/nextjs-app/postcss.config.cjs b/apps/nextjs-app/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/apps/nextjs-app/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/nextjs-app/public/_redirects b/apps/nextjs-app/public/_redirects new file mode 100644 index 00000000..f8243379 --- /dev/null +++ b/apps/nextjs-app/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/apps/nextjs-app/public/favicon.ico b/apps/nextjs-app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/apps/nextjs-app/public/logo.svg b/apps/nextjs-app/public/logo.svg new file mode 100644 index 00000000..9dfc1c05 --- /dev/null +++ b/apps/nextjs-app/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/nextjs-app/public/logo192.png b/apps/nextjs-app/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/apps/nextjs-app/public/mockServiceWorker.js b/apps/nextjs-app/public/mockServiceWorker.js new file mode 100644 index 00000000..15751fa1 --- /dev/null +++ b/apps/nextjs-app/public/mockServiceWorker.js @@ -0,0 +1,284 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.5' +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()) + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/apps/nextjs-app/public/robots.txt b/apps/nextjs-app/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/apps/nextjs-app/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/nextjs-app/src/app/pages/app/dashboard.tsx b/apps/nextjs-app/src/app/pages/app/dashboard.tsx new file mode 100644 index 00000000..9035fa5d --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/dashboard.tsx @@ -0,0 +1,45 @@ +import { ReactElement } from 'react'; + +import { ContentLayout, DashboardLayout } from '@/components/layouts'; +import { useUser } from '@/lib/auth'; +import { ROLES } from '@/lib/authorization'; + +export const DashboardPage = () => { + const user = useUser(); + if (!user.data) return null; + + return ( + <> +

+ Welcome {`${user.data?.firstName} ${user.data?.lastName}`} +

+

+ Your role is : {user.data?.role} +

+

In this application you can:

+ {user.data?.role === ROLES.USER && ( +
    +
  • Create comments in discussions
  • +
  • Delete own comments
  • +
+ )} + {user.data?.role === ROLES.ADMIN && ( +
    +
  • Create discussions
  • +
  • Edit discussions
  • +
  • Delete discussions
  • +
  • Comment on discussions
  • +
  • Delete all comments
  • +
+ )} + + ); +}; + +DashboardPage.getLayout = (page: ReactElement) => { + return ( + + {page} + + ); +}; diff --git a/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussion.test.tsx b/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussion.test.tsx new file mode 100644 index 00000000..7afa3c00 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussion.test.tsx @@ -0,0 +1,152 @@ +import mockRouter from 'next-router-mock'; + +import { + renderApp, + screen, + userEvent, + waitFor, + createDiscussion, + createUser, + within, + waitForLoadingToFinish, +} from '@/testing/test-utils'; + +import { DiscussionPage } from '../discussion'; + +const renderDiscussion = async () => { + const fakeUser = await createUser(); + const fakeDiscussion = await createDiscussion({ teamId: fakeUser.teamId }); + + mockRouter.query = { discussionId: fakeDiscussion.id }; + + const utils = await renderApp(, { + user: fakeUser, + path: `/app/discussions/:discussionId`, + url: `/app/discussions/${fakeDiscussion.id}`, + }); + + await waitForLoadingToFinish(); + + await screen.findByText(fakeDiscussion.title); + + return { + ...utils, + fakeUser, + fakeDiscussion, + }; +}; + +test('should render discussion', async () => { + const { fakeDiscussion } = await renderDiscussion(); + expect(screen.getByText(fakeDiscussion.body)).toBeInTheDocument(); +}); + +test('should update discussion', async () => { + const { fakeDiscussion } = await renderDiscussion(); + + const titleUpdate = '-Updated'; + const bodyUpdate = '-Updated'; + + await userEvent.click( + screen.getByRole('button', { name: /update discussion/i }), + ); + + const drawer = await screen.findByRole('dialog', { + name: /update discussion/i, + }); + + const titleField = within(drawer).getByText(/title/i); + const bodyField = within(drawer).getByText(/body/i); + + const newTitle = `${fakeDiscussion.title}${titleUpdate}`; + const newBody = `${fakeDiscussion.body}${bodyUpdate}`; + + // replacing the title with the new title + await userEvent.type(titleField, newTitle); + + // appending updated to the body + await userEvent.type(bodyField, bodyUpdate); + + const submitButton = within(drawer).getByRole('button', { + name: /submit/i, + }); + + await userEvent.click(submitButton); + + await waitFor(() => expect(drawer).not.toBeInTheDocument()); + + expect( + await screen.findByRole('heading', { name: newTitle }), + ).toBeInTheDocument(); + expect(await screen.findByText(newBody)).toBeInTheDocument(); +}); + +test( + 'should create and delete a comment on the discussion', + async () => { + await renderDiscussion(); + + const comment = 'Hello World'; + + await userEvent.click( + screen.getByRole('button', { name: /create comment/i }), + ); + + const drawer = await screen.findByRole('dialog', { + name: /create comment/i, + }); + + const bodyField = await within(drawer).findByText(/body/i); + + await userEvent.type(bodyField, comment); + + const submitButton = await within(drawer).findByRole('button', { + name: /submit/i, + }); + + await userEvent.click(submitButton); + + await waitFor(() => expect(drawer).not.toBeInTheDocument()); + + await screen.findByText(comment); + + const commentsList = await screen.findByRole('list', { + name: 'comments', + }); + + const commentElements = + await within(commentsList).findAllByRole('listitem'); + + const commentElement = commentElements[0]; + + expect(commentElement).toBeInTheDocument(); + + const deleteCommentButton = within(commentElement).getByRole('button', { + name: /delete comment/i, + // exact: false, + }); + + await userEvent.click(deleteCommentButton); + + const confirmationDialog = await screen.findByRole('dialog', { + name: /delete comment/i, + }); + + const confirmationDeleteButton = await within( + confirmationDialog, + ).findByRole('button', { + name: /delete/i, + }); + + await userEvent.click(confirmationDeleteButton); + + await screen.findByText(/comment deleted/i); + + await waitFor(() => { + expect(within(commentsList).queryByText(comment)).not.toBeInTheDocument(); + }); + }, + { + timeout: 20000, + }, +); diff --git a/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussions.test.tsx b/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussions.test.tsx new file mode 100644 index 00000000..de253e36 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/discussions/__tests__/discussions.test.tsx @@ -0,0 +1,99 @@ +import type { Mock } from 'vitest'; + +import { createDiscussion } from '@/testing/data-generators'; +import { + renderApp, + screen, + userEvent, + waitFor, + waitForLoadingToFinish, + within, +} from '@/testing/test-utils'; +import { formatDate } from '@/utils/format'; + +import { DiscussionsPage } from '../discussions'; + +beforeAll(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterAll(() => { + (console.error as Mock).mockRestore(); +}); + +test( + 'should create, render and delete discussions', + { timeout: 10000 }, + async () => { + await renderApp(); + + await waitForLoadingToFinish(); + + const newDiscussion = createDiscussion(); + + expect(await screen.findByText(/no entries/i)).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: /create discussion/i }), + ); + + const drawer = await screen.findByRole('dialog', { + name: /create discussion/i, + }); + + const titleField = within(drawer).getByText(/title/i); + const bodyField = within(drawer).getByText(/body/i); + + await userEvent.type(titleField, newDiscussion.title); + await userEvent.type(bodyField, newDiscussion.body); + + const submitButton = within(drawer).getByRole('button', { + name: /submit/i, + }); + + await userEvent.click(submitButton); + + await waitFor(() => expect(drawer).not.toBeInTheDocument()); + + const row = await screen.findByRole( + 'row', + { + name: `${newDiscussion.title} ${formatDate(newDiscussion.createdAt)} View Delete Discussion`, + }, + { timeout: 5000 }, + ); + + expect( + within(row).getByRole('cell', { + name: newDiscussion.title, + }), + ).toBeInTheDocument(); + + await userEvent.click( + within(row).getByRole('button', { + name: /delete discussion/i, + }), + ); + + const confirmationDialog = await screen.findByRole('dialog', { + name: /delete discussion/i, + }); + + const confirmationDeleteButton = within(confirmationDialog).getByRole( + 'button', + { + name: /delete discussion/i, + }, + ); + + await userEvent.click(confirmationDeleteButton); + + await screen.findByText(/discussion deleted/i); + + expect( + within(row).queryByRole('cell', { + name: newDiscussion.title, + }), + ).not.toBeInTheDocument(); + }, +); diff --git a/apps/nextjs-app/src/app/pages/app/discussions/discussion.tsx b/apps/nextjs-app/src/app/pages/app/discussions/discussion.tsx new file mode 100644 index 00000000..fcf8d683 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/discussions/discussion.tsx @@ -0,0 +1,92 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { useRouter } from 'next/router'; +import { ReactElement } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { ContentLayout, DashboardLayout } from '@/components/layouts'; +import { Spinner } from '@/components/ui/spinner'; +import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments'; +import { Comments } from '@/features/comments/components/comments'; +import { + useDiscussion, + getDiscussionQueryOptions, +} from '@/features/discussions/api/get-discussion'; +import { DiscussionView } from '@/features/discussions/components/discussion-view'; + +type DiscussionPageProps = { + dehydratedState?: unknown; +}; + +export const getServerSideProps = (async ({ query, req }) => { + const queryClient = new QueryClient(); + const discussionId = query.discussionId as string; + const cookie = req.headers.cookie; + + await queryClient.prefetchQuery( + getDiscussionQueryOptions(discussionId, cookie), + ); + await queryClient.prefetchInfiniteQuery( + getInfiniteCommentsQueryOptions(discussionId, cookie), + ); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + }; +}) satisfies GetServerSideProps; + +export const DiscussionPage = () => { + const router = useRouter(); + const discussionId = router.query.discussionId as string; + + const discussionQuery = useDiscussion({ + discussionId, + }); + + if (discussionQuery.isLoading) { + return ( +
+ +
+ ); + } + + const discussion = discussionQuery.data?.data; + + if (!discussion) return null; + + return ( + + +
+ Failed to load comments. Try to refresh the page.
+ } + > + + + +
+ ); +}; + +DiscussionPage.getLayout = (page: ReactElement) => { + return {page}; +}; + +export const PublicDiscussionPage = ({ + dehydratedState, +}: InferGetServerSidePropsType) => { + return ( + + + + ); +}; diff --git a/apps/nextjs-app/src/app/pages/app/discussions/discussions.tsx b/apps/nextjs-app/src/app/pages/app/discussions/discussions.tsx new file mode 100644 index 00000000..afce34ae --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/discussions/discussions.tsx @@ -0,0 +1,37 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { ReactElement } from 'react'; + +import { ContentLayout, DashboardLayout } from '@/components/layouts'; +import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments'; +import { CreateDiscussion } from '@/features/discussions/components/create-discussion'; +import { DiscussionsList } from '@/features/discussions/components/discussions-list'; + +export const DiscussionsPage = () => { + const queryClient = useQueryClient(); + + return ( + <> +
+ +
+
+ { + // Prefetch the comments data when the user hovers over the link in the list + queryClient.prefetchInfiniteQuery( + getInfiniteCommentsQueryOptions(id), + ); + }} + /> +
+ + ); +}; + +DiscussionsPage.getLayout = (page: ReactElement) => { + return ( + + {page} + + ); +}; diff --git a/apps/nextjs-app/src/app/pages/app/profile.tsx b/apps/nextjs-app/src/app/pages/app/profile.tsx new file mode 100644 index 00000000..744a6927 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/profile.tsx @@ -0,0 +1,57 @@ +import { ReactElement } from 'react'; + +import { ContentLayout, DashboardLayout } from '@/components/layouts'; +import { UpdateProfile } from '@/features/users/components/update-profile'; +import { useUser } from '@/lib/auth'; + +type EntryProps = { + label: string; + value: string; +}; +const Entry = ({ label, value }: EntryProps) => ( +
+
{label}
+
+ {value} +
+
+); + +export const ProfilePage = () => { + const user = useUser(); + + if (!user.data) return null; + + return ( +
+
+
+

+ User Information +

+ +
+

+ Personal details of the user. +

+
+
+
+ + + + + +
+
+
+ ); +}; + +ProfilePage.getLayout = (page: ReactElement) => { + return ( + + {page} + + ); +}; diff --git a/apps/nextjs-app/src/app/pages/app/users.tsx b/apps/nextjs-app/src/app/pages/app/users.tsx new file mode 100644 index 00000000..b0524e32 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/app/users.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from 'react'; + +import { ContentLayout, DashboardLayout } from '@/components/layouts'; +import { UsersList } from '@/features/users/components/users-list'; +import { Authorization, ROLES } from '@/lib/authorization'; + +export const UsersPage = () => { + return ( + + Only admin can view this.} + allowedRoles={[ROLES.ADMIN]} + > + + + + ); +}; + +UsersPage.getLayout = (page: ReactElement) => { + return ( + + {page} + + ); +}; diff --git a/apps/nextjs-app/src/app/pages/auth/login.tsx b/apps/nextjs-app/src/app/pages/auth/login.tsx new file mode 100644 index 00000000..69e9d238 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/auth/login.tsx @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router'; +import { ReactElement } from 'react'; + +import { AuthLayout } from '@/components/layouts/auth-layout'; +import { LoginForm } from '@/features/auth/components/login-form'; + +export const LoginPage = () => { + const router = useRouter(); + const { redirectTo } = router.query; + + return ( + + router.replace(`${redirectTo ? `${redirectTo}` : '/app'}`) + } + /> + ); +}; + +LoginPage.getLayout = (page: ReactElement) => { + return {page}; +}; diff --git a/apps/nextjs-app/src/app/pages/auth/register.tsx b/apps/nextjs-app/src/app/pages/auth/register.tsx new file mode 100644 index 00000000..d3858967 --- /dev/null +++ b/apps/nextjs-app/src/app/pages/auth/register.tsx @@ -0,0 +1,35 @@ +import { useRouter } from 'next/router'; +import { ReactElement, useState } from 'react'; + +import { AuthLayout } from '@/components/layouts/auth-layout'; +import { RegisterForm } from '@/features/auth/components/register-form'; +import { useTeams } from '@/features/teams/api/get-teams'; + +export const RegisterPage = () => { + const router = useRouter(); + + const { redirectTo } = router.query; + + const [chooseTeam, setChooseTeam] = useState(false); + + const teamsQuery = useTeams({ + queryConfig: { + enabled: chooseTeam, + }, + }); + + return ( + + router.replace(`${redirectTo ? `${redirectTo}` : '/app'}`) + } + chooseTeam={chooseTeam} + setChooseTeam={() => setChooseTeam(!chooseTeam)} + teams={teamsQuery.data?.data} + /> + ); +}; + +RegisterPage.getLayout = (page: ReactElement) => { + return {page}; +}; diff --git a/apps/nextjs-app/src/app/provider.tsx b/apps/nextjs-app/src/app/provider.tsx new file mode 100644 index 00000000..cb269351 --- /dev/null +++ b/apps/nextjs-app/src/app/provider.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import * as React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { MainErrorFallback } from '@/components/errors/main'; +import { Notifications } from '@/components/ui/notifications'; +import { Spinner } from '@/components/ui/spinner'; +import { queryConfig } from '@/lib/react-query'; + +type AppProviderProps = { + children: React.ReactNode; +}; + +export const AppProvider = ({ children }: AppProviderProps) => { + const [queryClient] = React.useState( + () => + new QueryClient({ + defaultOptions: queryConfig, + }), + ); + + return ( + + + + } + > + + + {process.env.DEV && } + + {children} + + + + ); +}; diff --git a/apps/nextjs-app/src/components/errors/main.tsx b/apps/nextjs-app/src/components/errors/main.tsx new file mode 100644 index 00000000..660a5ac5 --- /dev/null +++ b/apps/nextjs-app/src/components/errors/main.tsx @@ -0,0 +1,18 @@ +import { Button } from '../ui/button'; + +export const MainErrorFallback = () => { + return ( +
+

Ooops, something went wrong :(

+ +
+ ); +}; diff --git a/apps/nextjs-app/src/components/layouts/auth-layout.tsx b/apps/nextjs-app/src/components/layouts/auth-layout.tsx new file mode 100644 index 00000000..30369771 --- /dev/null +++ b/apps/nextjs-app/src/components/layouts/auth-layout.tsx @@ -0,0 +1,49 @@ +import { useRouter } from 'next/router'; +import * as React from 'react'; +import { useEffect } from 'react'; + +import { Head } from '@/components/seo'; +import { Link } from '@/components/ui/link'; +import { useUser } from '@/lib/auth'; + +type LayoutProps = { + children: React.ReactNode; + title: string; +}; + +export const AuthLayout = ({ children, title }: LayoutProps) => { + const user = useUser(); + + const router = useRouter(); + + useEffect(() => { + if (user.data) { + router.replace('/app'); + } + }, [user.data, router]); + + return ( + <> + +
+
+
+ + Workflow + +
+ +

+ {title} +

+
+ +
+
+ {children} +
+
+
+ + ); +}; diff --git a/apps/nextjs-app/src/components/layouts/content-layout.tsx b/apps/nextjs-app/src/components/layouts/content-layout.tsx new file mode 100644 index 00000000..8b09c411 --- /dev/null +++ b/apps/nextjs-app/src/components/layouts/content-layout.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { Head } from '../seo'; + +type ContentLayoutProps = { + children: React.ReactNode; + title: string; +}; + +export const ContentLayout = ({ children, title }: ContentLayoutProps) => { + return ( + <> + +
+
+

{title}

+
+
+ {children} +
+
+ + ); +}; diff --git a/apps/nextjs-app/src/components/layouts/dashboard-layout.tsx b/apps/nextjs-app/src/components/layouts/dashboard-layout.tsx new file mode 100644 index 00000000..c4c07cb9 --- /dev/null +++ b/apps/nextjs-app/src/components/layouts/dashboard-layout.tsx @@ -0,0 +1,251 @@ +import { Home, PanelLeft, Folder, Users, User2 } from 'lucide-react'; +import NextLink from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState, Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { Button } from '@/components/ui/button'; +import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'; +import { Spinner } from '@/components/ui/spinner'; +import { AuthLoader, useLogout } from '@/lib/auth'; +import { ROLES, useAuthorization } from '@/lib/authorization'; +import { cn } from '@/utils/cn'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown'; +import { Link } from '../ui/link'; + +type SideNavigationItem = { + name: string; + to: string; + icon: (props: React.SVGProps) => JSX.Element; +}; + +const Logo = () => { + return ( + + Workflow + + Bulletproof React + + + ); +}; + +const Progress = () => { + const router = useRouter(); + const [progress, setProgress] = useState(0); + + useEffect(() => { + const handleRouteChangeStart = () => { + setProgress(0); + const timer = setInterval(() => { + setProgress((oldProgress) => { + if (oldProgress === 100) { + clearInterval(timer); + return 100; + } + const newProgress = oldProgress + 10; + return newProgress > 100 ? 100 : newProgress; + }); + }, 300); + + return () => { + clearInterval(timer); + }; + }; + + const handleRouteChangeComplete = () => { + setProgress(100); + setTimeout(() => { + setProgress(0); + }, 500); // Adjust the delay as needed + }; + + router.events.on('routeChangeStart', handleRouteChangeStart); + router.events.on('routeChangeComplete', handleRouteChangeComplete); + router.events.on('routeChangeError', handleRouteChangeComplete); + + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart); + router.events.off('routeChangeComplete', handleRouteChangeComplete); + router.events.off('routeChangeError', handleRouteChangeComplete); + }; + }, [router.events]); + + if (progress === 0) { + return null; + } + + return ( +
+ ); +}; + +const Layout = ({ children }: { children: React.ReactNode }) => { + const logout = useLogout(); + const { checkAccess } = useAuthorization(); + const router = useRouter(); + const navigation = [ + { name: 'Dashboard', to: '/app', icon: Home }, + { name: 'Discussions', to: '/app/discussions', icon: Folder }, + checkAccess({ allowedRoles: [ROLES.ADMIN] }) && { + name: 'Users', + to: '/app/users', + icon: Users, + }, + ].filter(Boolean) as SideNavigationItem[]; + + return ( +
+ +
+
+ + + + + + + + + + + + + + + router.push('/app/profile')} + className={cn('block px-4 py-2 text-sm text-gray-700')} + > + Your Profile + + + logout.mutate({})} + > + Sign Out + + + +
+
+ {children} +
+
+
+ ); +}; + +export const DashboardLayout = ({ + children, +}: { + children: React.ReactNode; +}) => { + const router = useRouter(); + return ( + + + + + } + > + Something went wrong!} + > + ( +
+ +
+ )} + > + {children} +
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/components/layouts/index.ts b/apps/nextjs-app/src/components/layouts/index.ts new file mode 100644 index 00000000..fc6f49f3 --- /dev/null +++ b/apps/nextjs-app/src/components/layouts/index.ts @@ -0,0 +1,2 @@ +export * from './content-layout'; +export * from './dashboard-layout'; diff --git a/apps/nextjs-app/src/components/seo/head.tsx b/apps/nextjs-app/src/components/seo/head.tsx new file mode 100644 index 00000000..8269f173 --- /dev/null +++ b/apps/nextjs-app/src/components/seo/head.tsx @@ -0,0 +1,15 @@ +import NextHead from 'next/head'; + +type HeadProps = { + title?: string; + description?: string; +}; + +export const Head = ({ title = '', description = '' }: HeadProps = {}) => { + return ( + + {title} + + + ); +}; diff --git a/apps/nextjs-app/src/components/seo/index.ts b/apps/nextjs-app/src/components/seo/index.ts new file mode 100644 index 00000000..b6f0ab75 --- /dev/null +++ b/apps/nextjs-app/src/components/seo/index.ts @@ -0,0 +1 @@ +export * from './head'; diff --git a/apps/nextjs-app/src/components/ui/button/button.stories.tsx b/apps/nextjs-app/src/components/ui/button/button.stories.tsx new file mode 100644 index 00000000..3cd81eea --- /dev/null +++ b/apps/nextjs-app/src/components/ui/button/button.stories.tsx @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Button } from './button'; + +const meta: Meta = { + component: Button, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Button', + variant: 'default', + }, +}; diff --git a/apps/nextjs-app/src/components/ui/button/button.tsx b/apps/nextjs-app/src/components/ui/button/button.tsx new file mode 100644 index 00000000..55b4eada --- /dev/null +++ b/apps/nextjs-app/src/components/ui/button/button.tsx @@ -0,0 +1,76 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +import { Spinner } from '../spinner'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps & { + asChild?: boolean; + isLoading?: boolean; + icon?: React.ReactNode; + }; + +const Button = React.forwardRef( + ( + { + className, + variant, + size, + asChild = false, + children, + isLoading, + icon, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'button'; + return ( + + {isLoading && } + {!isLoading && icon && {icon}} + {children} + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/nextjs-app/src/components/ui/button/index.ts b/apps/nextjs-app/src/components/ui/button/index.ts new file mode 100644 index 00000000..eaf5eea7 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/button/index.ts @@ -0,0 +1 @@ +export * from './button'; diff --git a/apps/nextjs-app/src/components/ui/dialog/__tests__/dialog.test.tsx b/apps/nextjs-app/src/components/ui/dialog/__tests__/dialog.test.tsx new file mode 100644 index 00000000..f12895df --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/__tests__/dialog.test.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { useDisclosure } from '@/hooks/use-disclosure'; +import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../dialog'; + +const openButtonText = 'Open Modal'; +const cancelButtonText = 'Cancel'; +const titleText = 'Modal Title'; + +const TestDialog = () => { + const { close, open, isOpen } = useDisclosure(); + const cancelButtonRef = React.useRef(null); + + return ( + { + if (!isOpen) { + close(); + } else { + open(); + } + }} + > + + + + + + {titleText} + + + + + + + + + ); +}; + +test('should handle basic dialog flow', async () => { + rtlRender(); + + expect(screen.queryByText(titleText)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: openButtonText })); + + expect(await screen.findByText(titleText)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: cancelButtonText })); + + await waitFor(() => + expect(screen.queryByText(titleText)).not.toBeInTheDocument(), + ); +}); diff --git a/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx new file mode 100644 index 00000000..c56e701a --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx @@ -0,0 +1,37 @@ +import { Button } from '@/components/ui/button'; +import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils'; + +import { ConfirmationDialog } from '../confirmation-dialog'; + +test('should handle confirmation flow', async () => { + const titleText = 'Are you sure?'; + const bodyText = 'Are you sure you want to delete this item?'; + const confirmationButtonText = 'Confirm'; + const openButtonText = 'Open'; + + await rtlRender( + {confirmationButtonText}} + triggerButton={} + />, + ); + + expect(screen.queryByText(titleText)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: openButtonText })); + + expect(await screen.findByText(titleText)).toBeInTheDocument(); + + expect(screen.getByText(bodyText)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => + expect(screen.queryByText(titleText)).not.toBeInTheDocument(), + ); + + expect(screen.queryByText(bodyText)).not.toBeInTheDocument(); +}); diff --git a/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx new file mode 100644 index 00000000..3f787b42 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '@/components/ui/button'; + +import { ConfirmationDialog } from './confirmation-dialog'; + +const meta: Meta = { + component: ConfirmationDialog, +}; + +export default meta; + +type Story = StoryObj; + +export const Danger: Story = { + args: { + icon: 'danger', + title: 'Confirmation', + body: 'Hello World', + confirmButton: , + triggerButton: , + }, +}; + +export const Info: Story = { + args: { + icon: 'info', + title: 'Confirmation', + body: 'Hello World', + confirmButton: , + triggerButton: , + }, +}; diff --git a/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx new file mode 100644 index 00000000..c8da8312 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx @@ -0,0 +1,88 @@ +import { CircleAlert, Info } from 'lucide-react'; +import * as React from 'react'; +import { useEffect } from 'react'; + +import { Button } from '@/components/ui/button'; +import { useDisclosure } from '@/hooks/use-disclosure'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../dialog'; + +export type ConfirmationDialogProps = { + triggerButton: React.ReactElement; + confirmButton: React.ReactElement; + title: string; + body?: string; + cancelButtonText?: string; + icon?: 'danger' | 'info'; + isDone?: boolean; +}; + +export const ConfirmationDialog = ({ + triggerButton, + confirmButton, + title, + body = '', + cancelButtonText = 'Cancel', + icon = 'danger', + isDone = false, +}: ConfirmationDialogProps) => { + const { close, open, isOpen } = useDisclosure(); + const cancelButtonRef = React.useRef(null); + + useEffect(() => { + if (isDone) { + close(); + } + }, [isDone, close]); + + return ( + { + if (!isOpen) { + close(); + } else { + open(); + } + }} + > + {triggerButton} + + + + {' '} + {icon === 'danger' && ( + + + +
+ {body && ( +
+

{body}

+
+ )} +
+ + + {confirmButton} + + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts new file mode 100644 index 00000000..e8b63583 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts @@ -0,0 +1 @@ +export * from './confirmation-dialog'; diff --git a/apps/nextjs-app/src/components/ui/dialog/dialog.stories.tsx b/apps/nextjs-app/src/components/ui/dialog/dialog.stories.tsx new file mode 100644 index 00000000..7873b6ae --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/dialog.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { useDisclosure } from '@/hooks/use-disclosure'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './dialog'; + +const DemoDialog = () => { + const { close, open, isOpen } = useDisclosure(); + const cancelButtonRef = React.useRef(null); + + return ( + { + if (!isOpen) { + close(); + } else { + open(); + } + }} + > + + + + + + Edit profile + Lorem ipsum + +
Lorem ipsum
+ + + + + +
+
+ ); +}; + +const meta: Meta = { + component: Dialog, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: () => , +}; diff --git a/apps/nextjs-app/src/components/ui/dialog/dialog.tsx b/apps/nextjs-app/src/components/ui/dialog/dialog.tsx new file mode 100644 index 00000000..e9a1caae --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/nextjs-app/src/components/ui/dialog/index.ts b/apps/nextjs-app/src/components/ui/dialog/index.ts new file mode 100644 index 00000000..c25eeb7f --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dialog/index.ts @@ -0,0 +1,2 @@ +export * from './dialog'; +export * from './confirmation-dialog'; diff --git a/apps/nextjs-app/src/components/ui/drawer/__tests__/drawer.test.tsx b/apps/nextjs-app/src/components/ui/drawer/__tests__/drawer.test.tsx new file mode 100644 index 00000000..42123362 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/drawer/__tests__/drawer.test.tsx @@ -0,0 +1,66 @@ +import { Button } from '@/components/ui/button'; +import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils'; + +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '../drawer'; + +const openButtonText = 'Open Drawer'; +const titleText = 'Drawer Title'; +const cancelButtonText = 'Cancel'; +const drawerContentText = 'Hello From Drawer'; + +const TestDrawer = () => { + return ( + + + + + +
+ + {titleText} + +
{drawerContentText}
+
+ + + + + +
+
+ ); +}; + +test('should handle basic drawer flow', async () => { + await rtlRender(); + + expect(screen.queryByText(titleText)).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { + name: openButtonText, + }), + ); + + expect(await screen.findByText(titleText)).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { + name: cancelButtonText, + }), + ); + + await waitFor(() => + expect(screen.queryByText(titleText)).not.toBeInTheDocument(), + ); +}); diff --git a/apps/nextjs-app/src/components/ui/drawer/drawer.stories.tsx b/apps/nextjs-app/src/components/ui/drawer/drawer.stories.tsx new file mode 100644 index 00000000..c81d77d0 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/drawer/drawer.stories.tsx @@ -0,0 +1,64 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '@/components/ui/button'; +import { useDisclosure } from '@/hooks/use-disclosure'; + +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from './drawer'; + +const meta: Meta = { + component: Drawer, +}; + +export default meta; + +type Story = StoryObj; + +const DemoDrawer = () => { + const { close, open, isOpen } = useDisclosure(); + + return ( + { + if (!isOpen) { + close(); + } else { + open(); + } + }} + > + + + + +
+ + Drawer Header + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + +
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+ + + + + +
+
+ ); +}; + +export const Default: Story = { + render: () => , +}; diff --git a/apps/nextjs-app/src/components/ui/drawer/drawer.tsx b/apps/nextjs-app/src/components/ui/drawer/drawer.tsx new file mode 100644 index 00000000..f4f2306d --- /dev/null +++ b/apps/nextjs-app/src/components/ui/drawer/drawer.tsx @@ -0,0 +1,139 @@ +import * as DrawerPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const Drawer = DrawerPrimitive.Root; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const drawerVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +type DrawerContentProps = React.ComponentPropsWithoutRef< + typeof DrawerPrimitive.Content +> & + VariantProps; + +const DrawerContent = React.forwardRef< + React.ElementRef, + DrawerContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DrawerContent.displayName = DrawerPrimitive.Content.displayName; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/apps/nextjs-app/src/components/ui/drawer/index.ts b/apps/nextjs-app/src/components/ui/drawer/index.ts new file mode 100644 index 00000000..a150839b --- /dev/null +++ b/apps/nextjs-app/src/components/ui/drawer/index.ts @@ -0,0 +1 @@ +export * from './drawer'; diff --git a/apps/nextjs-app/src/components/ui/dropdown/dropdown.tsx b/apps/nextjs-app/src/components/ui/dropdown/dropdown.tsx new file mode 100644 index 00000000..94294654 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dropdown/dropdown.tsx @@ -0,0 +1,203 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from '@radix-ui/react-icons'; +import * as React from 'react'; + +import { cn } from '@/utils/cn'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/nextjs-app/src/components/ui/dropdown/index.ts b/apps/nextjs-app/src/components/ui/dropdown/index.ts new file mode 100644 index 00000000..b96a997b --- /dev/null +++ b/apps/nextjs-app/src/components/ui/dropdown/index.ts @@ -0,0 +1 @@ +export * from './dropdown'; diff --git a/apps/nextjs-app/src/components/ui/form/__tests__/form.test.tsx b/apps/nextjs-app/src/components/ui/form/__tests__/form.test.tsx new file mode 100644 index 00000000..b2bb4110 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/form/__tests__/form.test.tsx @@ -0,0 +1,74 @@ +import { SubmitHandler } from 'react-hook-form'; +import { z } from 'zod'; + +import { Button } from '@/components/ui/button'; +import { rtlRender, screen, waitFor, userEvent } from '@/testing/test-utils'; + +import { Form } from '../form'; +import { Input } from '../input'; + +const testData = { + title: 'Hello World', +}; + +const schema = z.object({ + title: z.string().min(1, 'Required'), +}); + +test('should render and submit a basic Form component', async () => { + const handleSubmit = vi.fn() as SubmitHandler>; + + rtlRender( +
+ {({ register, formState }) => ( + <> + + + + + )} +
, + ); + + await userEvent.type(screen.getByLabelText(/title/i), testData.title); + + await userEvent.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => + expect(handleSubmit).toHaveBeenCalledWith(testData, expect.anything()), + ); +}); + +test('should fail submission if validation fails', async () => { + const handleSubmit = vi.fn() as SubmitHandler>; + + rtlRender( +
+ {({ register, formState }) => ( + <> + + + + + )} +
, + ); + + await userEvent.click(screen.getByRole('button', { name: /submit/i })); + + await screen.findByRole('alert', { name: /required/i }); + + expect(handleSubmit).toHaveBeenCalledTimes(0); +}); diff --git a/apps/nextjs-app/src/components/ui/form/error.tsx b/apps/nextjs-app/src/components/ui/form/error.tsx new file mode 100644 index 00000000..5b2b0557 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/form/error.tsx @@ -0,0 +1,17 @@ +export type ErrorProps = { + errorMessage?: string | null; +}; + +export const Error = ({ errorMessage }: ErrorProps) => { + if (!errorMessage) return null; + + return ( +
+ {errorMessage} +
+ ); +}; diff --git a/apps/nextjs-app/src/components/ui/form/field-wrapper.tsx b/apps/nextjs-app/src/components/ui/form/field-wrapper.tsx new file mode 100644 index 00000000..3320eb72 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/form/field-wrapper.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { type FieldError } from 'react-hook-form'; + +import { Error } from './error'; +import { Label } from './label'; + +type FieldWrapperProps = { + label?: string; + className?: string; + children: React.ReactNode; + error?: FieldError | undefined; +}; + +export type FieldWrapperPassThroughProps = Omit< + FieldWrapperProps, + 'className' | 'children' +>; + +export const FieldWrapper = (props: FieldWrapperProps) => { + const { label, error, children } = props; + return ( +
+ + +
+ ); +}; diff --git a/apps/nextjs-app/src/components/ui/form/form-drawer.tsx b/apps/nextjs-app/src/components/ui/form/form-drawer.tsx new file mode 100644 index 00000000..0d7572a8 --- /dev/null +++ b/apps/nextjs-app/src/components/ui/form/form-drawer.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; + +import { useDisclosure } from '@/hooks/use-disclosure'; + +import { Button } from '../button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTrigger, + DrawerTitle, +} from '../drawer'; + +type FormDrawerProps = { + isDone: boolean; + triggerButton: React.ReactElement; + submitButton: React.ReactElement; + title: string; + children: React.ReactNode; +}; + +export const FormDrawer = ({ + title, + children, + isDone, + triggerButton, + submitButton, +}: FormDrawerProps) => { + const { close, open, isOpen } = useDisclosure(); + + React.useEffect(() => { + if (isDone) { + close(); + } + }, [isDone, close]); + + return ( + { + if (!isOpen) { + close(); + } else { + open(); + } + }} + > + {triggerButton} + +
+ + {title} + +
{children}
+
+ + + + + {submitButton} + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/components/ui/form/form.stories.tsx b/apps/nextjs-app/src/components/ui/form/form.stories.tsx new file mode 100644 index 00000000..071c78df --- /dev/null +++ b/apps/nextjs-app/src/components/ui/form/form.stories.tsx @@ -0,0 +1,87 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { z } from 'zod'; + +import { Button } from '../button'; + +import { Form } from './form'; +import { FormDrawer } from './form-drawer'; +import { Input } from './input'; +import { Select } from './select'; +import { Textarea } from './textarea'; + +const MyForm = ({ hideSubmit = false }: { hideSubmit?: boolean }) => { + return ( +
{ + alert(JSON.stringify(values, null, 2)); + }} + schema={z.object({ + title: z.string().min(1, 'Required'), + description: z.string().min(1, 'Required'), + type: z.string().min(1, 'Required'), + })} + id="my-form" + > + {({ register, formState }) => ( + <> + +