Repository: alan2207/bulletproof-react Branch: master Commit: c66ea0618e65 Files: 511 Total size: 699.2 KB Directory structure: gitextract_dmcys4tl/ ├── .github/ │ └── workflows/ │ ├── nextjs-app-ci.yml │ ├── nextjs-pages-ci.yml │ └── react-vite-ci.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── LICENSE ├── README.md ├── apps/ │ ├── nextjs-app/ │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.tsx │ │ ├── .vscode/ │ │ │ ├── extensions.json │ │ │ └── settings.json │ │ ├── README.md │ │ ├── __mocks__/ │ │ │ ├── vitest-env.d.ts │ │ │ └── zustand.ts │ │ ├── e2e/ │ │ │ ├── .eslintrc.cjs │ │ │ └── tests/ │ │ │ ├── auth.setup.ts │ │ │ ├── profile.spec.ts │ │ │ └── smoke.spec.ts │ │ ├── generators/ │ │ │ └── component/ │ │ │ ├── component.stories.tsx.hbs │ │ │ ├── component.tsx.hbs │ │ │ ├── index.cjs │ │ │ └── index.ts.hbs │ │ ├── index.html │ │ ├── lint-staged.config.mjs │ │ ├── mock-server.ts │ │ ├── next-env.d.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── plopfile.cjs │ │ ├── postcss.config.cjs │ │ ├── public/ │ │ │ ├── _redirects │ │ │ ├── mockServiceWorker.js │ │ │ └── robots.txt │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── app/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── dashboard-info.tsx │ │ │ │ │ │ └── dashboard-layout.tsx │ │ │ │ │ ├── discussions/ │ │ │ │ │ │ ├── [discussionId]/ │ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ │ └── discussion.test.tsx │ │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ │ └── discussion.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── discussions.test.tsx │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ └── discussions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ └── profile.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── users/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── admin-guard.tsx │ │ │ │ │ │ └── users.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ └── auth-layout.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── register/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── provider.tsx │ │ │ │ └── public/ │ │ │ │ └── discussions/ │ │ │ │ └── [discussionId]/ │ │ │ │ └── page.tsx │ │ │ ├── components/ │ │ │ │ ├── errors/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ └── content-layout.tsx │ │ │ │ └── ui/ │ │ │ │ ├── button/ │ │ │ │ │ ├── button.stories.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── dialog.test.tsx │ │ │ │ │ ├── confirmation-dialog/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── dialog.stories.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── drawer/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── drawer.test.tsx │ │ │ │ │ ├── drawer.stories.tsx │ │ │ │ │ ├── drawer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dropdown/ │ │ │ │ │ ├── dropdown.stories.tsx │ │ │ │ │ ├── dropdown.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── form/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── form.test.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── field-wrapper.tsx │ │ │ │ │ ├── form-drawer.tsx │ │ │ │ │ ├── form.stories.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── label.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ └── textarea.tsx │ │ │ │ ├── link/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── link.stories.tsx │ │ │ │ │ └── link.tsx │ │ │ │ ├── md-preview/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── md-preview.stories.tsx │ │ │ │ │ └── md-preview.tsx │ │ │ │ ├── notifications/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── notifications.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification.stories.tsx │ │ │ │ │ ├── notification.tsx │ │ │ │ │ ├── notifications-store.ts │ │ │ │ │ └── notifications.tsx │ │ │ │ ├── spinner/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── spinner.stories.tsx │ │ │ │ │ └── spinner.tsx │ │ │ │ └── table/ │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.tsx │ │ │ │ ├── table.stories.tsx │ │ │ │ └── table.tsx │ │ │ ├── config/ │ │ │ │ ├── env.ts │ │ │ │ └── paths.ts │ │ │ ├── features/ │ │ │ │ ├── auth/ │ │ │ │ │ └── components/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ │ └── register-form.test.tsx │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ └── register-form.tsx │ │ │ │ ├── comments/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── create-comment.ts │ │ │ │ │ │ ├── delete-comment.ts │ │ │ │ │ │ └── get-comments.ts │ │ │ │ │ └── components/ │ │ │ │ │ ├── comments-list.tsx │ │ │ │ │ ├── comments.tsx │ │ │ │ │ ├── create-comment.tsx │ │ │ │ │ └── delete-comment.tsx │ │ │ │ ├── discussions/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── create-discussion.ts │ │ │ │ │ │ ├── delete-discussion.ts │ │ │ │ │ │ ├── get-discussion.ts │ │ │ │ │ │ ├── get-discussions.ts │ │ │ │ │ │ └── update-discussion.ts │ │ │ │ │ └── components/ │ │ │ │ │ ├── create-discussion.tsx │ │ │ │ │ ├── delete-discussion.tsx │ │ │ │ │ ├── discussion-view.tsx │ │ │ │ │ ├── discussions-list.tsx │ │ │ │ │ └── update-discussion.tsx │ │ │ │ ├── teams/ │ │ │ │ │ └── api/ │ │ │ │ │ └── get-teams.ts │ │ │ │ └── users/ │ │ │ │ ├── api/ │ │ │ │ │ ├── delete-user.ts │ │ │ │ │ ├── get-users.ts │ │ │ │ │ └── update-profile.ts │ │ │ │ └── components/ │ │ │ │ ├── delete-user.tsx │ │ │ │ ├── update-profile.tsx │ │ │ │ └── users-list.tsx │ │ │ ├── hooks/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── use-disclosure.test.ts │ │ │ │ └── use-disclosure.ts │ │ │ ├── lib/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── authorization.test.tsx │ │ │ │ ├── api-client.ts │ │ │ │ ├── auth.tsx │ │ │ │ ├── authorization.ts │ │ │ │ └── react-query.ts │ │ │ ├── styles/ │ │ │ │ └── globals.css │ │ │ ├── testing/ │ │ │ │ ├── data-generators.ts │ │ │ │ ├── mocks/ │ │ │ │ │ ├── browser.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── auth.ts │ │ │ │ │ │ ├── comments.ts │ │ │ │ │ │ ├── discussions.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── teams.ts │ │ │ │ │ │ └── users.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── setup-tests.ts │ │ │ │ └── test-utils.tsx │ │ │ ├── types/ │ │ │ │ └── api.ts │ │ │ └── utils/ │ │ │ ├── auth.ts │ │ │ ├── cn.ts │ │ │ └── format.ts │ │ ├── tailwind.config.cjs │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── nextjs-pages/ │ │ ├── .eslintrc.cjs │ │ ├── .gitignore │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.tsx │ │ ├── .vscode/ │ │ │ ├── extensions.json │ │ │ └── settings.json │ │ ├── README.md │ │ ├── __mocks__/ │ │ │ ├── vitest-env.d.ts │ │ │ └── zustand.ts │ │ ├── e2e/ │ │ │ ├── .eslintrc.cjs │ │ │ └── tests/ │ │ │ ├── auth.setup.ts │ │ │ ├── profile.spec.ts │ │ │ └── smoke.spec.ts │ │ ├── generators/ │ │ │ └── component/ │ │ │ ├── component.stories.tsx.hbs │ │ │ ├── component.tsx.hbs │ │ │ ├── index.cjs │ │ │ └── index.ts.hbs │ │ ├── lint-staged.config.mjs │ │ ├── mock-server.ts │ │ ├── next-env.d.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── plopfile.cjs │ │ ├── postcss.config.cjs │ │ ├── public/ │ │ │ ├── _redirects │ │ │ ├── mockServiceWorker.js │ │ │ └── robots.txt │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── pages/ │ │ │ │ │ ├── app/ │ │ │ │ │ │ ├── dashboard.tsx │ │ │ │ │ │ ├── discussions/ │ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ │ ├── discussion.test.tsx │ │ │ │ │ │ │ │ └── discussions.test.tsx │ │ │ │ │ │ │ ├── discussion.tsx │ │ │ │ │ │ │ └── discussions.tsx │ │ │ │ │ │ ├── profile.tsx │ │ │ │ │ │ └── users.tsx │ │ │ │ │ └── auth/ │ │ │ │ │ ├── login.tsx │ │ │ │ │ └── register.tsx │ │ │ │ └── provider.tsx │ │ │ ├── components/ │ │ │ │ ├── errors/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ ├── auth-layout.tsx │ │ │ │ │ ├── content-layout.tsx │ │ │ │ │ ├── dashboard-layout.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── seo/ │ │ │ │ │ ├── head.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── button/ │ │ │ │ │ ├── button.stories.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── dialog.test.tsx │ │ │ │ │ ├── confirmation-dialog/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── dialog.stories.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── drawer/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── drawer.test.tsx │ │ │ │ │ ├── drawer.stories.tsx │ │ │ │ │ ├── drawer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dropdown/ │ │ │ │ │ ├── dropdown.stories.tsx │ │ │ │ │ ├── dropdown.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── form/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── form.test.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── field-wrapper.tsx │ │ │ │ │ ├── form-drawer.tsx │ │ │ │ │ ├── form.stories.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── label.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ └── textarea.tsx │ │ │ │ ├── link/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── link.stories.tsx │ │ │ │ │ └── link.tsx │ │ │ │ ├── md-preview/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── md-preview.stories.tsx │ │ │ │ │ └── md-preview.tsx │ │ │ │ ├── notifications/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── notifications.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification.stories.tsx │ │ │ │ │ ├── notification.tsx │ │ │ │ │ ├── notifications-store.ts │ │ │ │ │ └── notifications.tsx │ │ │ │ ├── spinner/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── spinner.stories.tsx │ │ │ │ │ └── spinner.tsx │ │ │ │ └── table/ │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.tsx │ │ │ │ ├── table.stories.tsx │ │ │ │ └── table.tsx │ │ │ ├── config/ │ │ │ │ ├── env.ts │ │ │ │ └── paths.ts │ │ │ ├── features/ │ │ │ │ ├── auth/ │ │ │ │ │ └── components/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ │ └── register-form.test.tsx │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ └── register-form.tsx │ │ │ │ ├── comments/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── create-comment.ts │ │ │ │ │ │ ├── delete-comment.ts │ │ │ │ │ │ └── get-comments.ts │ │ │ │ │ └── components/ │ │ │ │ │ ├── comments-list.tsx │ │ │ │ │ ├── comments.tsx │ │ │ │ │ ├── create-comment.tsx │ │ │ │ │ └── delete-comment.tsx │ │ │ │ ├── discussions/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── create-discussion.ts │ │ │ │ │ │ ├── delete-discussion.ts │ │ │ │ │ │ ├── get-discussion.ts │ │ │ │ │ │ ├── get-discussions.ts │ │ │ │ │ │ └── update-discussion.ts │ │ │ │ │ └── components/ │ │ │ │ │ ├── create-discussion.tsx │ │ │ │ │ ├── delete-discussion.tsx │ │ │ │ │ ├── discussion-view.tsx │ │ │ │ │ ├── discussions-list.tsx │ │ │ │ │ └── update-discussion.tsx │ │ │ │ ├── teams/ │ │ │ │ │ └── api/ │ │ │ │ │ └── get-teams.ts │ │ │ │ └── users/ │ │ │ │ ├── api/ │ │ │ │ │ ├── delete-user.ts │ │ │ │ │ ├── get-users.ts │ │ │ │ │ └── update-profile.ts │ │ │ │ └── components/ │ │ │ │ ├── delete-user.tsx │ │ │ │ ├── update-profile.tsx │ │ │ │ └── users-list.tsx │ │ │ ├── hooks/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── use-disclosure.test.ts │ │ │ │ └── use-disclosure.ts │ │ │ ├── lib/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── authorization.test.tsx │ │ │ │ ├── api-client.ts │ │ │ │ ├── auth.tsx │ │ │ │ ├── authorization.tsx │ │ │ │ └── react-query.ts │ │ │ ├── pages/ │ │ │ │ ├── 404.tsx │ │ │ │ ├── _app.tsx │ │ │ │ ├── app/ │ │ │ │ │ ├── discussions/ │ │ │ │ │ │ ├── [discussionId].tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── profile.tsx │ │ │ │ │ └── users.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── login.tsx │ │ │ │ │ └── register.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── public/ │ │ │ │ └── discussions/ │ │ │ │ └── [discussionId].tsx │ │ │ ├── styles/ │ │ │ │ └── globals.css │ │ │ ├── testing/ │ │ │ │ ├── data-generators.ts │ │ │ │ ├── mocks/ │ │ │ │ │ ├── browser.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── auth.ts │ │ │ │ │ │ ├── comments.ts │ │ │ │ │ │ ├── discussions.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── teams.ts │ │ │ │ │ │ └── users.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── setup-tests.ts │ │ │ │ └── test-utils.tsx │ │ │ ├── types/ │ │ │ │ └── api.ts │ │ │ └── utils/ │ │ │ ├── cn.ts │ │ │ └── format.ts │ │ ├── tailwind.config.cjs │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── react-vite/ │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── .storybook/ │ │ ├── main.ts │ │ └── preview.tsx │ ├── .vscode/ │ │ ├── extensions.json │ │ └── settings.json │ ├── README.md │ ├── __mocks__/ │ │ ├── vitest-env.d.ts │ │ └── zustand.ts │ ├── e2e/ │ │ ├── .eslintrc.cjs │ │ └── tests/ │ │ ├── auth.setup.ts │ │ ├── profile.spec.ts │ │ └── smoke.spec.ts │ ├── generators/ │ │ └── component/ │ │ ├── component.stories.tsx.hbs │ │ ├── component.tsx.hbs │ │ ├── index.cjs │ │ └── index.ts.hbs │ ├── index.html │ ├── mock-server.ts │ ├── package.json │ ├── playwright.config.ts │ ├── plopfile.cjs │ ├── postcss.config.cjs │ ├── public/ │ │ ├── _redirects │ │ ├── mockServiceWorker.js │ │ └── robots.txt │ ├── src/ │ │ ├── app/ │ │ │ ├── index.tsx │ │ │ ├── provider.tsx │ │ │ ├── router.tsx │ │ │ └── routes/ │ │ │ ├── app/ │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── discussions/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── discussion.test.tsx │ │ │ │ │ │ └── discussions.test.tsx │ │ │ │ │ ├── discussion.tsx │ │ │ │ │ └── discussions.tsx │ │ │ │ ├── profile.tsx │ │ │ │ ├── root.tsx │ │ │ │ └── users.tsx │ │ │ ├── auth/ │ │ │ │ ├── login.tsx │ │ │ │ └── register.tsx │ │ │ ├── landing.tsx │ │ │ └── not-found.tsx │ │ ├── components/ │ │ │ ├── errors/ │ │ │ │ └── main.tsx │ │ │ ├── layouts/ │ │ │ │ ├── auth-layout.tsx │ │ │ │ ├── content-layout.tsx │ │ │ │ ├── dashboard-layout.tsx │ │ │ │ └── index.ts │ │ │ ├── seo/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── head.test.tsx │ │ │ │ ├── head.tsx │ │ │ │ └── index.ts │ │ │ └── ui/ │ │ │ ├── button/ │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── button.tsx │ │ │ │ └── index.ts │ │ │ ├── dialog/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── dialog.test.tsx │ │ │ │ ├── confirmation-dialog/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── confirmation-dialog.test.tsx │ │ │ │ │ ├── confirmation-dialog.stories.tsx │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog.stories.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ └── index.ts │ │ │ ├── drawer/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── drawer.test.tsx │ │ │ │ ├── drawer.stories.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ └── index.ts │ │ │ ├── dropdown/ │ │ │ │ ├── dropdown.stories.tsx │ │ │ │ ├── dropdown.tsx │ │ │ │ └── index.ts │ │ │ ├── form/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── form.test.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── field-wrapper.tsx │ │ │ │ ├── form-drawer.tsx │ │ │ │ ├── form.stories.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── textarea.tsx │ │ │ ├── link/ │ │ │ │ ├── index.ts │ │ │ │ ├── link.stories.tsx │ │ │ │ └── link.tsx │ │ │ ├── md-preview/ │ │ │ │ ├── index.ts │ │ │ │ ├── md-preview.stories.tsx │ │ │ │ └── md-preview.tsx │ │ │ ├── notifications/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── notifications.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification.stories.tsx │ │ │ │ ├── notification.tsx │ │ │ │ ├── notifications-store.ts │ │ │ │ └── notifications.tsx │ │ │ ├── spinner/ │ │ │ │ ├── index.ts │ │ │ │ ├── spinner.stories.tsx │ │ │ │ └── spinner.tsx │ │ │ └── table/ │ │ │ ├── index.ts │ │ │ ├── pagination.tsx │ │ │ ├── table.stories.tsx │ │ │ └── table.tsx │ │ ├── config/ │ │ │ ├── env.ts │ │ │ └── paths.ts │ │ ├── features/ │ │ │ ├── auth/ │ │ │ │ └── components/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ └── register-form.test.tsx │ │ │ │ ├── login-form.tsx │ │ │ │ └── register-form.tsx │ │ │ ├── comments/ │ │ │ │ ├── api/ │ │ │ │ │ ├── create-comment.ts │ │ │ │ │ ├── delete-comment.ts │ │ │ │ │ └── get-comments.ts │ │ │ │ └── components/ │ │ │ │ ├── comments-list.tsx │ │ │ │ ├── comments.tsx │ │ │ │ ├── create-comment.tsx │ │ │ │ └── delete-comment.tsx │ │ │ ├── discussions/ │ │ │ │ ├── api/ │ │ │ │ │ ├── create-discussion.ts │ │ │ │ │ ├── delete-discussion.ts │ │ │ │ │ ├── get-discussion.ts │ │ │ │ │ ├── get-discussions.ts │ │ │ │ │ └── update-discussion.ts │ │ │ │ └── components/ │ │ │ │ ├── create-discussion.tsx │ │ │ │ ├── delete-discussion.tsx │ │ │ │ ├── discussion-view.tsx │ │ │ │ ├── discussions-list.tsx │ │ │ │ └── update-discussion.tsx │ │ │ ├── teams/ │ │ │ │ └── api/ │ │ │ │ └── get-teams.ts │ │ │ └── users/ │ │ │ ├── api/ │ │ │ │ ├── delete-user.ts │ │ │ │ ├── get-users.ts │ │ │ │ └── update-profile.ts │ │ │ └── components/ │ │ │ ├── delete-user.tsx │ │ │ ├── update-profile.tsx │ │ │ └── users-list.tsx │ │ ├── hooks/ │ │ │ ├── __tests__/ │ │ │ │ └── use-disclosure.test.ts │ │ │ └── use-disclosure.ts │ │ ├── index.css │ │ ├── lib/ │ │ │ ├── __tests__/ │ │ │ │ └── authorization.test.tsx │ │ │ ├── api-client.ts │ │ │ ├── auth.tsx │ │ │ ├── authorization.tsx │ │ │ └── react-query.ts │ │ ├── main.tsx │ │ ├── testing/ │ │ │ ├── data-generators.ts │ │ │ ├── mocks/ │ │ │ │ ├── browser.ts │ │ │ │ ├── db.ts │ │ │ │ ├── handlers/ │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── comments.ts │ │ │ │ │ ├── discussions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── teams.ts │ │ │ │ │ └── users.ts │ │ │ │ ├── index.ts │ │ │ │ ├── server.ts │ │ │ │ └── utils.ts │ │ │ ├── setup-tests.ts │ │ │ └── test-utils.tsx │ │ ├── types/ │ │ │ └── api.ts │ │ ├── utils/ │ │ │ ├── cn.ts │ │ │ └── format.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── vite-env.d.ts │ └── vite.config.ts ├── docs/ │ ├── additional-resources.md │ ├── api-layer.md │ ├── application-overview.md │ ├── components-and-styling.md │ ├── deployment.md │ ├── error-handling.md │ ├── performance.md │ ├── project-standards.md │ ├── project-structure.md │ ├── security.md │ ├── state-management.md │ └── testing.md └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/nextjs-app-ci.yml ================================================ name: Next.js App CI on: push: branches: ["*"] paths-ignore: - "README.md" - "docs/**" pull_request: branches: [master] paths-ignore: - "README.md" - "docs/**" jobs: all-cli-checks: runs-on: ubuntu-latest defaults: run: working-directory: ./apps/nextjs-app steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example .env - name: Install dependencies run: yarn install - name: Build application run: yarn build - name: Run tests run: yarn test - name: Run linter run: yarn lint - name: Check types run: yarn check-types e2e: timeout-minutes: 60 runs-on: ubuntu-latest defaults: run: working-directory: ./apps/nextjs-app steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example-e2e .env - name: Install dependencies run: npm install -g yarn && yarn - name: Install Playwright Browsers run: yarn playwright install --with-deps - name: Run Playwright tests run: yarn test-e2e - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: | playwright-report/ mocked-db.json retention-days: 30 ================================================ FILE: .github/workflows/nextjs-pages-ci.yml ================================================ name: Next.js Pages CI on: push: branches: ["*"] paths-ignore: - "README.md" - "docs/**" pull_request: branches: [master] paths-ignore: - "README.md" - "docs/**" jobs: all-cli-checks: runs-on: ubuntu-latest defaults: run: working-directory: ./apps/nextjs-pages steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example .env - name: Install dependencies run: yarn install - name: Build application run: yarn build - name: Run tests run: yarn test - name: Run linter run: yarn lint - name: Check types run: yarn check-types e2e: timeout-minutes: 60 runs-on: ubuntu-latest defaults: run: working-directory: ./apps/nextjs-pages steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example-e2e .env - name: Install dependencies run: npm install -g yarn && yarn - name: Install Playwright Browsers run: yarn playwright install --with-deps - name: Run Playwright tests run: yarn test-e2e - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: | playwright-report/ mocked-db.json retention-days: 30 ================================================ FILE: .github/workflows/react-vite-ci.yml ================================================ name: React Vite CI on: push: branches: ["*"] paths-ignore: - "README.md" - "docs/**" pull_request: branches: [master] paths-ignore: - "README.md" - "docs/**" jobs: all-cli-checks: runs-on: ubuntu-latest defaults: run: working-directory: ./apps/react-vite steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example .env - name: Install dependencies run: yarn install - name: Build application run: yarn build - name: Run tests run: yarn test - name: Run linter run: yarn lint - name: Check types run: yarn check-types e2e: timeout-minutes: 60 runs-on: ubuntu-latest defaults: run: working-directory: ./apps/react-vite steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Set environment variables run: mv .env.example-e2e .env - name: Install dependencies run: npm install -g yarn && yarn - name: Install Playwright Browsers run: yarn playwright install --with-deps - name: Run Playwright tests run: yarn test-e2e - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: | playwright-report/ mocked-db.json retention-days: 30 ================================================ FILE: .gitignore ================================================ # dependencies /node_modules /.pnp .pnp.js # misc .DS_Store ================================================ FILE: .husky/pre-commit ================================================ yarn --cwd apps/nextjs-app lint-staged && yarn --cwd apps/nextjs-pages lint-staged && yarn --cwd apps/react-vite lint-staged ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Alan Alickovic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Bulletproof React 🛡️ ⚛️ [![MIT License](https://img.shields.io/github/license/alan2207/bulletproof-react)](https://github.com/alan2207/bulletproof-react/blob/master/LICENSE) [![Next.js App CI](https://github.com/alan2207/bulletproof-react/actions/workflows/nextjs-app-ci.yml/badge.svg)](https://github.com/alan2207/bulletproof-react/actions/workflows/nextjs-app-ci.yml) [![Next.js Pages CI](https://github.com/alan2207/bulletproof-react/actions/workflows/nextjs-pages-ci.yml/badge.svg)](https://github.com/alan2207/bulletproof-react/actions/workflows/nextjs-pages-ci.yml) [![React Vite CI](https://github.com/alan2207/bulletproof-react/actions/workflows/react-vite-ci.yml/badge.svg)](https://github.com/alan2207/bulletproof-react/actions/workflows/react-vite-ci.yml) A simple, scalable, and powerful architecture for building production ready React applications. ## Introduction React is an excellent tool for building front-end applications. It has a diverse ecosystem with hundreds of great libraries for literally anything you might need. However, being forced to make so many choices can be overwhelming. It is also very flexible, you can write React applications in any way you like, but that flexibility comes with a cost. Since there is no pre-defined architecture that developers can follow, it often leads to a messy, inconsistent, and over-complicated codebase. This repo attempts to present a way of creating React applications using some of the best tools in the ecosystem with a good project structure that scales very well. Based on my experience working with a lot of different codebases, this architecture turns out to be the most effective. The goal here is to serve as a collection of resources and best practices when developing React applications. It is supposed to showcase solving most of the real-world problems of an application in a practical way and help developers write better applications. Feel free to explore the sample app codebase to get the most value out of the repo. > 🤝 **Looking for help implementing these patterns at your company?** [Get in touch](mailto:alan2207@live.com) ## What makes a React application "bulletproof"? This repo doesn't aim to be a silver bullet for all React applications as there are many different use cases, but it tries to provide a solid foundation for building applications based on the following principles: - Easy to get started with - Simple to understand and maintain - Uses the right tools for the job - Clean boundaries between different parts of the application - Everyone on the team is on the same page when it comes to how things are done - Secure - Performant - Scalable in terms of codebase and team size - Issues detectable as early as possible #### Disclaimer: This is not supposed to be a template, boilerplate or a framework. It is an opinionated guide that shows how to do some things in a certain way. You are not forced to do everything exactly as it is shown here, decide what works best for you and your team and stay consistent with your style. To get most out of it, do not get limited by the technologies used in this sample app, but rather focus on the principles and the concepts that are being presented here. The tools and libraries used here are just a suggestion, you can always replace them with something that fits your needs better. Sometimes, your project might require a slightly different approach, and that's totally fine. ## Table Of Contents: - [💻 Application Overview](docs/application-overview.md) - [⚙️ Project Standards](docs/project-standards.md) - [🗄️ Project Structure](docs/project-structure.md) - [🧱 Components And Styling](docs/components-and-styling.md) - [📡 API Layer](docs/api-layer.md) - [🗃️ State Management](docs/state-management.md) - [🧪 Testing](docs/testing.md) - [⚠️ Error Handling](docs/error-handling.md) - [🔐 Security](docs/security.md) - [🚄 Performance](docs/performance.md) - [🌐 Deployment](docs/deployment.md) - [📚 Additional Resources](docs/additional-resources.md) ## Contributing Contributions are always welcome! If you have any ideas, suggestions, fixes, feel free to contribute. You can do that by going through the following steps: 1. Clone this repo 2. Create a branch: `git checkout -b your-feature` 3. Execute the `yarn prepare` script. 4. Make some changes 5. Test your changes 6. Push your branch and open a Pull Request ## License [MIT](/LICENSE) ================================================ FILE: apps/nextjs-app/.eslintrc.cjs ================================================ 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'], 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 }], }, }, { plugins: ['check-file'], files: ['src/**/*'], rules: { 'check-file/filename-naming-convention': [ 'error', { '**/*.{ts,tsx}': 'KEBAB_CASE', }, { ignoreMiddleExtensions: true, }, ], 'check-file/folder-naming-convention': [ 'error', { '!(src/app)/**/*': 'KEBAB_CASE', '!(**/__tests__)/**/*': 'KEBAB_CASE', }, ], }, }, ], }; ================================================ FILE: apps/nextjs-app/.gitignore ================================================ # 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 ================================================ FILE: apps/nextjs-app/.prettierignore ================================================ *.hbs ================================================ FILE: apps/nextjs-app/.prettierrc ================================================ { "singleQuote": true, "trailingComma": "all", "printWidth": 80, "tabWidth": 2, "useTabs": false } ================================================ FILE: apps/nextjs-app/.storybook/main.ts ================================================ 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', }, }; ================================================ FILE: apps/nextjs-app/.storybook/preview.tsx ================================================ import React from 'react'; import '../src/styles/globals.css'; export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, }; export const decorators = [(Story) => ]; ================================================ FILE: apps/nextjs-app/.vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "dsznajder.es7-react-js-snippets", "mariusalchimavicius.json-to-ts", "bradlc.vscode-tailwindcss" ] } ================================================ FILE: apps/nextjs-app/.vscode/settings.json ================================================ { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } } ================================================ FILE: apps/nextjs-app/README.md ================================================ # Next.js App 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-app 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. ================================================ FILE: apps/nextjs-app/__mocks__/vitest-env.d.ts ================================================ /// /// ================================================ FILE: apps/nextjs-app/__mocks__/zustand.ts ================================================ 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(); }); }); }); ================================================ FILE: apps/nextjs-app/e2e/.eslintrc.cjs ================================================ module.exports = { root: true, parser: '@typescript-eslint/parser', extends: 'plugin:playwright/recommended', }; ================================================ FILE: apps/nextjs-app/e2e/tests/auth.setup.ts ================================================ 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 }); }); ================================================ FILE: apps/nextjs-app/e2e/tests/profile.spec.ts ================================================ 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(); }); ================================================ FILE: apps/nextjs-app/e2e/tests/smoke.spec.ts ================================================ 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(); }); ================================================ FILE: apps/nextjs-app/generators/component/component.stories.tsx.hbs ================================================ 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: {} }; ================================================ FILE: apps/nextjs-app/generators/component/component.tsx.hbs ================================================ import * as React from "react"; export type {{properCase name}}Props = {}; export const {{properCase name}} = (props: {{properCase name}}Props) => { return (
{{properCase name}}
); }; ================================================ FILE: apps/nextjs-app/generators/component/index.cjs ================================================ 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', }, ]; }, }; ================================================ FILE: apps/nextjs-app/generators/component/index.ts.hbs ================================================ export * from './{{ kebabCase name }}'; ================================================ FILE: apps/nextjs-app/index.html ================================================ Bulletproof React
================================================ FILE: apps/nextjs-app/lint-staged.config.mjs ================================================ 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; ================================================ FILE: apps/nextjs-app/mock-server.ts ================================================ 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: 'info', redact: ['req.headers', 'res.headers'], transport: { target: 'pino-pretty', options: { colorize: true, translateTime: true, }, }, }), ); 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}`, ); }); }); ================================================ FILE: apps/nextjs-app/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: apps/nextjs-app/next.config.mjs ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, }; export default nextConfig; ================================================ FILE: apps/nextjs-app/package.json ================================================ { "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", "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", "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", "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": "^2.1.4" }, "msw": { "workerDirectory": "public" } } ================================================ FILE: apps/nextjs-app/playwright.config.ts ================================================ 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, }, }); ================================================ FILE: apps/nextjs-app/plopfile.cjs ================================================ const componentGenerator = require('./generators/component/index'); /** * * @param {import('plop').NodePlopAPI} plop */ module.exports = function (plop) { plop.setGenerator('component', componentGenerator); }; ================================================ FILE: apps/nextjs-app/postcss.config.cjs ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: apps/nextjs-app/public/_redirects ================================================ /* /index.html 200 ================================================ FILE: apps/nextjs-app/public/mockServiceWorker.js ================================================ /* 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 } ================================================ FILE: apps/nextjs-app/public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: apps/nextjs-app/src/app/app/_components/dashboard-info.tsx ================================================ 'use client'; import { useUser } from '@/lib/auth'; export const DashboardInfo = () => { const user = useUser(); return ( <>

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

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

In this application you can:

{user.data?.role === 'USER' && (
  • Create comments in discussions
  • Delete own comments
)} {user.data?.role === 'ADMIN' && (
  • Create discussions
  • Edit discussions
  • Delete discussions
  • Comment on discussions
  • Delete all comments
)} ); }; ================================================ FILE: apps/nextjs-app/src/app/app/_components/dashboard-layout.tsx ================================================ 'use client'; import { Home, PanelLeft, Folder, Users, User2 } from 'lucide-react'; import NextLink from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { ErrorBoundary } from 'react-error-boundary'; import { Button } from '@/components/ui/button'; import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown'; import { Link } from '@/components/ui/link'; import { paths } from '@/config/paths'; import { useLogout, useUser } from '@/lib/auth'; import { cn } from '@/utils/cn'; type SideNavigationItem = { name: string; to: string; icon: (props: React.SVGProps) => JSX.Element; }; const Logo = () => { return ( Workflow Bulletproof React ); }; const Layout = ({ children }: { children: React.ReactNode }) => { const user = useUser(); const pathname = usePathname(); const router = useRouter(); const logout = useLogout({ onSuccess: () => router.push(paths.auth.login.getHref(pathname)), }); const navigation = [ { name: 'Dashboard', to: paths.app.root.getHref(), icon: Home }, { name: 'Discussions', to: paths.app.discussions.getHref(), icon: Folder }, user.data?.role === 'ADMIN' && { name: 'Users', to: paths.app.users.getHref(), icon: Users, }, ].filter(Boolean) as SideNavigationItem[]; return (
{/* */} router.push(paths.app.profile.getHref())} className={cn('block px-4 py-2 text-sm text-gray-700')} > Your Profile logout.mutate()} > Sign Out
{children}
); }; function Fallback({ error }: { error: Error }) { return

Error: {error.message ?? 'Something went wrong!'}

; } export const DashboardLayout = ({ children, }: { children: React.ReactNode; }) => { const pathname = usePathname(); return ( {children} ); }; ================================================ FILE: apps/nextjs-app/src/app/app/discussions/[discussionId]/__tests__/discussion.test.tsx ================================================ import { useParams } from 'next/navigation'; import { renderApp, screen, userEvent, waitFor, createDiscussion, createUser, within, waitForLoadingToFinish, } from '@/testing/test-utils'; import { Discussion } from '../_components/discussion'; vi.mock('next/navigation', async () => { const actual = await vi.importActual('next/navigation'); return { ...actual, useRouter: () => { return { push: vi.fn(), replace: vi.fn(), }; }, useParams: vi.fn(), }; }); const renderDiscussion = async () => { const fakeUser = await createUser(); const fakeDiscussion = await createDiscussion({ teamId: fakeUser.teamId }); vi.mocked(useParams).mockReturnValue({ 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, }, ); ================================================ FILE: apps/nextjs-app/src/app/app/discussions/[discussionId]/_components/discussion.tsx ================================================ 'use client'; import { ErrorBoundary } from 'react-error-boundary'; import { ContentLayout } from '@/components/layouts/content-layout'; import { Comments } from '@/features/comments/components/comments'; import { useDiscussion } from '@/features/discussions/api/get-discussion'; import { DiscussionView } from '@/features/discussions/components/discussion-view'; export const Discussion = ({ discussionId }: { discussionId: string }) => { const discussion = useDiscussion({ discussionId }); return (
Failed to load comments. Try to refresh the page.
} >
); }; ================================================ FILE: apps/nextjs-app/src/app/app/discussions/[discussionId]/page.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient, } from '@tanstack/react-query'; import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments'; import { getDiscussion, getDiscussionQueryOptions, } from '@/features/discussions/api/get-discussion'; import { Discussion } from './_components/discussion'; export const generateMetadata = async ({ params, }: { params: Promise<{ discussionId: string }>; }) => { const discussionId = (await params).discussionId; const discussion = await getDiscussion({ discussionId }); return { title: discussion.data?.title, description: discussion.data?.title, }; }; const preloadData = async (discussionId: string) => { const queryClient = new QueryClient(); await Promise.all([ queryClient.prefetchQuery(getDiscussionQueryOptions(discussionId)), queryClient.prefetchInfiniteQuery( getInfiniteCommentsQueryOptions(discussionId), ), ]); const dehydratedState = dehydrate(queryClient); return { dehydratedState, queryClient, }; }; const DiscussionPage = async ({ params, }: { params: Promise<{ discussionId: string; }>; }) => { const discussionId = (await params).discussionId; const { dehydratedState, queryClient } = await preloadData(discussionId); const discussion = queryClient.getQueryData( getDiscussionQueryOptions(discussionId).queryKey, ); if (!discussion?.data) return
Discussion not found
; return ( ); }; export default DiscussionPage; ================================================ FILE: apps/nextjs-app/src/app/app/discussions/__tests__/discussions.test.tsx ================================================ 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 { Discussions } from '../_components/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(); }, ); ================================================ FILE: apps/nextjs-app/src/app/app/discussions/_components/discussions.tsx ================================================ 'use client'; import { useQueryClient } from '@tanstack/react-query'; import { ContentLayout } from '@/components/layouts/content-layout'; 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 Discussions = () => { const queryClient = useQueryClient(); return (
{ // Prefetch the comments data when the user hovers over the link in the list queryClient.prefetchInfiniteQuery( getInfiniteCommentsQueryOptions(id), ); }} />
); }; ================================================ FILE: apps/nextjs-app/src/app/app/discussions/page.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient, } from '@tanstack/react-query'; import { getDiscussionsQueryOptions } from '@/features/discussions/api/get-discussions'; import { Discussions } from './_components/discussions'; export const metadata = { title: 'Discussions', description: 'Discussions', }; const DiscussionsPage = async ({ searchParams, }: { searchParams: { page: string | null }; }) => { const queryClient = new QueryClient(); await queryClient.prefetchQuery( getDiscussionsQueryOptions({ page: searchParams.page ? Number(searchParams.page) : 1, }), ); const dehydratedState = dehydrate(queryClient); return ( ); }; export default DiscussionsPage; ================================================ FILE: apps/nextjs-app/src/app/app/layout.tsx ================================================ import { ReactNode } from 'react'; import { DashboardLayout } from './_components/dashboard-layout'; export const metadata = { title: 'Dashboard', description: 'Dashboard', }; const AppLayout = ({ children }: { children: ReactNode }) => { return {children}; }; export default AppLayout; ================================================ FILE: apps/nextjs-app/src/app/app/page.tsx ================================================ import { DashboardInfo } from './_components/dashboard-info'; export const metadata = { title: 'Dashboard', description: 'Dashboard', }; const DashboardPage = async () => { return ; }; export default DashboardPage; ================================================ FILE: apps/nextjs-app/src/app/app/profile/_components/profile.tsx ================================================ 'use client'; 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 Profile = () => { const user = useUser(); if (!user) return null; return (

User Information

Personal details of the user.

); }; ================================================ FILE: apps/nextjs-app/src/app/app/profile/page.tsx ================================================ import { Profile } from './_components/profile'; export const metadata = { title: 'Profile', description: 'Profile', }; const ProfilePage = () => { return ; }; export default ProfilePage; ================================================ FILE: apps/nextjs-app/src/app/app/users/_components/admin-guard.tsx ================================================ 'use client'; import { Spinner } from '@/components/ui/spinner'; import { useUser } from '@/lib/auth'; import { canViewUsers } from '@/lib/authorization'; export const AdminGuard = ({ children }: { children: React.ReactNode }) => { const user = useUser(); if (!user?.data) { return ; } if (!canViewUsers(user?.data)) { return
Only admin can view this.
; } return children; }; ================================================ FILE: apps/nextjs-app/src/app/app/users/_components/users.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient, } from '@tanstack/react-query'; import { getUsersQueryOptions } from '@/features/users/api/get-users'; import { UsersList } from '@/features/users/components/users-list'; export const Users = async () => { const queryClient = new QueryClient(); await queryClient.prefetchQuery(getUsersQueryOptions()); const dehydratedState = dehydrate(queryClient); return ( ); }; ================================================ FILE: apps/nextjs-app/src/app/app/users/page.tsx ================================================ import { ContentLayout } from '@/components/layouts/content-layout'; import { AdminGuard } from './_components/admin-guard'; import { Users } from './_components/users'; export const metadata = { title: 'Users', description: 'Users', }; const UsersPage = () => { return ( ); }; export default UsersPage; ================================================ FILE: apps/nextjs-app/src/app/auth/_components/auth-layout.tsx ================================================ 'use client'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { ReactNode, useEffect } from 'react'; import { Link } from '@/components/ui/link'; import { paths } from '@/config/paths'; import { useUser } from '@/lib/auth'; type LayoutProps = { children: ReactNode; }; export const AuthLayout = ({ children }: LayoutProps) => { const user = useUser(); const router = useRouter(); const pathname = usePathname(); const isLoginPage = pathname === paths.auth.login.getHref(); const title = isLoginPage ? 'Log in to your account' : 'Register your account'; const searchParams = useSearchParams(); const redirectTo = searchParams?.get('redirectTo'); useEffect(() => { if (user.data) { router.replace( `${redirectTo ? `${decodeURIComponent(redirectTo)}` : paths.app.dashboard.getHref()}`, ); } }, [user.data, router, redirectTo]); return (
Workflow

{title}

{children}
); }; ================================================ FILE: apps/nextjs-app/src/app/auth/layout.tsx ================================================ import { ReactNode, Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Spinner } from '@/components/ui/spinner'; import { AuthLayout as AuthLayoutComponent } from './_components/auth-layout'; export const metadata = { title: 'Bulletproof React', description: 'Welcome to Bulletproof React', }; const AuthLayout = ({ children }: { children: ReactNode }) => { return ( } > Something went wrong!}> {children} ); }; export default AuthLayout; ================================================ FILE: apps/nextjs-app/src/app/auth/login/page.tsx ================================================ 'use client'; import { useRouter, useSearchParams } from 'next/navigation'; import { paths } from '@/config/paths'; import { LoginForm } from '@/features/auth/components/login-form'; const LoginPage = () => { const router = useRouter(); const searchParams = useSearchParams(); const redirectTo = searchParams?.get('redirectTo'); return ( router.replace( `${redirectTo ? `${decodeURIComponent(redirectTo)}` : paths.app.dashboard.getHref()}`, ) } /> ); }; export default LoginPage; ================================================ FILE: apps/nextjs-app/src/app/auth/register/page.tsx ================================================ 'use client'; import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; import { paths } from '@/config/paths'; import { RegisterForm } from '@/features/auth/components/register-form'; import { useTeams } from '@/features/teams/api/get-teams'; const RegisterPage = () => { const router = useRouter(); const searchParams = useSearchParams(); const redirectTo = searchParams?.get('redirectTo'); const [chooseTeam, setChooseTeam] = useState(false); const teamsQuery = useTeams({ queryConfig: { enabled: chooseTeam, }, }); return ( router.replace( `${redirectTo ? `${decodeURIComponent(redirectTo)}` : paths.app.dashboard.getHref()}`, ) } chooseTeam={chooseTeam} setChooseTeam={() => setChooseTeam(!chooseTeam)} teams={teamsQuery.data?.data} /> ); }; export default RegisterPage; ================================================ FILE: apps/nextjs-app/src/app/layout.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient, } from '@tanstack/react-query'; import { ReactNode } from 'react'; import { AppProvider } from '@/app/provider'; import { getUserQueryOptions } from '@/lib/auth'; import '@/styles/globals.css'; export const metadata = { title: 'Bulletproof React', description: 'Showcasing Best Practices For Building React Applications', }; const RootLayout = async ({ children }: { children: ReactNode }) => { const queryClient = new QueryClient(); await queryClient.prefetchQuery(getUserQueryOptions()); const dehydratedState = dehydrate(queryClient); return ( {children} ); }; export default RootLayout; // We are not prerendering anything because the app is highly dynamic // and the data depends on the user so we need to send cookies with each request export const dynamic = 'force-dynamic'; ================================================ FILE: apps/nextjs-app/src/app/not-found.tsx ================================================ import { Link } from '@/components/ui/link'; import { paths } from '@/config/paths'; const NotFoundPage = () => { return (

404 - Not Found

Sorry, the page you are looking for does not exist.

Go to Home
); }; export default NotFoundPage; ================================================ FILE: apps/nextjs-app/src/app/page.tsx ================================================ import { Button } from '@/components/ui/button'; import { Link } from '@/components/ui/link'; import { paths } from '@/config/paths'; import { checkLoggedIn } from '@/utils/auth'; const HomePage = () => { const isLoggedIn = checkLoggedIn(); return (

Bulletproof React

react

Showcasing Best Practices For Building React Applications

); }; export default HomePage; ================================================ FILE: apps/nextjs-app/src/app/provider.tsx ================================================ 'use client'; 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 { 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} ); }; ================================================ FILE: apps/nextjs-app/src/app/public/discussions/[discussionId]/page.tsx ================================================ import { dehydrate, HydrationBoundary, QueryClient, } from '@tanstack/react-query'; import { Discussion } from '@/app/app/discussions/[discussionId]/_components/discussion'; import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments'; import { getDiscussion, getDiscussionQueryOptions, } from '@/features/discussions/api/get-discussion'; export const generateMetadata = async ({ params, }: { params: Promise<{ discussionId: string }>; }) => { const discussionId = (await params).discussionId; const discussion = await getDiscussion({ discussionId }); return { title: discussion.data?.title, description: discussion.data?.title, }; }; const preloadData = async (discussionId: string) => { const queryClient = new QueryClient(); await Promise.all([ queryClient.prefetchQuery(getDiscussionQueryOptions(discussionId)), queryClient.prefetchInfiniteQuery( getInfiniteCommentsQueryOptions(discussionId), ), ]); return { dehydratedState: dehydrate(queryClient), }; }; const PublicDiscussionPage = async ({ params: { discussionId }, }: { params: { discussionId: string; }; }) => { const { dehydratedState } = await preloadData(discussionId); return ( ); }; export default PublicDiscussionPage; ================================================ FILE: apps/nextjs-app/src/components/errors/main.tsx ================================================ import { Button } from '../ui/button'; export const MainErrorFallback = () => { return (

Ooops, something went wrong :(

); }; ================================================ FILE: apps/nextjs-app/src/components/layouts/content-layout.tsx ================================================ import { ReactNode } from 'react'; type ContentLayoutProps = { children: ReactNode; title?: string; }; export const ContentLayout = ({ children, title = '' }: ContentLayoutProps) => { return (

{title}

{children}
); }; ================================================ FILE: apps/nextjs-app/src/components/ui/button/button.stories.tsx ================================================ 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', }, }; ================================================ FILE: apps/nextjs-app/src/components/ui/button/button.tsx ================================================ 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 }; ================================================ FILE: apps/nextjs-app/src/components/ui/button/index.ts ================================================ export * from './button'; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/__tests__/dialog.test.tsx ================================================ 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(), ); }); ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx ================================================ 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(); }); ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx ================================================ 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: , }, }; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx ================================================ 'use client'; 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}
); }; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/confirmation-dialog/index.ts ================================================ export * from './confirmation-dialog'; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/dialog.stories.tsx ================================================ 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: () => , }; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/dialog.tsx ================================================ 'use client'; 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, }; ================================================ FILE: apps/nextjs-app/src/components/ui/dialog/index.ts ================================================ export * from './dialog'; export * from './confirmation-dialog'; ================================================ FILE: apps/nextjs-app/src/components/ui/drawer/__tests__/drawer.test.tsx ================================================ 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(), ); }); ================================================ FILE: apps/nextjs-app/src/components/ui/drawer/drawer.stories.tsx ================================================ 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: () => , }; ================================================ FILE: apps/nextjs-app/src/components/ui/drawer/drawer.tsx ================================================ 'use client'; 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, }; ================================================ FILE: apps/nextjs-app/src/components/ui/drawer/index.ts ================================================ export * from './drawer'; ================================================ FILE: apps/nextjs-app/src/components/ui/dropdown/dropdown.stories.tsx ================================================ import type { Meta } from '@storybook/react'; import React from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuRadioGroup, } from './dropdown'; const meta: Meta = { component: DropdownMenu, }; export default meta; export const Default = () => ( Item One Item Two Item Three ); export const WithCheckboxItems = () => { const [checked, setChecked] = React.useState(true); const [checked2, setChecked2] = React.useState(false); return ( Option One Option Two ); }; export const WithRadioItems = () => { const [value, setValue] = React.useState('one'); return ( Select an option Option One Option Two Option Three ); }; export const WithSubmenus = () => ( Item One More Options Sub Item One Sub Item Two Item Three ); ================================================ FILE: apps/nextjs-app/src/components/ui/dropdown/dropdown.tsx ================================================ 'use client'; 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, }; ================================================ FILE: apps/nextjs-app/src/components/ui/dropdown/index.ts ================================================ export * from './dropdown'; ================================================ FILE: apps/nextjs-app/src/components/ui/form/__tests__/form.test.tsx ================================================ 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); }); ================================================ FILE: apps/nextjs-app/src/components/ui/form/error.tsx ================================================ export type ErrorProps = { errorMessage?: string | null; }; export const Error = ({ errorMessage }: ErrorProps) => { if (!errorMessage) return null; return (
{errorMessage}
); }; ================================================ FILE: apps/nextjs-app/src/components/ui/form/field-wrapper.tsx ================================================ 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 (
); }; ================================================ FILE: apps/nextjs-app/src/components/ui/form/form-drawer.tsx ================================================ 'use client'; 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}
); }; ================================================ FILE: apps/nextjs-app/src/components/ui/form/form.stories.tsx ================================================ 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 }) => ( <>