├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── README.md ├── components ├── atoms │ ├── button.tsx │ ├── form-error-message.tsx │ ├── form-label.tsx │ ├── index.ts │ ├── input-label.tsx │ ├── link.tsx │ ├── text-field.tsx │ └── typography.tsx ├── logo.tsx ├── molecules │ ├── .gitkeep │ ├── dashboard-card.tsx │ ├── navi-item.tsx │ ├── pager.tsx │ ├── product-row.tsx │ └── table-header.tsx ├── organisms │ ├── .gitkeep │ ├── dashboard.tsx │ └── searchable-table.tsx ├── page │ ├── confirm-code-dialog.tsx │ ├── index-page.tsx │ ├── login-page.tsx │ └── password-dialog.tsx ├── progress.tsx └── template │ ├── confirm.tsx │ ├── dashboard-layout.tsx │ ├── header.tsx │ ├── index.ts │ ├── seo.tsx │ ├── sidebar.tsx │ └── simple-layout.tsx ├── const └── index.ts ├── context ├── confirm-context.ts ├── confirm-provider.tsx ├── global-context.ts └── global-state-provider.tsx ├── data ├── dialog-options.ts ├── global-state.ts ├── index.ts ├── page-item.ts ├── session.ts ├── sort-item.ts ├── table-header-item.ts └── text-field-type.ts ├── filters ├── addFilters.ts ├── checkAutoLogin.ts └── checkSession.ts ├── helpers ├── system.ts └── token.ts ├── hooks ├── index.ts ├── useConfirm.ts ├── useSafeState.ts └── useUnmountRef.ts ├── jest.config.js ├── lib ├── data │ ├── id-request.ts │ ├── product.ts │ └── products.ts └── shared │ └── product-data.ts ├── next-env.d.ts ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── api │ ├── auth │ │ ├── check │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── permissions │ │ │ └── index.ts │ │ ├── refreshTokenCheck │ │ │ └── index.ts │ │ └── signout │ │ │ └── index.ts │ ├── code │ │ └── verify │ │ │ └── index.ts │ ├── hello.ts │ ├── password │ │ └── change │ │ │ └── index.ts │ └── products │ │ ├── delete │ │ └── index.ts │ │ ├── get │ │ └── index.ts │ │ ├── index.ts │ │ ├── post │ │ └── index.ts │ │ └── put │ │ └── index.ts ├── complete.tsx ├── index.tsx ├── login.tsx ├── product │ └── [id].tsx └── ui-elements.tsx ├── postcss.config.js ├── public ├── favicon.ico └── images │ ├── profile.jpg │ └── profile.png ├── renovate.json ├── repository ├── auth-repository.ts └── product-repository.ts ├── styles └── global.css ├── tailwind.config.js ├── test ├── __mocks__ │ └── fileMock.js ├── compornents │ ├── atoms │ │ ├── __snapshots__ │ │ │ └── text-field.test.tsx.snap │ │ └── text-field.test.tsx │ └── molecules │ │ ├── __snapshots__ │ │ └── pager.test.tsx.snap │ │ └── pager.test.tsx ├── pages │ ├── __snapshots__ │ │ └── login.test.tsx.snap │ └── login.test.tsx └── testUtils.tsx ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/out/* 3 | **/.next/* 4 | **/test/* 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | // Uncomment the following lines to enable eslint-config-prettier 9 | // Is not enabled right now to avoid issues with the Next.js repo 10 | // "prettier", 11 | ], 12 | "env": { 13 | "es6": true, 14 | "browser": true, 15 | "jest": true, 16 | "node": true 17 | }, 18 | "settings": { 19 | "react": { 20 | "version": "detect" 21 | } 22 | }, 23 | "rules": { 24 | "react/react-in-jsx-scope": 0, 25 | "react/display-name": 0, 26 | "react/prop-types": 0, 27 | "@typescript-eslint/explicit-function-return-type": 0, 28 | "@typescript-eslint/explicit-member-accessibility": 0, 29 | "@typescript-eslint/indent": 0, 30 | "@typescript-eslint/member-delimiter-style": 0, 31 | "@typescript-eslint/no-explicit-any": 0, 32 | "@typescript-eslint/no-var-requires": 0, 33 | "@typescript-eslint/no-use-before-define": 0, 34 | "@typescript-eslint/no-unused-vars": [ 35 | 2, 36 | { 37 | "argsIgnorePattern": "^_" 38 | } 39 | ], 40 | "no-console": [ 41 | 2, 42 | { 43 | "allow": ["warn", "error"] 44 | } 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | .vercel 28 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | yarn.lock 4 | package-lock.json 5 | public 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-typescript-sample 2 | 3 | Create a [Next.js](https://nextjs.org/) sample app powered by [Vercel](https://vercel.com/). 4 | 5 | This is a starter template for [Learn Next.js](https://nextjs.org/learn). 6 | 7 | ## Demo Page 8 | 9 | - [https://next-typescript-sample-mu.vercel.app/](https://next-typescript-sample-mu.vercel.app/) 10 | 11 | ## SetUp 12 | 13 | ``` 14 | yarn 15 | ``` 16 | 17 | ``` 18 | yarn dev 19 | ``` 20 | 21 | ## Api Call by httpie 22 | 23 | ### Auth 24 | 25 | #### Login 26 | 27 | ``` 28 | % http POST localhost:3000/api/auth id=test@test.com password=admin 29 | HTTP/1.1 200 OK 30 | Access-Control-Allow-Headers: Content-Type, Authorization 31 | Access-Control-Allow-Methods: GET, POST, OPTIONS 32 | Access-Control-Allow-Origin: * 33 | Connection: keep-alive 34 | Content-Length: 394 35 | Content-Type: application/json; charset=utf-8 36 | Date: Fri, 17 May 2024 10:17:01 GMT 37 | ETag: "18a-YxvWFfvmnbsudkJJ4TTgo1ce+FA" 38 | Keep-Alive: timeout=5 39 | Set-Cookie: refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTY1NDU4MjAsInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20ifSwiaWF0IjoxNzE1OTQxMDIwfQ.4kcZ2GH7qJwDTCCneHUj8uwlcmnPD1blXRtgnTbOrb4; Max-Age=604800; Path=/; HttpOnly; SameSite=Strict 40 | Vary: Accept-Encoding 41 | 42 | { 43 | "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTY1NDU4MjAsInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20ifSwiaWF0IjoxNzE1OTQxMDIwfQ.4kcZ2GH7qJwDTCCneHUj8uwlcmnPD1blXRtgnTbOrb4", 44 | "status": "ok", 45 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTU5NDEwMjAsInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20ifSwiaWF0IjoxNzE1OTQxMDIwfQ.FvPQhS4SDhsLkPwEIo9jbOaPDlMIeSOiq01nso-4fM4" 46 | } 47 | ``` 48 | 49 | #### Sign Out 50 | 51 | ``` 52 | % http POST localhost:3000/api/auth/signout 53 | HTTP/1.1 200 OK 54 | Connection: keep-alive 55 | Content-Length: 15 56 | Content-Type: application/json; charset=utf-8 57 | Date: Thu, 19 Aug 2021 08:58:59 GMT 58 | ETag: "f-VaSQ4oDUiZblZNAEkkN+sX+q3Sg" 59 | Keep-Alive: timeout=5 60 | Vary: Accept-Encoding 61 | 62 | { 63 | "status": "ok" 64 | } 65 | ``` 66 | 67 | #### Get Permisson 68 | 69 | ``` 70 | $ http localhost:3000/api/auth/permissions Authorization:'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTYxNzIyMjMsInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20iLCJyb2xlIjoidXNlciJ9LCJpYXQiOjE3MTYxNjg2MjN9.3PWJF0ibVh7BwaC2xV7dHavnvQwmhW8qkJrPuGvVDpM' 71 | HTTP/1.1 200 OK 72 | Access-Control-Allow-Headers: * 73 | Access-Control-Allow-Methods: GET, OPTIONS 74 | Access-Control-Allow-Origin: * 75 | Connection: keep-alive 76 | Content-Length: 158 77 | Content-Type: application/json; charset=utf-8 78 | Date: Mon, 20 May 2024 01:34:29 GMT 79 | ETag: "9e-uyDVjhu2j3xsqjS8Dud7Gx7zBas" 80 | Keep-Alive: timeout=5 81 | Vary: Accept-Encoding 82 | 83 | { 84 | "permissions": [ 85 | { 86 | "namespace": "product", 87 | "operation": "view" 88 | }, 89 | { 90 | "namespace": "order", 91 | "operation": "create" 92 | }, 93 | { 94 | "namespace": "order", 95 | "operation": "view" 96 | } 97 | ], 98 | "status": "ok" 99 | } 100 | ``` 101 | 102 | #### Token Refresh 103 | 104 | ``` 105 | $ http POST localhost:3000/api/auth/refreshTokenCheck 'Cookie:refreshToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTY1NDU4MjAsInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20ifSwiaWF0IjoxNzE1OTQxMDIwfQ.4kcZ2GH7qJwDTCCneHUj8uwlcmnPD1blXRtgnTbOrb4' 106 | HTTP/1.1 200 OK 107 | Access-Control-Allow-Headers: Content-Type, Authorization 108 | Access-Control-Allow-Methods: GET, POST, OPTIONS 109 | Access-Control-Allow-Origin: * 110 | Connection: keep-alive 111 | Content-Length: 154 112 | Content-Type: application/json; charset=utf-8 113 | Date: Fri, 17 May 2024 10:20:40 GMT 114 | ETag: "9a-x4wAb7VqEzpy5Lw11nvelKU82kU" 115 | Keep-Alive: timeout=5 116 | Vary: Accept-Encoding 117 | 118 | { 119 | "status": "ok", 120 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTU5NDEyNDAsImlhdCI6MTcxNTk0MTI0MH0.Xobe4Bf4T9qQnudnrpln1nhbVMHpf60_1eSl9o76bC8" 121 | } 122 | ``` 123 | 124 | #### Session Check 125 | 126 | ``` 127 | % http POST localhost:3000/api/auth/check "authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mjc3OTk3ODgsInBheWxvYWQiOnsidXNlciI6ImFkbWluIn0sImlhdCI6MTYyNzc5NjE4OH0.vkZzymb3hyftl2pb75wuLKaavfnZV5ZlR88aISIQOBQ" 128 | HTTP/1.1 200 OK 129 | Connection: keep-alive 130 | Content-Length: 154 131 | Content-Type: application/json; charset=utf-8 132 | Date: Sun, 01 Aug 2021 05:37:18 GMT 133 | ETag: "9a-WG2wB4ewrnriUOAqysn9WZKtyC4" 134 | Keep-Alive: timeout=5 135 | Vary: Accept-Encoding 136 | 137 | { 138 | "status": "ok", 139 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mjc3OTk4MzgsImlhdCI6MTYyNzc5NjIzOH0.r7KYW3z8md7ZqN94TEuWRKoLRGB8Up6dAGkQrF7J9CE" 140 | } 141 | ``` 142 | 143 | #### Change Password 144 | 145 | ``` 146 | % http POST localhost:3000/api/password/change password=after 147 | HTTP/1.1 200 OK 148 | Connection: keep-alive 149 | Content-Length: 15 150 | Content-Type: application/json; charset=utf-8 151 | Date: Wed, 18 Aug 2021 05:47:58 GMT 152 | ETag: "f-VaSQ4oDUiZblZNAEkkN+sX+q3Sg" 153 | Keep-Alive: timeout=5 154 | Vary: Accept-Encoding 155 | 156 | { 157 | "status": "ok" 158 | } 159 | ``` 160 | 161 | #### Verify Code 162 | 163 | ``` 164 | % http POST localhost:3000/api/code/verify code=123456 165 | HTTP/1.1 200 OK 166 | Connection: keep-alive 167 | Content-Length: 15 168 | Content-Type: application/json; charset=utf-8 169 | Date: Wed, 18 Aug 2021 05:49:36 GMT 170 | ETag: "f-VaSQ4oDUiZblZNAEkkN+sX+q3Sg" 171 | Keep-Alive: timeout=5 172 | Vary: Accept-Encoding 173 | 174 | { 175 | "status": "ok" 176 | } 177 | ``` 178 | 179 | ### Product 180 | 181 | #### Get 182 | 183 | - find 184 | 185 | ``` 186 | % http 'localhost:3000/api/products/get?id=1' 187 | HTTP/1.1 200 OK 188 | Access-Control-Allow-Headers: Content-Type, Authorization 189 | Access-Control-Allow-Methods: GET, OPTIONS 190 | Access-Control-Allow-Origin: * 191 | Connection: keep-alive 192 | Content-Length: 102 193 | Content-Type: application/json; charset=utf-8 194 | Date: Thu, 16 May 2024 07:30:37 GMT 195 | ETag: "66-iuGdBZkk9ljc2lvh62laBFM7VG0" 196 | Keep-Alive: timeout=5 197 | Vary: Accept-Encoding 198 | 199 | { 200 | "description": "16oz package of fresh organic strawberries", 201 | "id": 1, 202 | "name": "Strawberries", 203 | "quantity": 1 204 | } 205 | ``` 206 | 207 | - findAll 208 | 209 | ``` 210 | % http 'localhost:3000/api/products?page=0&rows=5' 211 | HTTP/1.1 200 OK 212 | Connection: keep-alive 213 | Content-Length: 418 214 | Content-Type: application/json; charset=utf-8 215 | Date: Fri, 23 Jul 2021 06:28:56 GMT 216 | ETag: "1a2-U2ohMMGmi3qNYLxR2flXsEJkdWk" 217 | Keep-Alive: timeout=5 218 | Vary: Accept-Encoding 219 | 220 | { 221 | "count": 16, 222 | "data": [ 223 | { 224 | "description": "16oz package of fresh organic strawberries", 225 | "id": 1, 226 | "name": "Strawberries", 227 | "quantity": 1 228 | }, 229 | { 230 | "description": "Loaf of fresh sliced wheat bread", 231 | "id": 2, 232 | "name": "Sliced bread", 233 | "quantity": 2 234 | }, 235 | { 236 | "description": "Bag of 7 fresh McIntosh apples", 237 | "id": 3, 238 | "name": "Apples", 239 | "quantity": 3 240 | }, 241 | { 242 | "description": "no.4", 243 | "id": 4, 244 | "name": "Item4", 245 | "quantity": 4 246 | }, 247 | { 248 | "description": "no.5", 249 | "id": 5, 250 | "name": "Item5", 251 | "quantity": 5 252 | } 253 | ] 254 | } 255 | ``` 256 | 257 | #### Post 258 | 259 | ``` 260 | % http POST localhost:3000/api/products/post name=hoge description=hoge quantity=777 261 | HTTP/1.1 201 Created 262 | Connection: keep-alive 263 | Content-Length: 62 264 | Content-Type: application/json; charset=utf-8 265 | Date: Fri, 23 Jul 2021 06:26:47 GMT 266 | ETag: "3e-jwZIwKhCJX29WAxEMJMPwUd7Hgk" 267 | Keep-Alive: timeout=5 268 | Vary: Accept-Encoding 269 | 270 | { 271 | "description": "hoge", 272 | "id": 936, 273 | "name": "hoge", 274 | "quantity": "777" 275 | } 276 | ``` 277 | 278 | #### Put 279 | 280 | ``` 281 | % http PUT 'localhost:3000/api/products/put?id=4' name=hoge description=hoge quantity=777 282 | HTTP/1.1 200 OK 283 | Connection: keep-alive 284 | Content-Length: 60 285 | Content-Type: application/json; charset=utf-8 286 | Date: Fri, 23 Jul 2021 06:30:16 GMT 287 | ETag: "3c-n6NUU6qGGHwu3q1V68ShspQ8AVw" 288 | Keep-Alive: timeout=5 289 | Vary: Accept-Encoding 290 | 291 | { 292 | "description": "hoge", 293 | "id": 4, 294 | "name": "hoge", 295 | "quantity": "777" 296 | } 297 | ``` 298 | 299 | #### Delete 300 | 301 | ``` 302 | % http DELETE 'localhost:3000/api/products/delete?id=4' 303 | HTTP/1.1 200 OK 304 | Connection: keep-alive 305 | Content-Length: 2 306 | Content-Type: application/json; charset=utf-8 307 | Date: Fri, 23 Jul 2021 06:30:55 GMT 308 | ETag: "2-vyGp6PvFo4RvsFtPoIWeCReyIC8" 309 | Keep-Alive: timeout=5 310 | Vary: Accept-Encoding 311 | 312 | {} 313 | ``` 314 | -------------------------------------------------------------------------------- /components/atoms/button.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | type Map = { 4 | key: string 5 | class: string[] 6 | } 7 | 8 | const ColorSetting: Map[] = [ 9 | { 10 | key: 'primary', 11 | class: [ 12 | 'text-white', 13 | 'bg-indigo-600', 14 | 'hover:bg-indigo-700', 15 | 'focus:ring-indigo-500', 16 | 'border-transparent', 17 | 'primary-button', 18 | ], 19 | }, 20 | { 21 | key: 'default', 22 | class: [ 23 | 'text-gray-700', 24 | 'bg-white', 25 | 'hover:bg-gray-100', 26 | 'focus:ring-indigo-500', 27 | 'border-gray-300', 28 | 'default-button', 29 | ], 30 | }, 31 | { 32 | key: 'danger', 33 | class: [ 34 | 'text-white', 35 | 'bg-red-600', 36 | 'hover:bg-red-700', 37 | 'focus:ring-red-500', 38 | 'border-transparent', 39 | 'danger-button', 40 | ], 41 | }, 42 | ] 43 | 44 | const SizeSetting: Map[] = [ 45 | { key: 'small', class: ['py-2', 'px-4', 'text-sm'] }, 46 | ] 47 | 48 | export const Button = ({ 49 | children, 50 | color = 'default', 51 | size = 'small', 52 | fullWidth = false, 53 | disabled = false, 54 | classes = [], 55 | onClick, 56 | }: { 57 | children: React.ReactNode 58 | color?: 'default' | 'primary' | 'secondary' | 'danger' 59 | size?: 'large' | 'medium' | 'small' 60 | fullWidth?: boolean 61 | disabled?: boolean 62 | classes?: string[] 63 | onClick?: (event: any) => void 64 | }): JSX.Element => { 65 | const _color = _.head( 66 | ColorSetting.filter((map: Map) => map.key === color).map( 67 | (map: Map) => map.class 68 | ) 69 | ) 70 | const _size = _.head( 71 | SizeSetting.filter((map: Map) => map.key === size).map( 72 | (map: Map) => map.class 73 | ) 74 | ) 75 | const className = [ 76 | 'inline-flex', 77 | 'justify-center', 78 | 'rounded-md', 79 | 'border', 80 | 'focus:outline-none', 81 | 'focus:ring-2', 82 | 'focus:ring-offset-2', 83 | ..._color, 84 | ..._size, 85 | ...classes, 86 | ].join(' ') 87 | 88 | const handleSubmit = (event: any) => { 89 | if (onClick && !disabled) { 90 | onClick(event) 91 | } 92 | } 93 | 94 | return ( 95 | 103 | ) 104 | } 105 | 106 | export default Button 107 | -------------------------------------------------------------------------------- /components/atoms/form-error-message.tsx: -------------------------------------------------------------------------------- 1 | export const FormErrorMessage = ({ 2 | children, 3 | classes = ['mt-1'], 4 | }: { 5 | children: React.ReactNode 6 | classes?: string[] 7 | }): JSX.Element => { 8 | const className = ['text-red-500', 'text-xs', ...classes].join(' ') 9 | 10 | return

{children}

11 | } 12 | 13 | export default FormErrorMessage 14 | -------------------------------------------------------------------------------- /components/atoms/form-label.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from './typography' 2 | 3 | export const FormLabel = ({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }): JSX.Element => {children} 8 | 9 | export default FormLabel 10 | -------------------------------------------------------------------------------- /components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | import FormLabel from './form-label' 2 | import FormErrorMessage from './form-error-message' 3 | import Button from './button' 4 | import Link from './link' 5 | import Typography from './typography' 6 | import InputLabel from './input-label' 7 | 8 | export { FormLabel, FormErrorMessage, Button, Link, Typography, InputLabel } 9 | -------------------------------------------------------------------------------- /components/atoms/input-label.tsx: -------------------------------------------------------------------------------- 1 | export const InputLabel = ({ 2 | value, 3 | fullWidth = false, 4 | classes = [], 5 | }: { 6 | value: string | number 7 | fullWidth?: boolean 8 | classes?: string[] 9 | }): JSX.Element => { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export default InputLabel 23 | -------------------------------------------------------------------------------- /components/atoms/link.tsx: -------------------------------------------------------------------------------- 1 | export const Link = ({ 2 | children, 3 | href = '#', 4 | onClick, 5 | }: { 6 | children: React.ReactNode 7 | href?: string 8 | onClick?: (event: any) => void 9 | }): JSX.Element => { 10 | const handleClick = (event: any): void => { 11 | if (onClick) { 12 | onClick(event) 13 | } 14 | } 15 | 16 | return ( 17 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | export default Link 28 | -------------------------------------------------------------------------------- /components/atoms/text-field.tsx: -------------------------------------------------------------------------------- 1 | import { TextFieldType } from '../../data' 2 | import { useState } from 'react' 3 | 4 | export const TextField = ({ 5 | label, 6 | type, 7 | classes, 8 | value, 9 | error, 10 | helperText, 11 | onChange, 12 | }: { 13 | label: string 14 | type?: TextFieldType 15 | classes?: string[] 16 | value: string 17 | error: boolean 18 | helperText?: string 19 | onChange: (...args: any[]) => any 20 | }): JSX.Element => { 21 | const className = ['block', ...(classes ? classes : [])].join(' ') 22 | const [val, setValue] = useState(value) 23 | 24 | const handleChange = (event: any): void => { 25 | setValue(event.target.value) 26 | onChange(event) 27 | } 28 | 29 | return ( 30 | 40 | ) 41 | } 42 | 43 | export default TextField 44 | -------------------------------------------------------------------------------- /components/atoms/typography.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | type Map = { 4 | key: string 5 | class: string[] 6 | } 7 | 8 | const VariantSetting: Map[] = [ 9 | { key: 'h2', class: ['text-gray-900', 'text-5xl', 'font-semibold'] }, 10 | { key: 'h3', class: ['text-gray-900', 'text-4xl', 'font-semibold'] }, 11 | { key: 'h4', class: ['text-gray-900', 'text-2xl', 'font-semibold'] }, 12 | { key: 'h5', class: ['text-gray-900', 'text-xl', 'font-semibold'] }, 13 | { key: 'h6', class: ['text-gray-900', 'text-lg', 'font-semibold'] }, 14 | { key: 'subtitle1', class: ['prose', 'font-semibold'] }, 15 | { key: 'subtitle2', class: ['prose', 'prose-sm', 'font-semibold'] }, 16 | { key: 'body1', class: ['prose'] }, 17 | { key: 'body2', class: ['prose', 'prose-sm'] }, 18 | ] 19 | 20 | export const Typography = ({ 21 | children, 22 | variant = 'body1', 23 | classes = [], 24 | }: { 25 | children: React.ReactNode 26 | variant?: 27 | | 'h1' 28 | | 'h2' 29 | | 'h3' 30 | | 'h4' 31 | | 'h5' 32 | | 'h6' 33 | | 'subtitle1' 34 | | 'subtitle2' 35 | | 'body1' 36 | | 'body2' 37 | classes?: string[] 38 | }): JSX.Element => { 39 | const _classes = _.head( 40 | VariantSetting.filter((map: Map) => map.key === variant).map( 41 | (map: Map) => map.class 42 | ) 43 | ) 44 | const className = [..._classes, ...classes].join(' ') 45 | 46 | return ( 47 |
48 | {children} 49 |
50 | ) 51 | } 52 | 53 | export default Typography 54 | -------------------------------------------------------------------------------- /components/logo.tsx: -------------------------------------------------------------------------------- 1 | export const Logo = (): JSX.Element => ( 2 | 8 | 16 | 20 | 21 | ) 22 | 23 | export default Logo 24 | -------------------------------------------------------------------------------- /components/molecules/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thirosue/next-typescript-sample/9212790aca9fb7816782c6ac17166a105bfa7b29/components/molecules/.gitkeep -------------------------------------------------------------------------------- /components/molecules/dashboard-card.tsx: -------------------------------------------------------------------------------- 1 | export const DashboardCard = ({ 2 | children, 3 | count, 4 | label, 5 | }: { 6 | children: React.ReactNode 7 | count: number 8 | label: string 9 | }): JSX.Element => { 10 | return ( 11 | <> 12 |
13 | {children} 14 |
15 |

16 | {count.toLocaleString()} 17 |

18 |
{label}
19 |
20 |
21 | 22 | ) 23 | } 24 | 25 | export default DashboardCard 26 | -------------------------------------------------------------------------------- /components/molecules/navi-item.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | 3 | export const NaviItem = ({ 4 | children, 5 | link, 6 | label, 7 | active, 8 | toggle, 9 | }: { 10 | children: React.ReactNode 11 | label: string 12 | link: string 13 | active?: boolean 14 | toggle: () => void 15 | }): JSX.Element => { 16 | const router = useRouter() 17 | 18 | const handleClick = async (): Promise => { 19 | await router.push(link) 20 | toggle() 21 | } 22 | 23 | return ( 24 | 33 | {children} 34 | {label} 35 | 36 | ) 37 | } 38 | 39 | export default NaviItem 40 | -------------------------------------------------------------------------------- /components/molecules/pager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { PageItem } from '../../data/page-item' 3 | import Const from '../../const' 4 | 5 | const OmitLink = (): JSX.Element => ( 6 | 7 | ... 8 | 9 | ) 10 | 11 | const PageLink = ({ 12 | page, 13 | active, 14 | handleClick, 15 | }: { 16 | page: number 17 | active?: boolean 18 | handleClick: (page: number) => Promise 19 | }): JSX.Element => { 20 | return ( 21 | active && handleClick(page)} 24 | className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium page-link-${page} ${ 25 | active 26 | ? 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50' 27 | : 'z-10 bg-indigo-50 border-indigo-500 text-indigo-600 cursor-not-allowed' 28 | }`} 29 | > 30 | {page} 31 | 32 | ) 33 | } 34 | 35 | const NaviLink = ({ 36 | children, 37 | active, 38 | handleClick, 39 | }: { 40 | children: React.ReactNode 41 | active?: boolean 42 | handleClick: () => Promise 43 | }): JSX.Element => { 44 | return ( 45 | 52 | {children} 53 | 54 | ) 55 | } 56 | 57 | const PAGER_BUFFER = 3 58 | 59 | export const Pager = ({ 60 | pageItem, 61 | search, 62 | }: { 63 | pageItem: PageItem 64 | search: (page: number) => Promise 65 | }): JSX.Element => { 66 | const isFirstActive = pageItem.page !== 1 67 | const isLastActive = pageItem.page !== pageItem.totalPage 68 | 69 | const [pages, setPages] = useState([]) 70 | useEffect(() => { 71 | setPages([]) 72 | const { page, totalPage } = pageItem 73 | const from = 1 <= page - PAGER_BUFFER ? page - PAGER_BUFFER : 1 74 | const to = 75 | page + PAGER_BUFFER <= totalPage ? page + PAGER_BUFFER : totalPage 76 | for (let i = from; i <= to; i++) { 77 | setPages((prev) => 78 | [...prev, i].filter((page) => page !== 1 && page !== pageItem.totalPage) 79 | ) //1ページ目と最終ページは除く 80 | } 81 | }, [pageItem.page, pageItem.totalPage]) 82 | 83 | return ( 84 |
85 |
86 | isFirstActive && search(pageItem.page - 1)} 89 | > 90 | Previous 91 | 92 | isLastActive && search(pageItem.page + 1)} 95 | > 96 | Next 97 | 98 |
99 |
100 |
101 |

102 | Showing 103 | 104 | {Const.defaultPageValue.perPage * (pageItem.page - 1) + 1} 105 | 106 | to 107 | 108 | {Const.defaultPageValue.perPage} 109 | 110 | of 111 | {pageItem.totalCount} 112 | results 113 |

114 |
115 |
116 | 180 |
181 |
182 |
183 | ) 184 | } 185 | 186 | export default Pager 187 | -------------------------------------------------------------------------------- /components/molecules/product-row.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { Product } from '../../repository/product-repository' 3 | 4 | export const ProductRow = ({ product }: { product: Product }): JSX.Element => { 5 | const router = useRouter() 6 | 7 | return ( 8 | 9 | 10 |
11 |
12 | {product.name} 13 |
14 |
15 | 16 | 17 | 18 |
19 | {product.description} 20 |
21 | 22 | 23 | 24 | {product.quantity} 25 | 26 | 27 | 28 | 29 | Active 30 | 31 | 32 | 33 | 34 | router.push(`/product/${product.id}`)} 37 | className="text-indigo-600 hover:text-indigo-900" 38 | > 39 | Edit 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | export default ProductRow 47 | -------------------------------------------------------------------------------- /components/molecules/table-header.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react' 2 | import { TableHeaderItem } from '../../data/table-header-item' 3 | import { SortItem } from '../../data/sort-item' 4 | 5 | const Upicon = (): JSX.Element => ( 6 | 13 | 19 | 20 | ) 21 | 22 | const Downicon = (): JSX.Element => ( 23 | 30 | 36 | 37 | ) 38 | 39 | export const TableHeader = ({ 40 | headerItems, 41 | sortItem, 42 | setSortItem, 43 | search, 44 | }: { 45 | headerItems: TableHeaderItem[] 46 | sortItem: SortItem 47 | setSortItem: Dispatch> 48 | search: (page: number, sortItem: SortItem) => Promise 49 | }): JSX.Element => { 50 | const sort = async (item: SortItem): Promise => { 51 | setSortItem(item) 52 | await search(1, { 53 | key: item.key, 54 | order: sortItem.order === 'asc' ? 'desc' : 'asc', 55 | }) 56 | } 57 | 58 | return ( 59 | 60 | {headerItems.map((item, index) => ( 61 | 65 | {item.sortable ? ( 66 | 69 | sort({ 70 | key: item.key, 71 | order: sortItem.order === 'asc' ? 'desc' : 'asc', 72 | }) 73 | } 74 | className="flex justify-start" 75 | > 76 |
{item.label}
77 |
78 | {sortItem.key === item.key && sortItem.order === 'asc' ? ( 79 | 80 | ) : ( 81 | 82 | )} 83 |
84 |
85 | ) : ( 86 | <>{item.label} 87 | )} 88 | 89 | ))} 90 | 91 | ) 92 | } 93 | 94 | export default TableHeader 95 | -------------------------------------------------------------------------------- /components/organisms/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thirosue/next-typescript-sample/9212790aca9fb7816782c6ac17166a105bfa7b29/components/organisms/.gitkeep -------------------------------------------------------------------------------- /components/organisms/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import DashboardCard from '../molecules/dashboard-card' 2 | 3 | export const Dashboard = (): JSX.Element => { 4 | return ( 5 |
6 |
7 | 8 |
9 | 16 | 22 | 23 |
24 |
25 |
26 | 27 |
28 | 29 |
30 | 37 | 43 | 49 | 50 |
51 |
52 |
53 | 54 |
55 | 56 |
57 | 64 | 70 | 71 |
72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | export default Dashboard 79 | -------------------------------------------------------------------------------- /components/organisms/searchable-table.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | import TableHeader from '../molecules/table-header' 3 | import Pager from '../molecules/pager' 4 | import { TableHeaderItem } from '../../data/table-header-item' 5 | import { SortItem } from '../../data/sort-item' 6 | import { PageItem } from '../../data/page-item' 7 | import { UseQueryResult } from '@tanstack/react-query' 8 | import { AxiosResponse } from 'axios' 9 | 10 | export const SearchableTable = ({ 11 | children, 12 | headerItems, 13 | pageItem, 14 | sortItem, 15 | setSortItem, 16 | search, 17 | queryResult, 18 | }: { 19 | children: React.ReactNode 20 | headerItems: TableHeaderItem[] 21 | pageItem: PageItem 22 | sortItem: SortItem 23 | setSortItem: Dispatch> 24 | search: (page: number, sortItem?: SortItem) => Promise 25 | queryResult: UseQueryResult 26 | }): JSX.Element => { 27 | return ( 28 |
29 |
30 |
31 | 32 | 33 | 39 | 40 | 41 | {children} 42 |
43 | {queryResult.isFetched && ( 44 | 45 | )} 46 |
47 |
48 |
49 | ) 50 | } 51 | 52 | export default SearchableTable 53 | -------------------------------------------------------------------------------- /components/page/confirm-code-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form' 2 | import { yupResolver } from '@hookform/resolvers/yup' 3 | import * as yup from 'yup' 4 | import { toast } from 'react-toastify' 5 | import { AxiosPromise } from 'axios' 6 | import { useMutation } from '@tanstack/react-query' 7 | import { Progress } from '../../components/progress' 8 | import { FormLabel, FormErrorMessage } from '../atoms' 9 | import { Confirm } from '../template' 10 | import { TextFieldType } from '../../data' 11 | import { 12 | VerifyCodeRequest, 13 | BaseResponse, 14 | AuthRepository, 15 | } from '../../repository/auth-repository' 16 | 17 | const captains = console 18 | 19 | type FormValues = { 20 | code: string 21 | } 22 | 23 | const schema = yup.object().shape({ 24 | code: yup.number().required('入力してください'), 25 | }) 26 | 27 | export const ConfirmCodeModal = ({ 28 | onSubmit, 29 | onClose, 30 | onCancel, 31 | }: { 32 | onSubmit: () => void 33 | onClose: (event: any) => void 34 | onCancel: (event: any) => void 35 | }): JSX.Element => { 36 | const { 37 | register, 38 | handleSubmit, 39 | formState: { errors }, 40 | } = useForm({ 41 | resolver: yupResolver(schema), 42 | }) 43 | const mutation = useMutation( 44 | (req: VerifyCodeRequest): AxiosPromise => 45 | AuthRepository.verifyCode(req) 46 | ) 47 | 48 | const doSubmit = (data: FormValues): void => { 49 | captains.log(data) 50 | const request: VerifyCodeRequest = { 51 | code: data.code, 52 | } 53 | mutation.mutate(request, { 54 | onSuccess: () => { 55 | onClose(null) 56 | onSubmit() 57 | toast.success('パスワードを更新しました') 58 | }, 59 | }) 60 | } 61 | 62 | return ( 63 | <> 64 | 65 | 72 |
73 | 87 |
88 |
89 | 90 | ) 91 | } 92 | 93 | export default ConfirmCodeModal 94 | -------------------------------------------------------------------------------- /components/page/index-page.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useRouter } from 'next/router' 3 | import { ParsedUrlQuery } from 'querystring' 4 | import { useQuery } from '@tanstack/react-query' 5 | import { AxiosPromise } from 'axios' 6 | import { 7 | Product, 8 | ProductResponse, 9 | ProductRepository, 10 | } from '../../repository/product-repository' 11 | import { Progress } from '../../components/progress' 12 | import ProductRow from '../molecules/product-row' 13 | import { PageItem } from '../../data/page-item' 14 | import { TableHeaderItem } from '../../data/table-header-item' 15 | import { SortItem } from '../../data/sort-item' 16 | import Dashboard from '../organisms/dashboard' 17 | import SearchableTable from '../organisms/searchable-table' 18 | import Const from '../../const' 19 | 20 | type IndexQuery = ParsedUrlQuery & { 21 | keyword: string 22 | page: string 23 | order: string 24 | orderBy: string 25 | } 26 | 27 | const headerItems: TableHeaderItem[] = [ 28 | { key: 'name', label: 'Name', sortable: true }, 29 | { key: 'description', label: 'Description', sortable: true }, 30 | { label: 'Quantity' }, 31 | { label: 'Status' }, 32 | { label: '' }, 33 | ] 34 | 35 | export const IndexPage = (): JSX.Element => { 36 | const router = useRouter() 37 | const [keyword, setKeyword] = useState('') 38 | const [pageItem, setPageItem] = useState({ 39 | ...Const.defaultPageValue, 40 | }) 41 | const [sortItem, setSortItem] = useState({ 42 | ...Const.sortDefaultValue, 43 | }) 44 | 45 | const products = useQuery( 46 | ['products', [keyword, pageItem, sortItem]], 47 | (): AxiosPromise => 48 | ProductRepository.findAll({ 49 | name: keyword, 50 | page: pageItem.page - 1, 51 | order: sortItem.order, 52 | orderBy: sortItem.key, 53 | rows: Const.defaultPageValue.perPage, 54 | }) 55 | ) 56 | 57 | useEffect(() => { 58 | const { keyword, page } = router.query as IndexQuery 59 | setKeyword(keyword) 60 | setPageItem({ 61 | ...pageItem, 62 | page: page ? Number(page) : 1, 63 | }) 64 | }, [router.query]) 65 | 66 | useEffect(() => { 67 | if (products.isFetched) { 68 | setPageItem({ 69 | ...pageItem, 70 | totalCount: products.data.data.count, 71 | totalPage: Math.ceil(products.data.data.count / pageItem.perPage), 72 | }) 73 | } 74 | }, [products.isFetched]) 75 | 76 | const pushState = async (page: number, sort?: SortItem): Promise => { 77 | await router.push({ 78 | query: { 79 | keyword, 80 | page, 81 | orderBy: sort?.key ?? sortItem.key, 82 | order: sort?.order ?? sortItem.order, 83 | }, 84 | }) 85 | } 86 | 87 | return ( 88 | <> 89 | {/* コンテンツ */} 90 | 91 |
92 |

Dashboard

93 | 94 |
95 | 96 |
97 | 98 |
99 | 100 | 108 | 109 | {products.isFetched && 110 | products.data.data.data.map((product: Product, index: number) => ( 111 | 112 | ))} 113 | 114 | 115 |
116 | 117 | ) 118 | } 119 | 120 | export default IndexPage 121 | -------------------------------------------------------------------------------- /components/page/login-page.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react' 2 | import Head from 'next/head' 3 | import { toast } from 'react-toastify' 4 | import { useRouter } from 'next/router' 5 | import { useForm } from 'react-hook-form' 6 | import { yupResolver } from '@hookform/resolvers/yup' 7 | import * as yup from 'yup' 8 | import { AxiosPromise, AxiosResponse, AxiosError } from 'axios' 9 | import { useMutation } from '@tanstack/react-query' 10 | import { destroyCookie, setCookie, parseCookies } from 'nookies' 11 | import isAfter from 'date-fns/isAfter' 12 | import jwt from 'jsonwebtoken' 13 | import useConfirm from '../../hooks/useConfirm' 14 | import GlobalContext from '../../context/global-context' 15 | import Logo from '../../components/logo' 16 | import { 17 | FormLabel, 18 | FormErrorMessage, 19 | Button, 20 | Link, 21 | Typography, 22 | } from '../../components/atoms' 23 | import { Progress } from '../../components/progress' 24 | import { TextFieldType } from '../../data' 25 | import { 26 | AuthRequest, 27 | AuthResponse, 28 | AuthRepository, 29 | } from '../../repository/auth-repository' 30 | 31 | const captains = console 32 | 33 | type FormValues = { 34 | email: string 35 | password: string 36 | rememberMe: boolean 37 | } 38 | 39 | const schema = yup.object().shape({ 40 | email: yup 41 | .string() 42 | .required('入力してください') 43 | .email('メールアドレスを入力してください'), 44 | password: yup 45 | .string() 46 | .required('入力してください') 47 | .matches( 48 | /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, 49 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 50 | ), 51 | }) 52 | 53 | export const LoginPage = ({ 54 | passwordModalOpen, 55 | }: { 56 | passwordModalOpen: () => void 57 | }): JSX.Element => { 58 | const router = useRouter() 59 | const mutation = useMutation( 60 | (req: AuthRequest): AxiosPromise => AuthRepository.signIn(req) 61 | ) 62 | const context = useContext(GlobalContext) 63 | const confirm = useConfirm() 64 | 65 | const { 66 | register, 67 | handleSubmit, 68 | formState: { errors }, 69 | } = useForm({ 70 | resolver: yupResolver(schema), 71 | defaultValues: { 72 | // email: 'test@test.com', 73 | // password: 'Password1?', 74 | rememberMe: Boolean(parseCookies(null).rememberMe), 75 | }, 76 | }) 77 | 78 | useEffect(() => { 79 | const rememberMe = Boolean(parseCookies(null).rememberMe) 80 | const { jwtToken } = context.state.session 81 | const decodedToken = jwt.decode(jwtToken, { 82 | complete: true, 83 | }) 84 | const exp = new Date((decodedToken?.payload.exp * 1000) as number) 85 | if (rememberMe && isAfter(exp, new Date())) { 86 | router 87 | .push('/') 88 | .then(() => setTimeout(() => toast('自動ログインしました'), 100)) 89 | } else { 90 | context.clearState() 91 | } 92 | }, [parseCookies(null).rememberMe]) 93 | 94 | const rememberMe = async (event: any): Promise => { 95 | if (event.target.checked) { 96 | const cancel = await confirm({ 97 | title: '自動ログイン設定', 98 | icon: 'info', 99 | description: '自動ログインを有効にしますか?', 100 | }) 101 | .then(() => { 102 | setCookie(null, 'rememberMe', 'true') 103 | }) 104 | .catch(() => { 105 | return true 106 | }) 107 | if (cancel) { 108 | event.target.checked = false 109 | event.preventDefault() 110 | } 111 | } else { 112 | const cancel = await confirm({ 113 | title: '自動ログイン設定', 114 | icon: 'info', 115 | description: '自動ログインを無効にしますか?', 116 | }) 117 | .then(() => { 118 | destroyCookie(null, 'rememberMe') 119 | }) 120 | .catch(() => { 121 | return true 122 | }) 123 | if (cancel) { 124 | event.target.checked = true 125 | event.preventDefault() 126 | } 127 | } 128 | } 129 | 130 | const doSubmit = (data: FormValues): void => { 131 | captains.log(data) 132 | const authRequest: AuthRequest = { 133 | id: data.email, 134 | password: data.password, 135 | } 136 | mutation.mutate(authRequest, { 137 | onSuccess: async (res: AxiosResponse) => { 138 | context.updateState({ 139 | session: { 140 | username: data.email, 141 | jwtToken: res.data.token, 142 | sub: 'sub', 143 | }, 144 | }) 145 | await router.push('/') 146 | setTimeout(() => toast('ログインしました'), 100) // display toast after screen transition 147 | }, 148 | onError: async (error: AxiosError) => { 149 | if (error.response.status === 401) { 150 | confirm({ 151 | title: '認証エラー', 152 | alert: true, 153 | icon: 'warn', 154 | description: 'Emailもしくはパスワードが誤っています', 155 | }) 156 | } else { 157 | confirm({ 158 | title: 'システムエラー', 159 | alert: true, 160 | icon: 'alert', 161 | description: 162 | 'エラーが発生しました。しばらくしてからもう一度お試しください。', 163 | }) 164 | } 165 | }, 166 | }) 167 | } 168 | 169 | return ( 170 | <> 171 | 172 | 173 | 177 | 178 | 179 | 180 |
181 |
182 |
183 | 184 | Dashboard 185 |
186 | 187 |
188 | 202 | 203 | 219 | 220 |
221 |
222 | 234 |
235 | 236 |
237 | Forgot your password? 238 |
239 |
240 | 241 |
242 | 249 |
250 |
251 |
252 |
253 | 254 | ) 255 | } 256 | 257 | export default LoginPage 258 | -------------------------------------------------------------------------------- /components/page/password-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form' 2 | import { yupResolver } from '@hookform/resolvers/yup' 3 | import * as yup from 'yup' 4 | import { AxiosPromise } from 'axios' 5 | import { useMutation } from '@tanstack/react-query' 6 | import { Progress } from '../../components/progress' 7 | import { FormLabel, FormErrorMessage } from '../atoms' 8 | import { Confirm } from '../template' 9 | import { TextFieldType } from '../../data' 10 | import { 11 | ChangePasswordRequest, 12 | BaseResponse, 13 | AuthRepository, 14 | } from '../../repository/auth-repository' 15 | 16 | const captains = console 17 | 18 | type FormValues = { 19 | password: string 20 | confirmPassword: string 21 | } 22 | 23 | const schema = yup.object().shape({ 24 | password: yup 25 | .string() 26 | .required('入力してください') 27 | .matches( 28 | /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, 29 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 30 | ), 31 | confirmPassword: yup 32 | .string() 33 | .required('入力してください') 34 | .matches( 35 | /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, 36 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 37 | ) 38 | .oneOf([yup.ref('password'), null], '確認用パスワードが一致していません'), 39 | }) 40 | 41 | export const PasswordDialog = ({ 42 | onSubmit, 43 | onClose, 44 | onCancel, 45 | }: { 46 | onSubmit: () => void 47 | onClose: (event: any) => void 48 | onCancel: (event: any) => void 49 | }): JSX.Element => { 50 | const { 51 | register, 52 | handleSubmit, 53 | formState: { errors }, 54 | } = useForm({ 55 | resolver: yupResolver(schema), 56 | defaultValues: { 57 | password: 'Password1?', 58 | confirmPassword: 'Password1?', 59 | }, 60 | }) 61 | const mutation = useMutation( 62 | (req: ChangePasswordRequest): AxiosPromise => 63 | AuthRepository.changePassword(req) 64 | ) 65 | 66 | const doSubmit = (data: FormValues): void => { 67 | captains.log(data) 68 | const request: ChangePasswordRequest = { 69 | password: data.password, 70 | } 71 | mutation.mutate(request, { 72 | onSuccess: () => { 73 | onClose(null) 74 | onSubmit() 75 | }, 76 | }) 77 | } 78 | 79 | return ( 80 | <> 81 | 82 | 89 |
90 | 102 | 103 | 117 |
118 |
119 | 120 | ) 121 | } 122 | 123 | export default PasswordDialog 124 | -------------------------------------------------------------------------------- /components/progress.tsx: -------------------------------------------------------------------------------- 1 | export const Progress = ({ 2 | processing, 3 | }: { 4 | processing: boolean 5 | }): JSX.Element => { 6 | return ( 7 | <> 8 | {!!processing && ( 9 | <> 10 |
11 | 18 | 19 | 20 | 21 | 22 | 30 | 31 | 32 | 33 | 34 |

35 | Loading... 36 |

37 |
38 | 39 | )} 40 | 41 | ) 42 | } 43 | 44 | export default Progress 45 | -------------------------------------------------------------------------------- /components/template/confirm.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import Typography from '../atoms/typography' 3 | import Button from '../atoms/button' 4 | import Link from '../atoms/link' 5 | 6 | type Map = { 7 | key: string 8 | element: React.ReactNode 9 | } 10 | 11 | const IconMapping: Map[] = [ 12 | { 13 | key: 'info', 14 | element: ( 15 | <> 16 |
17 | 23 | 28 | 29 |
30 | 31 | ), 32 | }, 33 | { 34 | key: 'warn', 35 | element: ( 36 | <> 37 |
38 | 44 | 49 | 50 |
51 | 52 | ), 53 | }, 54 | { 55 | key: 'alert', 56 | element: ( 57 | <> 58 |
59 | 65 | 70 | 71 |
72 | 73 | ), 74 | }, 75 | ] 76 | 77 | export const Confirm = ({ 78 | title, 79 | children, 80 | onSubmit, 81 | confirmationText = 'OK', 82 | onClose, 83 | cancellationText = 'Cancel', 84 | onCancel, 85 | icon, 86 | alert = false, 87 | processing, 88 | }: { 89 | title: string 90 | children: React.ReactNode 91 | onSubmit: (event: any) => void 92 | confirmationText?: string 93 | onClose: (event: any) => void 94 | cancellationText?: string 95 | onCancel: (event: any) => void 96 | icon?: 'info' | 'warn' | 'alert' 97 | alert?: boolean 98 | processing?: boolean 99 | }): JSX.Element => { 100 | const DialogIcon = () => 101 | _.head( 102 | IconMapping.filter((map: Map) => map.key === icon).map( 103 | (map: Map) => map.element 104 | ) 105 | ) 106 | 107 | return ( 108 |
114 |
115 | 120 | 126 |
127 |
128 |
129 |
130 |
131 | {icon && DialogIcon()} 132 |
133 | {title && {title}} 134 |
135 |
136 |
137 | 138 | 145 | 151 | 152 | 153 |
154 |
155 |
{children}
156 |
157 |
158 |
159 | 168 | {!alert && ( 169 | <> 170 | 183 | 184 | )} 185 |
186 |
187 |
188 |
189 | ) 190 | } 191 | 192 | export default Confirm 193 | -------------------------------------------------------------------------------- /components/template/dashboard-layout.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { ToastContainer } from 'react-toastify' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 5 | import GlobalStateProvider from '../../context/global-state-provider' 6 | import ConfirmProvider from '../../context/confirm-provider' 7 | import Seo from './seo' 8 | import Header from './header' 9 | import SideBar from './sidebar' 10 | 11 | const queryClient = new QueryClient() 12 | 13 | export const DashboardLayout = ({ 14 | children, 15 | title, 16 | }: { 17 | children: React.ReactNode 18 | title: string 19 | }): JSX.Element => { 20 | const [sidebarOpen, setSidebarOpen] = useState(false) 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 |
29 | setSidebarOpen(false)} 32 | /> 33 |
34 |
setSidebarOpen(true)} /> 35 |
36 | {children} 37 |
38 |
39 |
40 |
41 | {process.env.NODE_ENV === 'development' && ( 42 | 43 | )} 44 |
45 |
46 | 57 | 58 | ) 59 | } 60 | 61 | export default DashboardLayout 62 | -------------------------------------------------------------------------------- /components/template/header.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { toast } from 'react-toastify' 3 | import { useForm } from 'react-hook-form' 4 | import { useRouter } from 'next/router' 5 | import { destroyCookie } from 'nookies' 6 | import { AxiosPromise } from 'axios' 7 | import { useMutation } from '@tanstack/react-query' 8 | import GlobalContext from '../../context/global-context' 9 | import { BaseResponse, AuthRepository } from '../../repository/auth-repository' 10 | 11 | type FormValues = { 12 | keyword: string 13 | } 14 | 15 | export const Header = ({ toggle }: { toggle: () => void }): JSX.Element => { 16 | const router = useRouter() 17 | const context = useContext(GlobalContext) 18 | 19 | const mutation = useMutation( 20 | (): AxiosPromise => AuthRepository.signOut() 21 | ) 22 | 23 | const { register, handleSubmit } = useForm({ 24 | defaultValues: { 25 | keyword: router.query.keyword as string, 26 | }, 27 | }) 28 | 29 | const doSubmit = async (data: FormValues): Promise => { 30 | await router.push({ 31 | query: { keyword: data.keyword }, 32 | }) 33 | } 34 | 35 | const signOut = (): void => { 36 | mutation.mutate(null, { 37 | onSuccess: async () => { 38 | context.clearState() 39 | await router.push('/login') 40 | destroyCookie(null, 'state') 41 | setTimeout(() => toast.dark('ログアウトしました'), 100) // display toast after screen transition 42 | }, 43 | }) 44 | } 45 | 46 | return ( 47 |
48 |
49 | 68 |
69 |
70 | 88 | 94 |
95 |
96 |
97 |
98 |
99 | 118 |
119 |
120 |
121 | ) 122 | } 123 | 124 | export default Header 125 | -------------------------------------------------------------------------------- /components/template/index.ts: -------------------------------------------------------------------------------- 1 | import SimpleLayout from './simple-layout' 2 | import DashboardLayout from './dashboard-layout' 3 | import Confirm from './confirm' 4 | 5 | export { SimpleLayout, DashboardLayout, Confirm } 6 | -------------------------------------------------------------------------------- /components/template/seo.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | export const siteName = 'Sample' 4 | 5 | export const Seo = ({ title }: { title: string }): JSX.Element => { 6 | const description = 'Create a Next.js sample app powered by Vercel.' 7 | const url = 'https://next-typescript-sample-mu.vercel.app/' 8 | const imageUrl = 'https://avatars.githubusercontent.com/u/14899056?v=4' 9 | 10 | return ( 11 | 12 | 13 | {siteName} - {title} 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default Seo 35 | -------------------------------------------------------------------------------- /components/template/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useRouter } from 'next/router' 3 | import NaviItem from '../molecules/navi-item' 4 | 5 | type Item = { 6 | label: string 7 | link: string 8 | icon: React.ReactNode 9 | } 10 | 11 | const NaviItems: Item[] = [ 12 | { 13 | link: '/', 14 | label: 'Dashboard', 15 | icon: ( 16 | 23 | 29 | 35 | 36 | ), 37 | }, 38 | { 39 | link: '/ui-elements', 40 | label: 'UI Elements', 41 | icon: ( 42 | 49 | 55 | 56 | ), 57 | }, 58 | { 59 | link: '/tables', 60 | label: 'Tables', 61 | icon: ( 62 | 69 | 75 | 76 | ), 77 | }, 78 | { 79 | link: '/forms', 80 | label: 'Forms', 81 | icon: ( 82 | 89 | 95 | 96 | ), 97 | }, 98 | ] 99 | 100 | export const Slidebar = ({ 101 | sidebarOpen, 102 | toggle, 103 | }: { 104 | sidebarOpen: boolean 105 | toggle: () => void 106 | }): JSX.Element => { 107 | const router = useRouter() 108 | const [active, setActive] = useState( 109 | Array(NaviItems.length).fill(false) 110 | ) 111 | 112 | useEffect(() => { 113 | const arr = NaviItems.map((item: Item) => item.link === router.pathname) 114 | setActive(arr) 115 | }, [router.pathname]) 116 | 117 | return ( 118 | <> 119 |
125 |
130 |
131 |
132 | 138 | 146 | 150 | 151 | 152 | Dashboard 153 | 154 |
155 |
156 | 157 | 170 |
171 | 172 | ) 173 | } 174 | 175 | export default Slidebar 176 | -------------------------------------------------------------------------------- /components/template/simple-layout.tsx: -------------------------------------------------------------------------------- 1 | import { ToastContainer } from 'react-toastify' 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 3 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 4 | import GlobalStateProvider from '../../context/global-state-provider' 5 | import ConfirmProvider from '../../context/confirm-provider' 6 | import Seo from './seo' 7 | 8 | const queryClient = new QueryClient() 9 | 10 | export const SimpleLayout = ({ 11 | children, 12 | title, 13 | }: { 14 | children: React.ReactNode 15 | title: string 16 | }): JSX.Element => { 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | {process.env.NODE_ENV === 'development' && ( 28 | 29 | )} 30 | 31 | 42 | 43 | ) 44 | } 45 | 46 | export default SimpleLayout 47 | -------------------------------------------------------------------------------- /const/index.ts: -------------------------------------------------------------------------------- 1 | const Const = { 2 | SessionRetentionPeriod: 3650, 3 | defaultPageValue: { 4 | page: 1, 5 | totalCount: 0, 6 | perPage: 2, 7 | }, 8 | sortDefaultValue: { 9 | key: '', 10 | order: '' as const, 11 | }, 12 | } 13 | 14 | export default Const 15 | -------------------------------------------------------------------------------- /context/confirm-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { DialogOptions } from '../data/dialog-options' 3 | 4 | export default createContext( 5 | {} as { 6 | confirm: (options: DialogOptions) => Promise 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /context/confirm-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react' 2 | import { DialogOptions } from '../data/dialog-options' 3 | import ConfirmContext from './confirm-context' 4 | import Confirm from '../components/template/confirm' 5 | 6 | const DEFAULT_OPTIONS: DialogOptions = { 7 | html: false, 8 | alert: false, 9 | title: '', 10 | description: '', 11 | confirmationText: 'Ok', 12 | cancellationText: 'Cancel', 13 | } 14 | 15 | const buildOptions = (options: DialogOptions): DialogOptions => { 16 | return { 17 | ...DEFAULT_OPTIONS, 18 | ...options, 19 | } 20 | } 21 | 22 | export const ConfirmProvider = ({ 23 | children, 24 | }: { 25 | children: React.ReactNode 26 | }): JSX.Element => { 27 | const [options, setOptions] = useState({ ...DEFAULT_OPTIONS }) 28 | const [resolveReject, setResolveReject] = useState([]) 29 | const [resolve, reject] = resolveReject 30 | 31 | const confirm = useCallback((options: DialogOptions): Promise => { 32 | return new Promise((resolve, reject) => { 33 | setOptions(buildOptions(options)) 34 | setResolveReject([resolve, reject]) 35 | }) 36 | }, []) 37 | 38 | const handleClose = useCallback(() => { 39 | setResolveReject([]) 40 | }, []) 41 | 42 | const handleCancel = useCallback(() => { 43 | reject() 44 | handleClose() 45 | }, [reject, handleClose]) 46 | 47 | const handleConfirm = useCallback(() => { 48 | resolve() 49 | handleClose() 50 | }, [resolve, handleClose]) 51 | 52 | return ( 53 | <> 54 | {resolveReject.length === 2 && ( 55 | <> 56 | 62 | {options.html ? ( 63 | options.description 64 | ) : ( 65 |

66 | {options.description} 67 |

68 | )} 69 |
70 | 71 | )} 72 | 73 | {children} 74 | 75 | 76 | ) 77 | } 78 | 79 | export default ConfirmProvider 80 | -------------------------------------------------------------------------------- /context/global-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { GlobalState } from '../data/global-state' 3 | 4 | export default createContext( 5 | {} as { 6 | state: GlobalState 7 | updateState: (value: GlobalState) => void 8 | renewToken: (token: string) => void 9 | clearState: () => void 10 | isSignin: () => boolean 11 | } 12 | ) 13 | -------------------------------------------------------------------------------- /context/global-state-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import _ from 'lodash' 3 | import { useRouter } from 'next/router' 4 | import { useMutation } from '@tanstack/react-query' 5 | import { AxiosPromise, AxiosResponse } from 'axios' 6 | import { parseCookies, setCookie } from 'nookies' 7 | import GlobalContext from './global-context' 8 | import { GlobalState } from '../data/global-state' 9 | import Const from '../const' 10 | import { 11 | CheckSessionRequest, 12 | AuthResponse, 13 | AuthRepository, 14 | } from '../repository/auth-repository' 15 | 16 | const captains = console 17 | 18 | const INIT_STATE = { 19 | session: { 20 | username: undefined, 21 | sub: undefined, 22 | jwtToken: undefined, 23 | email_verified: false, 24 | }, 25 | } 26 | 27 | const initState = (): GlobalState => { 28 | const cookie = parseCookies(null) 29 | let state = cookie.state 30 | ? (_.attempt(JSON.parse.bind(null, cookie.state)) as GlobalState) 31 | : { ...INIT_STATE } 32 | 33 | captains.log(state) 34 | if (_.isError(state) || !state) { 35 | state = { ...INIT_STATE } 36 | } 37 | return { 38 | ...state, 39 | } 40 | } 41 | 42 | const GlobalStateProvider = ({ 43 | children, 44 | }: { 45 | children: React.ReactNode 46 | }): JSX.Element => { 47 | const router = useRouter() 48 | const [state, setState] = useState(initState) 49 | 50 | const mutation = useMutation( 51 | (req: CheckSessionRequest): AxiosPromise => 52 | AuthRepository.checkSession(req) 53 | ) 54 | 55 | const clearState = (): void => { 56 | updateState({ ...INIT_STATE }) 57 | } 58 | 59 | const updateState = (value: GlobalState): void => { 60 | setState({ ...state, ...value }) 61 | setCookie(null, 'state', JSON.stringify({ ...state, ...value }), { 62 | // 60秒 * 60 * 24 * 3650 日間保存 63 | maxAge: 24 * 60 * 60 * Const.SessionRetentionPeriod, 64 | secure: true, 65 | }) 66 | } 67 | 68 | const renewToken = (token: string): void => { 69 | updateState({ 70 | ...state, 71 | ...{ session: { jwtToken: token, sub: state.session.sub } }, 72 | }) 73 | } 74 | 75 | const isSignin = () => !!state.session.sub 76 | 77 | const global = { 78 | state, 79 | updateState, 80 | renewToken, 81 | clearState, 82 | isSignin, 83 | } 84 | 85 | useEffect(() => { 86 | async function checkLogin() { 87 | if (state.session.jwtToken) { 88 | const req: CheckSessionRequest = { 89 | jwt: state.session.jwtToken, 90 | } 91 | 92 | mutation.mutate(req, { 93 | onSuccess: (res: AxiosResponse) => { 94 | captains.log('onSuccess renewToken', res) 95 | renewToken(res.data.token) 96 | }, 97 | onError: () => { 98 | clearState() 99 | router.push('/login') 100 | }, 101 | }) 102 | } else { 103 | clearState() 104 | router.push('/login') 105 | } 106 | } 107 | 108 | checkLogin() 109 | }, []) 110 | 111 | return ( 112 | {children} 113 | ) 114 | } 115 | 116 | export default GlobalStateProvider 117 | -------------------------------------------------------------------------------- /data/dialog-options.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type DialogOptions = { 4 | html?: boolean 5 | alert?: boolean 6 | icon?: 'info' | 'warn' | 'alert' 7 | title: string 8 | description: React.ReactNode 9 | confirmationText?: string 10 | cancellationText?: string 11 | } 12 | -------------------------------------------------------------------------------- /data/global-state.ts: -------------------------------------------------------------------------------- 1 | import { Session } from './session' 2 | 3 | export type GlobalState = { 4 | session: Session 5 | } 6 | -------------------------------------------------------------------------------- /data/index.ts: -------------------------------------------------------------------------------- 1 | import { TextFieldType } from './text-field-type' 2 | 3 | export { TextFieldType } 4 | -------------------------------------------------------------------------------- /data/page-item.ts: -------------------------------------------------------------------------------- 1 | export type PageItem = { 2 | page: number 3 | totalPage?: number 4 | totalCount: number 5 | perPage: number 6 | } 7 | -------------------------------------------------------------------------------- /data/session.ts: -------------------------------------------------------------------------------- 1 | export type Session = { 2 | username?: string 3 | sub: string 4 | jwtToken: string 5 | email_verified?: boolean 6 | } 7 | -------------------------------------------------------------------------------- /data/sort-item.ts: -------------------------------------------------------------------------------- 1 | export type SortItem = { 2 | key: string 3 | order: '' | 'asc' | 'desc' 4 | } 5 | -------------------------------------------------------------------------------- /data/table-header-item.ts: -------------------------------------------------------------------------------- 1 | export type TableHeaderItem = { 2 | key?: string 3 | label: string 4 | sortable?: boolean 5 | onClick?: () => Promise 6 | } 7 | -------------------------------------------------------------------------------- /data/text-field-type.ts: -------------------------------------------------------------------------------- 1 | export enum TextFieldType { 2 | Text = 'text', 3 | Number = 'number', 4 | Email = 'email', 5 | Password = 'password', 6 | Tel = 'tel', 7 | Url = 'url', 8 | } 9 | -------------------------------------------------------------------------------- /filters/addFilters.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext } from 'next' 2 | import { ParsedUrlQuery } from 'node:querystring' 3 | import { checkAutoLogin } from './checkAutoLogin' 4 | import { checkSession } from './checkSession' 5 | 6 | export const addFilters = ( 7 | f: (ctx: GetServerSidePropsContext) => any 8 | ): any => checkAutoLogin(checkSession(f)) 9 | -------------------------------------------------------------------------------- /filters/checkAutoLogin.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext } from 'next' 2 | import { NextApiRequestCookies } from 'next/dist/server/api-utils' 3 | import { ParsedUrlQuery } from 'node:querystring' 4 | 5 | type SessionCookie = NextApiRequestCookies & { 6 | state?: string 7 | rememberMe?: string 8 | } 9 | 10 | export const checkAutoLogin = 11 | (f: (ctx: GetServerSidePropsContext) => any): any => 12 | async (ctx: GetServerSidePropsContext) => { 13 | const cookie = ctx.req.cookies as SessionCookie 14 | const rememberMe = Boolean(cookie.rememberMe) 15 | if (rememberMe) { 16 | // nop 17 | return f(ctx) 18 | } else { 19 | // nop 20 | return f(ctx) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /filters/checkSession.ts: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext } from 'next' 2 | import { NextApiRequestCookies } from 'next/dist/server/api-utils' 3 | import { GlobalState } from '../data/global-state' 4 | import TokenHelper from '../helpers/token' 5 | import { ParsedUrlQuery } from 'node:querystring' 6 | import { destroyCookie } from 'nookies' 7 | 8 | const captains = console 9 | 10 | type SessionCookie = NextApiRequestCookies & { 11 | state?: string 12 | } 13 | 14 | export const checkSession = 15 | (f: (ctx: GetServerSidePropsContext) => any): any => 16 | async (ctx: GetServerSidePropsContext) => { 17 | try { 18 | const cookie = ctx.req.cookies as SessionCookie 19 | const { session } = JSON.parse(cookie.state) as GlobalState 20 | TokenHelper.verify(session.jwtToken) 21 | return f(ctx) 22 | } catch (e) { 23 | captains.warn('cookie is invalid... redirect to login page') 24 | destroyCookie(ctx, 'state') 25 | return { 26 | redirect: { 27 | permanent: false, // 永続的なリダイレクトかどうか 28 | destination: '/login', // リダイレクト先 29 | }, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /helpers/system.ts: -------------------------------------------------------------------------------- 1 | class SystemHelper { 2 | public static isBrowser = typeof window !== 'undefined' 3 | } 4 | 5 | export default SystemHelper 6 | -------------------------------------------------------------------------------- /helpers/token.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | export const SECRET_KEY = 'secret' 4 | 5 | class TokenHelper { 6 | // eslint-disable-next-line 7 | public static sign(payload: any, exp: any = 60 * 60): any { 8 | return jwt.sign( 9 | { 10 | exp: Math.floor(Date.now() / 1000) + exp, 11 | payload, 12 | }, 13 | SECRET_KEY 14 | ) 15 | } 16 | 17 | public static verify(token: string): string | void { 18 | const newToken = jwt.verify(token, SECRET_KEY, (err: any, decoded: any) => { 19 | if (err) { 20 | throw err 21 | } else { 22 | const token = this.sign(decoded.user) 23 | return token 24 | } 25 | }) 26 | return newToken 27 | } 28 | } 29 | 30 | export default TokenHelper 31 | -------------------------------------------------------------------------------- /hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useConfirm from './useConfirm' 2 | import useUnmountRef from './useUnmountRef' 3 | import useSafeState from './useSafeState' 4 | 5 | export { useConfirm, useSafeState, useUnmountRef } 6 | -------------------------------------------------------------------------------- /hooks/useConfirm.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import ConfirmContext from '../context/confirm-context' 3 | import { DialogOptions } from '../data/dialog-options' 4 | 5 | const useConfirm = (): ((options: DialogOptions) => Promise) => { 6 | const { confirm } = useContext(ConfirmContext) 7 | return confirm 8 | } 9 | 10 | export default useConfirm 11 | -------------------------------------------------------------------------------- /hooks/useSafeState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, MutableRefObject } from 'react' 2 | 3 | const useSafeState = ( 4 | unmountRef: MutableRefObject, 5 | defaultValue: any // eslint-disable-line 6 | ): any[] => { 7 | const [state, changeState] = useState(defaultValue) 8 | const wrapChangeState = useCallback( 9 | (value) => { 10 | if (!unmountRef.current) { 11 | changeState(value) 12 | } 13 | }, 14 | [changeState, unmountRef] 15 | ) 16 | 17 | return [state, wrapChangeState] 18 | } 19 | 20 | export default useSafeState 21 | -------------------------------------------------------------------------------- /hooks/useUnmountRef.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, MutableRefObject } from 'react' 2 | 3 | const useUnmountRef = (): MutableRefObject => { 4 | const unmountRef = useRef(false) 5 | 6 | useEffect( 7 | () => () => { 8 | unmountRef.current = true 9 | }, 10 | [] 11 | ) 12 | 13 | return unmountRef 14 | } 15 | 16 | export default useUnmountRef 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [''], 3 | testEnvironment: 'jsdom', 4 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'], 5 | testPathIgnorePatterns: ['[/\\\\](node_modules|.next)[/\\\\]'], 6 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'], 7 | transform: { 8 | '^.+\\.(ts|tsx)$': 'babel-jest', 9 | }, 10 | watchPlugins: [ 11 | 'jest-watch-typeahead/filename', 12 | 'jest-watch-typeahead/testname', 13 | ], 14 | moduleNameMapper: { 15 | '\\.(css|less|sass|scss)$': 'identity-obj-proxy', 16 | '\\.(gif|ttf|eot|svg|png)$': '/test/__mocks__/fileMock.js', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /lib/data/id-request.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest } from 'next' 2 | 3 | export type IdRequest = NextApiRequest & { 4 | query: { 5 | id: string | undefined 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/data/product.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | id: number 3 | name: string 4 | description: string 5 | quantity: number 6 | } 7 | -------------------------------------------------------------------------------- /lib/data/products.ts: -------------------------------------------------------------------------------- 1 | import { Product } from './product' 2 | 3 | export type Products = { 4 | products: Product[] 5 | } 6 | -------------------------------------------------------------------------------- /lib/shared/product-data.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '../data/product' 2 | import { Products } from '../data/products' 3 | 4 | const data: Products = { 5 | products: [ 6 | { 7 | id: 1, 8 | name: 'Strawberries', 9 | description: '16oz package of fresh organic strawberries', 10 | quantity: 1, 11 | }, 12 | { 13 | id: 2, 14 | name: 'Sliced bread', 15 | description: 'Loaf of fresh sliced wheat bread', 16 | quantity: 2, 17 | }, 18 | { 19 | id: 3, 20 | name: 'Apples', 21 | description: 'Bag of 7 fresh McIntosh apples', 22 | quantity: 3, 23 | }, 24 | { 25 | id: 4, 26 | name: 'Item4', 27 | description: 'no.4', 28 | quantity: 4, 29 | }, 30 | { 31 | id: 5, 32 | name: 'Item5', 33 | description: 'no.5', 34 | quantity: 5, 35 | }, 36 | { 37 | id: 6, 38 | name: 'Item6', 39 | description: 'no.6', 40 | quantity: 6, 41 | }, 42 | { 43 | id: 7, 44 | name: 'Item7', 45 | description: 'no.7', 46 | quantity: 7, 47 | }, 48 | { 49 | id: 8, 50 | name: 'Item8', 51 | description: 'no.8', 52 | quantity: 8, 53 | }, 54 | { 55 | id: 9, 56 | name: 'Item9', 57 | description: 'no.9', 58 | quantity: 9, 59 | }, 60 | { 61 | id: 10, 62 | name: 'Item10', 63 | description: 'no.10', 64 | quantity: 10, 65 | }, 66 | { 67 | id: 11, 68 | name: 'Item11', 69 | description: 'no.11', 70 | quantity: 11, 71 | }, 72 | { 73 | id: 12, 74 | name: 'Item12', 75 | description: 'no.12', 76 | quantity: 12, 77 | }, 78 | { 79 | id: 13, 80 | name: 'Item13', 81 | description: 'no.13', 82 | quantity: 13, 83 | }, 84 | { 85 | id: 14, 86 | name: 'Item14', 87 | description: 'no.14', 88 | quantity: 14, 89 | }, 90 | { 91 | id: 15, 92 | name: 'Item15', 93 | description: 'no.15', 94 | quantity: 15, 95 | }, 96 | { 97 | id: 16, 98 | name: 'Item16', 99 | description: 'no.16', 100 | quantity: 16, 101 | }, 102 | ], 103 | } 104 | 105 | const getRandomInt = (): number => { 106 | const max = 1000 107 | const min = 100 108 | return Math.floor(Math.random() * Math.floor(max) + min) 109 | } 110 | 111 | const getProduct = (id: number): Product => { 112 | return data.products.find((v) => v.id === id) 113 | } 114 | 115 | const addProduct = (product: Product): Product => { 116 | product.id = getRandomInt() 117 | data.products.push(product) 118 | return product 119 | } 120 | 121 | const updateProduct = (product: Product): Product => { 122 | const index = data.products.findIndex((v) => v.id === product.id) 123 | data.products.splice(index, 1, product) 124 | return product 125 | } 126 | 127 | const deleteProduct = (id: number): boolean => { 128 | data.products = data.products.filter((v) => v.id !== id) 129 | return true 130 | } 131 | 132 | const getProducts = (): Product[] => { 133 | return data.products 134 | } 135 | 136 | export default { 137 | addProduct, 138 | updateProduct, 139 | deleteProduct, 140 | getProducts, 141 | getProduct, 142 | } 143 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "NODE_OPTIONS='--openssl-legacy-provider' next dev", 7 | "build": "NODE_OPTIONS='--openssl-legacy-provider' next build", 8 | "start": "next start", 9 | "type-check": "tsc --pretty --noEmit", 10 | "format": "prettier --write .", 11 | "lint": "eslint . --ext ts --ext tsx --ext js", 12 | "test": "jest", 13 | "test-all": "yarn lint && yarn type-check && yarn test", 14 | "prepare": "husky install" 15 | }, 16 | "husky": { 17 | "hooks": { 18 | "pre-commit": "lint-staged", 19 | "pre-push": "yarn run type-check" 20 | } 21 | }, 22 | "lint-staged": { 23 | "*.@(ts|tsx)": [ 24 | "yarn lint", 25 | "yarn format" 26 | ] 27 | }, 28 | "dependencies": { 29 | "@hookform/resolvers": "3.0.0", 30 | "@tanstack/react-query": "4.28.0", 31 | "@tanstack/react-query-devtools": "4.28.0", 32 | "axios": "1.3.4", 33 | "date-fns": "2.29.3", 34 | "jsonwebtoken": "9.0.0", 35 | "lodash": "4.17.21", 36 | "next": "11.1.4", 37 | "nookies": "2.5.2", 38 | "react": "17.0.2", 39 | "react-dom": "17.0.2", 40 | "react-hook-form": "7.43.8", 41 | "react-toastify": "8.2.0", 42 | "yup": "0.32.11" 43 | }, 44 | "devDependencies": { 45 | "@tailwindcss/forms": "0.5.3", 46 | "@tailwindcss/line-clamp": "0.4.2", 47 | "@tailwindcss/typography": "0.5.9", 48 | "@testing-library/react": "12.1.5", 49 | "@types/jest": "29.5.0", 50 | "@types/node": "18.15.10", 51 | "@types/react": "17.0.20", 52 | "@typescript-eslint/eslint-plugin": "5.56.0", 53 | "@typescript-eslint/parser": "5.56.0", 54 | "autoprefixer": "10.4.14", 55 | "babel-jest": "29.5.0", 56 | "eslint": "8.36.0", 57 | "eslint-config-next": "13.2.4", 58 | "eslint-config-prettier": "8.8.0", 59 | "eslint-plugin-react": "7.32.2", 60 | "husky": "8.0.3", 61 | "jest": "29.5.0", 62 | "jest-environment-jsdom": "^29.5.0", 63 | "jest-watch-typeahead": "2.2.2", 64 | "lint-staged": "13.2.0", 65 | "prettier": "2.8.7", 66 | "tailwindcss": "3.2.7", 67 | "typescript": "5.0.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement, ReactNode } from 'react' 2 | import type { NextPage } from 'next' 3 | import type { AppProps } from 'next/app' 4 | 5 | import 'react-toastify/dist/ReactToastify.css' // For Toast 6 | import '../styles/global.css' 7 | 8 | type NextPageWithLayout = NextPage & { 9 | getLayout?: (page: ReactElement) => ReactNode 10 | } 11 | 12 | type AppPropsWithLayout = AppProps & { 13 | Component: NextPageWithLayout 14 | } 15 | 16 | export default function MyApp({ 17 | Component, 18 | pageProps, 19 | }: AppPropsWithLayout): ReactNode { 20 | // Use the layout defined at the page level, if available 21 | const getLayout = Component.getLayout ?? ((page) => page) 22 | 23 | return getLayout() 24 | } 25 | -------------------------------------------------------------------------------- /pages/api/auth/check/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import jwt from 'jsonwebtoken' 3 | import TokenHelper, { SECRET_KEY } from '../../../../helpers/token' 4 | 5 | export default (req: NextApiRequest, res: NextApiResponse): void => { 6 | // CORSヘッダーの設定 7 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 8 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 9 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 10 | 11 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 12 | if (req.method === 'OPTIONS') { 13 | res.status(200).end() 14 | return 15 | } 16 | 17 | let token = '' 18 | if ( 19 | req.headers.authorization && 20 | req.headers.authorization.split(' ')[0] === 'Bearer' 21 | ) { 22 | token = req.headers.authorization.split(' ')[1] 23 | 24 | jwt.verify(token, SECRET_KEY, (err: any, decoded: any) => { 25 | if (err) { 26 | res.status(401).json({ message: 'Unauthorized' }) 27 | } else { 28 | const token = TokenHelper.sign(decoded.user) 29 | res.status(200).json({ status: 'ok', token }) 30 | } 31 | }) 32 | } else { 33 | res.status(401).json({ message: 'Unauthorized' }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { serialize } from 'cookie' 3 | import TokenHelper from '../../../helpers/token' 4 | 5 | type AuthRequest = NextApiRequest & { 6 | body: { 7 | id: string 8 | password: string 9 | } 10 | } 11 | 12 | export default async ( 13 | req: AuthRequest, 14 | res: NextApiResponse 15 | ): Promise => { 16 | // CORSヘッダーの設定 17 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 18 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 19 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 20 | 21 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 22 | if (req.method === 'OPTIONS') { 23 | res.status(200).end() 24 | return 25 | } 26 | 27 | if (req.body.id && req.body.password && 0 < req.body.id.lastIndexOf('.com')) { 28 | const payload = { 29 | user: req.body.id, 30 | // admin, user, operator をランダムで返す 31 | role: ['admin', 'user', 'operator'][Math.floor(Math.random() * 3)], 32 | } 33 | const token = TokenHelper.sign(payload) 34 | const refreshToken = TokenHelper.sign(payload, 7 * 24 * (60 * 60)) // 7 days in seconds 35 | const refreshCookie = serialize('refreshToken', refreshToken, { 36 | httpOnly: true, 37 | secure: process.env.NODE_ENV === 'production', 38 | sameSite: 'strict', 39 | maxAge: 604800, // 7 days in seconds 40 | path: '/', 41 | }) 42 | 43 | await new Promise((resolve) => setTimeout(resolve, 1000)) 44 | 45 | res.setHeader('Set-Cookie', refreshCookie) 46 | res.status(200).json({ status: 'ok', token, refreshToken }) 47 | } else { 48 | res.status(401).json({ message: 'Unauthorized' }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pages/api/auth/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import jwt from 'jsonwebtoken' 3 | import { SECRET_KEY } from '../../../../helpers/token' 4 | 5 | const roles = { 6 | admin: [ 7 | { namespace: 'product', operation: 'create' }, 8 | { namespace: 'product', operation: 'edit' }, 9 | { namespace: 'product', operation: 'delete' }, 10 | { namespace: 'product', operation: 'view' }, 11 | { namespace: 'order', operation: 'create' }, 12 | { namespace: 'order', operation: 'edit' }, 13 | { namespace: 'order', operation: 'delete' }, 14 | { namespace: 'order', operation: 'view' }, 15 | ], 16 | user: [ 17 | { namespace: 'order', operation: 'create' }, 18 | { namespace: 'order', operation: 'view' }, 19 | ], 20 | operator: [ 21 | { namespace: 'product', operation: 'view' }, 22 | { namespace: 'product', operation: 'edit' }, 23 | { namespace: 'order', operation: 'view' }, 24 | { namespace: 'order', operation: 'edit' }, 25 | ], 26 | } 27 | 28 | export default (req: NextApiRequest, res: NextApiResponse): void => { 29 | // CORSヘッダーの設定 30 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 31 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') // 許可するHTTPメソッド 32 | res.setHeader('Access-Control-Allow-Headers', '*') // 許可するヘッダー 33 | 34 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 35 | if (req.method === 'OPTIONS') { 36 | res.status(200).end() 37 | return 38 | } 39 | 40 | let token = null 41 | // Authorization header override 42 | if ( 43 | req.headers.authorization && 44 | req.headers.authorization.split(' ')[0] === 'Bearer' 45 | ) { 46 | token = req.headers.authorization.split(' ')[1] 47 | } 48 | 49 | if (!token) { 50 | return res.status(401).json({ message: 'Unauthorized' }) 51 | } 52 | 53 | try { 54 | jwt.verify(token, SECRET_KEY, (err: any, decoded: any) => { 55 | if (err) { 56 | res.status(401).json({ message: 'Unauthorized' }) 57 | } else { 58 | res 59 | .status(200) 60 | .json({ status: 'ok', permissions: roles[decoded.payload.role] }) 61 | } 62 | }) 63 | } catch (error) { 64 | return res.status(401).json({ message: 'Invalid refresh token' }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /pages/api/auth/refreshTokenCheck/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import jwt from 'jsonwebtoken' 3 | import TokenHelper, { SECRET_KEY } from '../../../../helpers/token' 4 | 5 | export default (req: NextApiRequest, res: NextApiResponse): void => { 6 | // CORSヘッダーの設定 7 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 8 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS') // 許可するHTTPメソッド 9 | res.setHeader('Access-Control-Allow-Headers', '*') // 許可するヘッダー 10 | 11 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 12 | if (req.method === 'OPTIONS') { 13 | res.status(200).end() 14 | return 15 | } 16 | 17 | let refreshToken = req.cookies.refreshToken 18 | // X-REFRESH-TOKEN header override 19 | if (req.headers['x-refresh-token']) { 20 | refreshToken = req.headers['x-refresh-token'] as string 21 | } 22 | // Authorization header override 23 | if ( 24 | req.headers.authorization && 25 | req.headers.authorization.split(' ')[0] === 'Bearer' 26 | ) { 27 | refreshToken = req.headers.authorization.split(' ')[1] 28 | } 29 | 30 | if (!refreshToken) { 31 | return res.status(401).json({ message: 'No refresh token provided' }) 32 | } 33 | 34 | try { 35 | jwt.verify(refreshToken, SECRET_KEY, (err: any, decoded: any) => { 36 | if (err) { 37 | res.status(401).json({ message: 'Unauthorized' }) 38 | } else { 39 | const token = TokenHelper.sign(decoded.payload) 40 | res.status(200).json({ status: 'ok', token }) 41 | } 42 | }) 43 | } catch (error) { 44 | return res.status(401).json({ message: 'Invalid refresh token' }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pages/api/auth/signout/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default (req: NextApiRequest, res: NextApiResponse): void => { 4 | // CORSヘッダーの設定 5 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 6 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') // 許可するHTTPメソッド 7 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 8 | 9 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 10 | if (req.method === 'OPTIONS') { 11 | res.status(200).end() 12 | return 13 | } 14 | 15 | res.status(200).json({ status: 'ok' }) 16 | } 17 | -------------------------------------------------------------------------------- /pages/api/code/verify/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | type CodeVerifyRequest = NextApiRequest & { 4 | body: { 5 | code: string 6 | } 7 | } 8 | 9 | export default async ( 10 | req: CodeVerifyRequest, 11 | res: NextApiResponse 12 | ): Promise => { 13 | // CORSヘッダーの設定 14 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 15 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 16 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 17 | 18 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 19 | if (req.method === 'OPTIONS') { 20 | res.status(200).end() 21 | return 22 | } 23 | 24 | if (req.body.code) { 25 | await new Promise((resolve) => setTimeout(resolve, 1000)) 26 | res.status(200).json({ status: 'ok' }) 27 | } else { 28 | res.status(400).json({ message: 'Bad Request' }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default (req: NextApiRequest, res: NextApiResponse): void => { 4 | // CORSヘッダーの設定 5 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 6 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 7 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 8 | 9 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 10 | if (req.method === 'OPTIONS') { 11 | res.status(200).end() 12 | return 13 | } 14 | 15 | res.status(200).json({ text: 'Hello' }) 16 | } 17 | -------------------------------------------------------------------------------- /pages/api/password/change/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | type ChangePasswordRequest = NextApiRequest & { 4 | body: { 5 | password: string 6 | } 7 | } 8 | 9 | export default async ( 10 | req: ChangePasswordRequest, 11 | res: NextApiResponse 12 | ): Promise => { 13 | // CORSヘッダーの設定 14 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 15 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 16 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 17 | 18 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 19 | if (req.method === 'OPTIONS') { 20 | res.status(200).end() 21 | return 22 | } 23 | 24 | if (req.body.password) { 25 | await new Promise((resolve) => setTimeout(resolve, 1000)) 26 | res.status(200).json({ status: 'ok' }) 27 | } else { 28 | res.status(400).json({ message: 'Bad Request' }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/products/delete/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next' 2 | import data from '../../../../lib/shared/product-data' 3 | import { IdRequest } from '../../../../lib/data/id-request' 4 | 5 | export default (req: IdRequest, res: NextApiResponse): void => { 6 | // CORSヘッダーの設定 7 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 8 | res.setHeader('Access-Control-Allow-Methods', 'DELETE, OPTIONS') // 許可するHTTPメソッド 9 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 10 | 11 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 12 | if (req.method === 'OPTIONS') { 13 | res.status(200).end() 14 | return 15 | } 16 | 17 | const id = parseInt(req.query.id, 10) 18 | 19 | try { 20 | data.deleteProduct(id) 21 | res.status(200).json({}) 22 | } catch (error) { 23 | res.status(500).send(error) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/api/products/get/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next' 2 | import data from '../../../../lib/shared/product-data' 3 | import { IdRequest } from '../../../../lib/data/id-request' 4 | 5 | export default async (req: IdRequest, res: NextApiResponse): Promise => { 6 | // CORSヘッダーの設定 7 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 8 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') // 許可するHTTPメソッド 9 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 10 | 11 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 12 | if (req.method === 'OPTIONS') { 13 | res.status(200).end() 14 | return 15 | } 16 | 17 | const id = parseInt(req.query.id, 10) 18 | await new Promise((resolve) => setTimeout(resolve, 1500)) 19 | 20 | try { 21 | res.status(200).json(data.getProduct(id)) 22 | } catch (error) { 23 | res.status(500).send(error) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/api/products/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import _, { Many } from 'lodash' 3 | import data from '../../../lib/shared/product-data' 4 | import { Product } from '../../../lib/data/product' 5 | 6 | type ProductGetRequest = NextApiRequest & { 7 | query: { 8 | name: string | undefined 9 | description: string | undefined 10 | page: string | undefined 11 | rows: string | undefined 12 | order: string | undefined 13 | orderBy: string | undefined 14 | } 15 | } 16 | 17 | const captains = console 18 | 19 | export default async ( 20 | req: ProductGetRequest, 21 | res: NextApiResponse 22 | ): Promise => { 23 | // CORSヘッダーの設定 24 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 25 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 26 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 27 | 28 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 29 | if (req.method === 'OPTIONS') { 30 | res.status(200).end() 31 | return 32 | } 33 | 34 | try { 35 | await new Promise((resolve) => setTimeout(resolve, 1000)) 36 | 37 | const { name, description, page, rows, order, orderBy } = req.query 38 | const _rows = parseInt(rows) 39 | const _page = parseInt(page) 40 | 41 | let products = _.orderBy(data.getProducts(), 'id', 'asc') 42 | if (order) { 43 | if (['name', 'description'].includes(orderBy)) { 44 | products = _.orderBy(products, orderBy, order as Many<'asc' | 'desc'>) 45 | } 46 | } 47 | if (name) { 48 | products = products.filter( 49 | (item: Product) => -1 !== item.name.indexOf(name) 50 | ) 51 | } 52 | if (description) { 53 | products = products.filter( 54 | (item: Product) => -1 !== item.description.indexOf(description) 55 | ) 56 | } 57 | const start = _rows * _page 58 | captains.log(name, description, start, page, rows, order, orderBy) 59 | const results = { 60 | count: products.length, 61 | data: products.slice(start, start + _rows), 62 | } 63 | res.status(200).json(results) 64 | } catch (error) { 65 | res.status(500).send(error) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pages/api/products/post/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { Product } from '../../../../lib/data/product' 3 | import data from '../../../../lib/shared/product-data' 4 | 5 | export default (req: NextApiRequest, res: NextApiResponse): void => { 6 | // CORSヘッダーの設定 7 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 8 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') // 許可するHTTPメソッド 9 | res.setHeader('Access-Control-Allow-Headers', '*') // 許可するヘッダー 10 | 11 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 12 | if (req.method === 'OPTIONS') { 13 | res.status(200).end() 14 | return 15 | } 16 | 17 | const product: Product = { 18 | id: undefined, 19 | name: req.body.name, 20 | description: req.body.description, 21 | quantity: req.body.quantity, 22 | } 23 | 24 | try { 25 | const newProduct = data.addProduct(product) 26 | res.status(201).json(newProduct) 27 | } catch (error) { 28 | res.status(500).send(error) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/products/put/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next' 2 | import { Product } from '../../../../lib/data/product' 3 | import { IdRequest } from '../../../../lib/data/id-request' 4 | import data from '../../../../lib/shared/product-data' 5 | 6 | export default (req: IdRequest, res: NextApiResponse): void => { 7 | // CORSヘッダーの設定 8 | res.setHeader('Access-Control-Allow-Origin', '*') // すべてのオリジンからのアクセスを許可 9 | res.setHeader('Access-Control-Allow-Methods', 'PUT, OPTIONS') // 許可するHTTPメソッド 10 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') // 許可するヘッダー 11 | 12 | // OPTIONSメソッドのハンドリング(CORSプリフライトリクエスト) 13 | if (req.method === 'OPTIONS') { 14 | res.status(200).end() 15 | return 16 | } 17 | 18 | const id = parseInt(req.query.id, 10) 19 | 20 | const product: Product = { 21 | id, 22 | name: req.body.name, 23 | description: req.body.description, 24 | quantity: req.body.quantity, 25 | } 26 | 27 | try { 28 | const updatedProduct = data.updateProduct(product) 29 | res.status(200).json(updatedProduct) 30 | } catch (error) { 31 | res.status(500).send(error) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/complete.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { ReactElement } from 'react' 3 | import { useRouter } from 'next/router' 4 | import { DashboardLayout } from '../components/template' 5 | import { GetServerSideProps } from 'next' 6 | import { checkSession } from '../filters/checkSession' 7 | import Progress from '../components/progress' 8 | import { ParsedUrlQuery } from 'querystring' 9 | 10 | type CompleteQuery = ParsedUrlQuery & { 11 | to: string 12 | } 13 | 14 | export default function Complete(): JSX.Element { 15 | const router = useRouter() 16 | useEffect(() => { 17 | const { to } = router.query as CompleteQuery 18 | router.push(to) 19 | }, [router.query]) 20 | 21 | return ( 22 | <> 23 | 24 | redirect 25 | 26 | ) 27 | } 28 | 29 | Complete.getLayout = function getLayout(page: ReactElement) { 30 | return {page} 31 | } 32 | 33 | export const getServerSideProps: GetServerSideProps = checkSession(async () => { 34 | return { 35 | props: {}, 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | import { DashboardLayout } from '../components/template' 3 | import IndexPage from '../components/page/index-page' 4 | import { GetServerSideProps } from 'next' 5 | import { checkSession } from '../filters/checkSession' 6 | 7 | export default function Index(): JSX.Element { 8 | return 9 | } 10 | 11 | Index.getLayout = function getLayout(page: ReactElement) { 12 | return {page} 13 | } 14 | 15 | export const getServerSideProps: GetServerSideProps = checkSession(async () => { 16 | return { 17 | props: {}, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ReactElement } from 'react' 2 | import { SimpleLayout } from '../components/template' 3 | import LoginPage from '../components/page/login-page' 4 | import PasswordDialog from '../components/page/password-dialog' 5 | import ConfirmCodeModal from '../components/page/confirm-code-dialog' 6 | 7 | export default function Login(): JSX.Element { 8 | // For Password Modal 9 | const [state, setState] = useState<'Init' | 'WaitingForCode' | 'Done'>('Init') 10 | 11 | // For Password Modal 12 | const [passwordModal, setPasswordModalOpen] = useState(false) 13 | const handlePasswordModalClose = (_: any): void => setPasswordModalOpen(false) 14 | 15 | return ( 16 | <> 17 | setPasswordModalOpen(true)} /> 18 | {passwordModal && ( 19 | setState('WaitingForCode')} 21 | onClose={handlePasswordModalClose} 22 | onCancel={handlePasswordModalClose} 23 | /> 24 | )} 25 | {state === 'WaitingForCode' && ( 26 | setState('Done')} 28 | onClose={() => setState('Init')} 29 | onCancel={() => setState('Init')} 30 | /> 31 | )} 32 | 33 | ) 34 | } 35 | 36 | Login.getLayout = function getLayout(page: ReactElement) { 37 | return {page} 38 | } 39 | -------------------------------------------------------------------------------- /pages/product/[id].tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { ReactElement } from 'react' 3 | import { useRouter } from 'next/router' 4 | import { GetServerSideProps } from 'next' 5 | import { toast } from 'react-toastify' 6 | import { useForm } from 'react-hook-form' 7 | import { useMutation } from '@tanstack/react-query' 8 | import { AxiosPromise } from 'axios' 9 | import { ProductRepository } from '../../repository/product-repository' 10 | import { DashboardLayout } from '../../components/template' 11 | import { Product } from '../../lib/data/product' 12 | import data from '../../lib/shared/product-data' 13 | import { 14 | FormLabel, 15 | FormErrorMessage, 16 | Button, 17 | Typography, 18 | InputLabel, 19 | } from '../../components/atoms' 20 | import Progress from '../../components/progress' 21 | import { TextFieldType } from '../../data' 22 | import { 23 | ProductUpdateRequest, 24 | BaseResponse, 25 | } from '../../repository/product-repository' 26 | import { checkSession } from '../../filters/checkSession' 27 | 28 | const captains = console 29 | 30 | export default function ProductDetail({ 31 | product, 32 | }: { 33 | product: Product 34 | }): JSX.Element { 35 | const router = useRouter() 36 | const { 37 | register, 38 | handleSubmit, 39 | formState: { errors }, 40 | } = useForm({ 41 | defaultValues: { 42 | id: product.id, 43 | name: product.name, 44 | description: product.description, 45 | quantity: product.quantity, 46 | }, 47 | }) 48 | const mutation = useMutation( 49 | (req: ProductUpdateRequest): AxiosPromise => 50 | ProductRepository.update(req) 51 | ) 52 | 53 | const doSubmit = (data: Product): void => { 54 | captains.log(data) 55 | const request: ProductUpdateRequest = { ...data } 56 | mutation.mutate(request, { 57 | onSuccess: async () => { 58 | await router.push(`/complete?to=/`) 59 | setTimeout(() => toast.success('商品を更新しました'), 100) // display toast after screen transition 60 | }, 61 | }) 62 | } 63 | 64 | const back = (event: any): void => { 65 | router.back() 66 | event.preventDefault() 67 | } 68 | 69 | return ( 70 | <> 71 | 72 |
73 | 商品詳細 74 | 75 |
76 | 80 | 81 | 93 | 94 | 106 | 107 | 119 | 120 |
121 | 124 | 127 |
128 |
129 |
130 | 131 | ) 132 | } 133 | 134 | ProductDetail.getLayout = function getLayout(page: ReactElement) { 135 | return {page} 136 | } 137 | 138 | export const getServerSideProps: GetServerSideProps = checkSession( 139 | async ({ params }) => { 140 | const product = _.head( 141 | data.getProducts().filter((row: Product) => row.id === Number(params.id)) 142 | ) 143 | captains.log(`target product id = ${product.id}`) 144 | if (product) { 145 | return { 146 | props: { 147 | product, 148 | }, 149 | } 150 | } else { 151 | return { 152 | redirect: { 153 | permanent: false, // 永続的なリダイレクトかどうか 154 | destination: '/404', // リダイレクト先 155 | }, 156 | } 157 | } 158 | } 159 | ) 160 | -------------------------------------------------------------------------------- /pages/ui-elements.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | import { toast } from 'react-toastify' 3 | import useConfirm from '../hooks/useConfirm' 4 | import { Typography, Link } from '../components/atoms' 5 | import { DashboardLayout } from '../components/template' 6 | 7 | const captains = console 8 | 9 | export const InfoDilalog: React.FC = () => { 10 | const confirm = useConfirm() 11 | 12 | const handleClick = (_: any): void => { 13 | confirm({ 14 | title: 'Info Dialog Demo', 15 | alert: true, 16 | icon: 'info', 17 | description: 18 | 'Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone.', 19 | }) 20 | .then(() => { 21 | captains.log('then') 22 | }) 23 | .catch(() => { 24 | captains.log('error') 25 | }) 26 | } 27 | 28 | return ( 29 |
30 | open info dialog? 31 |
32 | ) 33 | } 34 | 35 | export const AlertDilalog: React.FC = () => { 36 | const confirm = useConfirm() 37 | 38 | const handleClick = (_: any): void => { 39 | confirm({ 40 | title: 'Alert Dialog Demo', 41 | alert: true, 42 | icon: 'alert', 43 | description: 44 | 'Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone.', 45 | }) 46 | .then(() => { 47 | captains.log('then') 48 | }) 49 | .catch(() => { 50 | captains.log('error') 51 | }) 52 | } 53 | 54 | return ( 55 |
56 | open alert dialog? 57 |
58 | ) 59 | } 60 | 61 | export const HtlmDilalog: React.FC = () => { 62 | const confirm = useConfirm() 63 | 64 | const handleClick = (_: any): void => { 65 | confirm({ 66 | title: 'Html Dialog Demo', 67 | html: true, 68 | icon: 'warn', 69 | description:
hoge?
, 70 | }) 71 | .then(() => { 72 | captains.log('then') 73 | }) 74 | .catch(() => { 75 | captains.log('error') 76 | }) 77 | } 78 | 79 | return ( 80 |
81 | open inline html dialog? 82 |
83 | ) 84 | } 85 | 86 | export const DilalogWithNoAlert: React.FC = () => { 87 | const confirm = useConfirm() 88 | 89 | const handleClick = (_: any): void => { 90 | confirm({ 91 | title: 'No Icon Dialog Demo', 92 | alert: true, 93 | description: 94 | 'Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone.', 95 | }) 96 | .then(() => { 97 | captains.log('then') 98 | }) 99 | .catch(() => { 100 | captains.log('error') 101 | }) 102 | } 103 | 104 | return ( 105 |
106 | open dialog with no icon? 107 |
108 | ) 109 | } 110 | 111 | export const Toast: React.FC = () => { 112 | const notify = () => toast.success('Wow so easy!') 113 | 114 | return ( 115 |
116 | Success! 117 |
118 | ) 119 | } 120 | 121 | export const ErrorToast: React.FC = () => { 122 | const notify = () => toast.error('Wow so easy!') 123 | 124 | return ( 125 |
126 | Error! 127 |
128 | ) 129 | } 130 | 131 | export default function Demo(): JSX.Element { 132 | return ( 133 |
134 |
135 |

@tailwindcss/form demo

136 |
137 |
138 | 145 | 146 | 153 | 154 |
155 |
156 | 163 |
164 | 165 | 173 |
174 | 175 |
176 | 179 |
180 |
181 |
182 |

Typography demo

183 |
184 |
185 | h2. Heading 186 | h3. Heading 187 | h4. Heading 188 | h5. Heading 189 | h6. Heading 190 | 191 | subtitle1. Lorem ipsum dolor sit amet, consectetur adipisicing elit. 192 | Quos blanditiis tenetur 193 | 194 | 195 | subtitle2. Lorem ipsum dolor sit amet, consectetur adipisicing elit. 196 | Quos blanditiis tenetur 197 | 198 | 199 | For years parents have espoused the health benefits of eating garlic 200 | bread with cheese to their children, with the food earning such an 201 | iconic status in our culture that kids will often dress up as warm, 202 | cheesy loaf for Halloween. 203 | 204 | 205 | But a recent study shows that the celebrated appetizer may be linked 206 | to a series of rabies cases springing up around the country. 207 | 208 |
209 |
210 |

@tailwindcss/line-clamp Plugin demo

211 |
212 |
213 |

214 | Et molestiae hic earum repellat aliquid est doloribus delectus. Enim 215 | illum odio porro ut omnis dolor debitis natus. Voluptas possimus 216 | deserunt sit delectus est saepe nihil. Qui voluptate possimus et quia. 217 | Eligendi voluptas voluptas dolor cum. Rerum est quos quos id ut 218 | molestiae fugit. 219 |

220 |
221 |
222 |

Dialog demo

223 |
224 |
225 | 226 |
227 |
228 | 229 |
230 |
231 | 232 |
233 |
234 | 235 |
236 |
237 |

Toast demo

238 |
239 |
240 | 241 |
242 |
243 | 244 |
245 |
246 | ) 247 | } 248 | 249 | Demo.getLayout = function getLayout(page: ReactElement) { 250 | return {page} 251 | } 252 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thirosue/next-typescript-sample/9212790aca9fb7816782c6ac17166a105bfa7b29/public/favicon.ico -------------------------------------------------------------------------------- /public/images/profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thirosue/next-typescript-sample/9212790aca9fb7816782c6ac17166a105bfa7b29/public/images/profile.jpg -------------------------------------------------------------------------------- /public/images/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thirosue/next-typescript-sample/9212790aca9fb7816782c6ac17166a105bfa7b29/public/images/profile.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /repository/auth-repository.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosPromise } from 'axios' 2 | 3 | export type AuthRequest = { 4 | id: string 5 | password: string 6 | } 7 | 8 | export type CheckSessionRequest = { 9 | jwt: string 10 | } 11 | 12 | export type ChangePasswordRequest = { 13 | password: string 14 | } 15 | 16 | export type VerifyCodeRequest = { 17 | code: string 18 | } 19 | 20 | export type BaseResponse = { 21 | status: string 22 | } 23 | 24 | export type AuthResponse = { 25 | status: string 26 | token: string 27 | } 28 | 29 | class AuthRepository { 30 | public static signIn(req: AuthRequest): AxiosPromise { 31 | return axios.put(`/api/auth`, req) 32 | } 33 | 34 | public static signOut(): AxiosPromise { 35 | return axios.put(`/api/auth/signout`, {}) 36 | } 37 | 38 | public static checkSession( 39 | req: CheckSessionRequest 40 | ): AxiosPromise { 41 | const config = { 42 | headers: { Authorization: `Bearer ${req.jwt}` }, 43 | } 44 | return axios.post(`/api/auth/check`, {}, config) 45 | } 46 | 47 | public static changePassword( 48 | req: ChangePasswordRequest 49 | ): AxiosPromise { 50 | return axios.put(`/api/password/change`, req) 51 | } 52 | 53 | public static verifyCode(req: VerifyCodeRequest): AxiosPromise { 54 | return axios.put(`/api/code/verify`, req) 55 | } 56 | } 57 | 58 | export { AuthRepository } 59 | -------------------------------------------------------------------------------- /repository/product-repository.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosPromise } from 'axios' 2 | 3 | export type ProductListRequest = { 4 | name?: string 5 | description?: string 6 | page: number 7 | rows: number 8 | order?: '' | 'asc' | 'desc' 9 | orderBy?: string 10 | } 11 | 12 | export type Product = { 13 | name: string 14 | description: string 15 | id: number 16 | quantity: number 17 | } 18 | 19 | export type ProductResponse = { 20 | count: number 21 | data: Product[] 22 | } 23 | 24 | export type BaseResponse = { 25 | status: string 26 | } 27 | 28 | export type ProductUpdateRequest = Product 29 | 30 | class ProductRepository { 31 | public static findAll( 32 | req: ProductListRequest 33 | ): AxiosPromise { 34 | return axios.get(`/api/products`, { params: req }) 35 | } 36 | 37 | public static update(req: ProductUpdateRequest): AxiosPromise { 38 | return axios.put(`/api/products/put?id=${req.id}`, req) 39 | } 40 | } 41 | 42 | export { ProductRepository } 43 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900&display=swap'); 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | ::-webkit-scrollbar { 7 | width: 7px; 8 | height: 7px; 9 | } 10 | 11 | ::-webkit-scrollbar-track { 12 | background: #2d3748; 13 | } 14 | 15 | ::-webkit-scrollbar-thumb { 16 | background: #cbd5e0; 17 | } 18 | 19 | ::-webkit-scrollbar-thumb:hover { 20 | background: #718096; 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: { 6 | fontFamily: { 7 | roboto: ['Roboto'], 8 | }, 9 | }, 10 | }, 11 | variants: { 12 | extend: {}, 13 | }, 14 | plugins: [ 15 | require('@tailwindcss/typography'), 16 | require('@tailwindcss/forms'), 17 | require('@tailwindcss/line-clamp'), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /test/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' 2 | -------------------------------------------------------------------------------- /test/compornents/atoms/__snapshots__/text-field.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TextField components matches snapshot 1`] = ` 4 | 5 | 19 | 20 | `; 21 | -------------------------------------------------------------------------------- /test/compornents/atoms/text-field.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '../../testUtils' 3 | import TextField from '../../../components/atoms/text-field' 4 | import { TextFieldType } from '../../../data' 5 | 6 | describe('TextField components', () => { 7 | it('matches snapshot', () => { 8 | const { asFragment } = render( 9 | console.log('hoge')} 16 | />, 17 | {} 18 | ) 19 | expect(asFragment()).toMatchSnapshot() 20 | }) 21 | 22 | // it('clicking button triggers alert', () => { 23 | // const { getByText } = render(, {}) 24 | // window.alert = jest.fn() 25 | // fireEvent.click(getByText('Test Button')) 26 | // expect(window.alert).toHaveBeenCalledWith('With typescript and Jest') 27 | // }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/compornents/molecules/__snapshots__/pager.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Pager components matches snapshot 1`] = ` 4 | 5 |
8 | 24 | 139 |
140 |
141 | `; 142 | -------------------------------------------------------------------------------- /test/compornents/molecules/pager.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '../../testUtils' 3 | import { PageItem } from '../../../data/page-item' 4 | import Pager from '../../../components/molecules/pager' 5 | 6 | type Pages = { 7 | page1: Element 8 | page2: Element 9 | page3: Element 10 | page4: Element 11 | page5: Element 12 | page6: Element 13 | page7: Element 14 | page8: Element 15 | page9: Element 16 | page10: Element 17 | } 18 | 19 | class Page { 20 | container: HTMLElement 21 | constructor(container: HTMLElement) { 22 | this.container = container 23 | } 24 | 25 | 一ページ目リンク(): Element { 26 | return this.container.querySelector('.page-link-1') 27 | } 28 | 29 | 二ページ目リンク(): Element { 30 | return this.container.querySelector('.page-link-2') 31 | } 32 | 33 | 三ページ目リンク(): Element { 34 | return this.container.querySelector('.page-link-3') 35 | } 36 | 37 | 四ページ目リンク(): Element { 38 | return this.container.querySelector('.page-link-4') 39 | } 40 | 41 | 五ページ目リンク(): Element { 42 | return this.container.querySelector('.page-link-5') 43 | } 44 | 45 | 六ページ目リンク(): Element { 46 | return this.container.querySelector('.page-link-6') 47 | } 48 | 49 | 七ページ目リンク(): Element { 50 | return this.container.querySelector('.page-link-7') 51 | } 52 | 53 | 八ページ目リンク(): Element { 54 | return this.container.querySelector('.page-link-8') 55 | } 56 | 57 | 九ページ目リンク(): Element { 58 | return this.container.querySelector('.page-link-9') 59 | } 60 | 61 | 十ページ目リンク(): Element { 62 | return this.container.querySelector('.page-link-10') 63 | } 64 | 65 | ページリンク一覧(): Pages { 66 | return { 67 | page1: this.一ページ目リンク(), 68 | page2: this.二ページ目リンク(), 69 | page3: this.三ページ目リンク(), 70 | page4: this.四ページ目リンク(), 71 | page5: this.五ページ目リンク(), 72 | page6: this.六ページ目リンク(), 73 | page7: this.七ページ目リンク(), 74 | page8: this.八ページ目リンク(), 75 | page9: this.九ページ目リンク(), 76 | page10: this.十ページ目リンク(), 77 | } 78 | } 79 | } 80 | 81 | const handleClick = async (): Promise => {} 82 | 83 | describe('Pager components', () => { 84 | it('matches snapshot', () => { 85 | const item: PageItem = { 86 | page: 1, 87 | totalPage: 10, 88 | totalCount: 97, 89 | perPage: 10, 90 | } 91 | const { asFragment } = render( 92 | , 93 | {} 94 | ) 95 | expect(asFragment()).toMatchSnapshot() 96 | }) 97 | 98 | it('全10ページ かつ 現在1ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 99 | const item: PageItem = { 100 | page: 1, 101 | totalPage: 10, 102 | totalCount: 97, 103 | perPage: 10, 104 | } 105 | const { container } = render( 106 | , 107 | {} 108 | ) 109 | const page = new Page(container) 110 | 111 | // 期待値 112 | // [1] 2 3 4 ... 10 113 | const { page1, page2, page3, page4, page5, page9, page10 } = 114 | page.ページリンク一覧() 115 | 116 | expect(page1).toBeTruthy() 117 | expect(page1.className).toContain('cursor-not-allowed') 118 | 119 | expect(page2).toBeTruthy() 120 | expect(page2.className).not.toContain('cursor-not-allowed') 121 | 122 | expect(page3).toBeTruthy() 123 | expect(page4).toBeTruthy() 124 | 125 | expect(page5).toBeNull() 126 | expect(page9).toBeNull() 127 | 128 | expect(page10).toBeTruthy() 129 | expect(page10.className).not.toContain('cursor-not-allowed') 130 | }) 131 | 132 | it('全10ページ かつ 現在2ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 133 | const item: PageItem = { 134 | page: 2, 135 | totalPage: 10, 136 | totalCount: 97, 137 | perPage: 10, 138 | } 139 | const { container } = render( 140 | , 141 | {} 142 | ) 143 | const page = new Page(container) 144 | 145 | // 期待値 146 | // 1 [2] 3 4 5 ... 10 147 | const { page1, page2, page3, page4, page5, page6, page9, page10 } = 148 | page.ページリンク一覧() 149 | 150 | expect(page1).toBeTruthy() 151 | expect(page1.className).not.toContain('cursor-not-allowed') 152 | 153 | expect(page2).toBeTruthy() 154 | expect(page2.className).toContain('cursor-not-allowed') 155 | 156 | expect(page3).toBeTruthy() 157 | expect(page3.className).not.toContain('cursor-not-allowed') 158 | 159 | expect(page4).toBeTruthy() 160 | expect(page5).toBeTruthy() 161 | 162 | expect(page6).toBeNull() 163 | expect(page9).toBeNull() 164 | 165 | expect(page10).toBeTruthy() 166 | expect(page10.className).not.toContain('cursor-not-allowed') 167 | }) 168 | 169 | it('全10ページ かつ 現在3ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 170 | const item: PageItem = { 171 | page: 3, 172 | totalPage: 10, 173 | totalCount: 97, 174 | perPage: 10, 175 | } 176 | const { container } = render( 177 | , 178 | {} 179 | ) 180 | const page = new Page(container) 181 | 182 | // 期待値 183 | // 1 2 [3] 4 5 6 ... 10 184 | const { page1, page2, page3, page4, page5, page6, page7, page9, page10 } = 185 | page.ページリンク一覧() 186 | 187 | expect(page1).toBeTruthy() 188 | expect(page1.className).not.toContain('cursor-not-allowed') 189 | 190 | expect(page2).toBeTruthy() 191 | expect(page2.className).not.toContain('cursor-not-allowed') 192 | 193 | expect(page3).toBeTruthy() 194 | expect(page3.className).toContain('cursor-not-allowed') 195 | 196 | expect(page4).toBeTruthy() 197 | expect(page4.className).not.toContain('cursor-not-allowed') 198 | 199 | expect(page5).toBeTruthy() 200 | expect(page6).toBeTruthy() 201 | 202 | expect(page7).toBeNull() 203 | expect(page9).toBeNull() 204 | 205 | expect(page10).toBeTruthy() 206 | expect(page10.className).not.toContain('cursor-not-allowed') 207 | }) 208 | 209 | it('全10ページ かつ 現在4ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 210 | const item: PageItem = { 211 | page: 4, 212 | totalPage: 10, 213 | totalCount: 97, 214 | perPage: 10, 215 | } 216 | render(, {}) 217 | 218 | const { container } = render( 219 | , 220 | {} 221 | ) 222 | const page = new Page(container) 223 | 224 | // 期待値 225 | // 1 2 3 [4] 5 6 7 ... 10 226 | const { 227 | page1, 228 | page2, 229 | page3, 230 | page4, 231 | page5, 232 | page6, 233 | page7, 234 | page8, 235 | page9, 236 | page10, 237 | } = page.ページリンク一覧() 238 | 239 | expect(page1).toBeTruthy() 240 | expect(page1.className).not.toContain('cursor-not-allowed') 241 | 242 | expect(page2).toBeTruthy() 243 | expect(page2.className).not.toContain('cursor-not-allowed') 244 | 245 | expect(page3).toBeTruthy() 246 | expect(page3.className).not.toContain('cursor-not-allowed') 247 | 248 | expect(page4).toBeTruthy() 249 | expect(page4.className).toContain('cursor-not-allowed') 250 | 251 | expect(page5).toBeTruthy() 252 | expect(page5.className).not.toContain('cursor-not-allowed') 253 | 254 | expect(page6).toBeTruthy() 255 | expect(page7).toBeTruthy() 256 | 257 | expect(page8).toBeNull() 258 | expect(page9).toBeNull() 259 | 260 | expect(page10).toBeTruthy() 261 | expect(page10.className).not.toContain('cursor-not-allowed') 262 | }) 263 | 264 | it('全10ページ かつ 現在5ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 265 | const item: PageItem = { 266 | page: 5, 267 | totalPage: 10, 268 | totalCount: 97, 269 | perPage: 10, 270 | } 271 | const { container } = render( 272 | , 273 | {} 274 | ) 275 | const page = new Page(container) 276 | 277 | // 期待値 278 | // 1 2 3 4 [5] 6 7 8 ... 10 279 | const { 280 | page1, 281 | page2, 282 | page3, 283 | page4, 284 | page5, 285 | page6, 286 | page7, 287 | page8, 288 | page9, 289 | page10, 290 | } = page.ページリンク一覧() 291 | 292 | expect(page1).toBeTruthy() 293 | expect(page1.className).not.toContain('cursor-not-allowed') 294 | 295 | expect(page2).toBeTruthy() 296 | expect(page2.className).not.toContain('cursor-not-allowed') 297 | 298 | expect(page3).toBeTruthy() 299 | expect(page3.className).not.toContain('cursor-not-allowed') 300 | 301 | expect(page4).toBeTruthy() 302 | expect(page4.className).not.toContain('cursor-not-allowed') 303 | 304 | expect(page5).toBeTruthy() 305 | expect(page5.className).toContain('cursor-not-allowed') 306 | 307 | expect(page6).toBeTruthy() 308 | expect(page6.className).not.toContain('cursor-not-allowed') 309 | 310 | expect(page7).toBeTruthy() 311 | expect(page8).toBeTruthy() 312 | 313 | expect(page9).toBeNull() 314 | 315 | expect(page10).toBeTruthy() 316 | expect(page10.className).not.toContain('cursor-not-allowed') 317 | }) 318 | 319 | it('全10ページ かつ 現在6ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 320 | const item: PageItem = { 321 | page: 6, 322 | totalPage: 10, 323 | totalCount: 97, 324 | perPage: 10, 325 | } 326 | const { container } = render( 327 | , 328 | {} 329 | ) 330 | const page = new Page(container) 331 | 332 | // 期待値 333 | // 6ページ 1 ... 3 4 5 [6] 7 8 9 10 334 | const { 335 | page1, 336 | page2, 337 | page3, 338 | page4, 339 | page5, 340 | page6, 341 | page7, 342 | page8, 343 | page9, 344 | page10, 345 | } = page.ページリンク一覧() 346 | 347 | expect(page1).toBeTruthy() 348 | expect(page1.className).not.toContain('cursor-not-allowed') 349 | 350 | expect(page2).toBeNull() 351 | 352 | expect(page3).toBeTruthy() 353 | expect(page3.className).not.toContain('cursor-not-allowed') 354 | 355 | expect(page4).toBeTruthy() 356 | expect(page4.className).not.toContain('cursor-not-allowed') 357 | 358 | expect(page5).toBeTruthy() 359 | expect(page5.className).not.toContain('cursor-not-allowed') 360 | 361 | expect(page6).toBeTruthy() 362 | expect(page6.className).toContain('cursor-not-allowed') 363 | 364 | expect(page7).toBeTruthy() 365 | expect(page7.className).not.toContain('cursor-not-allowed') 366 | 367 | expect(page8).toBeTruthy() 368 | expect(page9).toBeTruthy() 369 | expect(page10).toBeTruthy() 370 | expect(page10.className).not.toContain('cursor-not-allowed') 371 | }) 372 | 373 | it('全10ページ かつ 現在10ページ目を指定しているとき、ページャの数が期待値どおりであること', () => { 374 | const item: PageItem = { 375 | page: 10, 376 | totalPage: 10, 377 | totalCount: 97, 378 | perPage: 10, 379 | } 380 | const { container } = render( 381 | , 382 | {} 383 | ) 384 | const page = new Page(container) 385 | 386 | // 期待値 387 | // 10ページ 1 ... 7 8 9 [10] 388 | const { 389 | page1, 390 | page2, 391 | page3, 392 | page4, 393 | page5, 394 | page6, 395 | page7, 396 | page8, 397 | page9, 398 | page10, 399 | } = page.ページリンク一覧() 400 | 401 | expect(page1).toBeTruthy() 402 | expect(page1.className).not.toContain('cursor-not-allowed') 403 | 404 | expect(page2).toBeNull() 405 | expect(page3).toBeNull() 406 | expect(page4).toBeNull() 407 | expect(page5).toBeNull() 408 | expect(page6).toBeNull() 409 | 410 | expect(page7).toBeTruthy() 411 | expect(page7.className).not.toContain('cursor-not-allowed') 412 | 413 | expect(page8).toBeTruthy() 414 | expect(page8.className).not.toContain('cursor-not-allowed') 415 | 416 | expect(page9).toBeTruthy() 417 | expect(page9.className).not.toContain('cursor-not-allowed') 418 | 419 | expect(page10).toBeTruthy() 420 | expect(page10.className).toContain('cursor-not-allowed') 421 | }) 422 | }) 423 | -------------------------------------------------------------------------------- /test/pages/__snapshots__/login.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Login page matches snapshot 1`] = ` 4 | 5 |
8 |
11 |
14 | 20 | 28 | 32 | 33 |
34 | 37 | Dashboard 38 | 39 |
40 |
41 |
44 |
126 |
127 |
128 | `; 129 | -------------------------------------------------------------------------------- /test/pages/login.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { act } from 'react-dom/test-utils' 3 | import { render, fireEvent } from '../testUtils' 4 | import Login from '../../pages/login' 5 | import { setCookie, parseCookies, destroyCookie } from 'nookies' 6 | import axios from 'axios' 7 | 8 | class Page { 9 | container: HTMLElement 10 | constructor(container: HTMLElement) { 11 | this.container = container 12 | } 13 | 14 | Eメール入力エリア(): Element { 15 | return this.container.querySelector('#email') 16 | } 17 | 18 | Eメールエラーメッセージエリア(): Element { 19 | return this.container.querySelector('.email-error-message-area') 20 | } 21 | 22 | パスワード入力エリア(): Element { 23 | return this.container.querySelector('#password') 24 | } 25 | 26 | パスワードエラーメッセージエリア(): Element { 27 | return this.container.querySelector('.password-error-message-area') 28 | } 29 | 30 | オートログインチェックボックス(): Element { 31 | return this.container.querySelector('#rememberMe') 32 | } 33 | 34 | 確認モーダル(): Element { 35 | return this.container.querySelector('.modal-dialog') 36 | } 37 | 38 | 確認モーダルタイトル(): Element { 39 | return this.container.querySelector('.modal-title') 40 | } 41 | 42 | 確認モーダルメッセージ(): Element { 43 | return this.container.querySelector('.modal-message') 44 | } 45 | 46 | 確認モーダルキャンセルボタン(): Element { 47 | return this.container.querySelector('.modal-cancel') 48 | } 49 | 50 | 確認モーダルサブミットボタン(): Element { 51 | return this.container.querySelector('.modal-submit') 52 | } 53 | 54 | ログインボタン(): Element { 55 | return this.container.querySelector('.primary-button') 56 | } 57 | 58 | Email入力(value: string): void { 59 | fireEvent.change(this.Eメール入力エリア(), { target: { value } }) 60 | } 61 | 62 | パスワード入力(value: string): void { 63 | fireEvent.change(this.パスワード入力エリア(), { target: { value } }) 64 | } 65 | 66 | オートログインチェック(): void { 67 | fireEvent.click(this.オートログインチェックボックス()) 68 | } 69 | 70 | 確認モーダルキャンセルボタンクリック(): void { 71 | fireEvent.click(this.確認モーダルキャンセルボタン()) 72 | } 73 | 74 | 確認モーダルサブミットボタンクリック(): void { 75 | fireEvent.click(this.確認モーダルサブミットボタン()) 76 | } 77 | 78 | ログインボタンクリック(): void { 79 | fireEvent.click(this.ログインボタン()) 80 | } 81 | } 82 | 83 | describe('Login page', () => { 84 | it('matches snapshot', () => { 85 | const { asFragment } = render(, {}) 86 | expect(asFragment()).toMatchSnapshot() 87 | }) 88 | 89 | describe('入力バリデーション', () => { 90 | it('Eメール未入力で、ログインボタンをクリックしたとき、Eメールが必須エラーとなること', async () => { 91 | const { container } = render(, {}) 92 | const page = new Page(container) 93 | await act(async () => { 94 | page.ログインボタンクリック() 95 | }) 96 | expect(page.Eメールエラーメッセージエリア().textContent).toEqual( 97 | '入力してください' 98 | ) 99 | }) 100 | it('Eメールをフォーマット誤りで入力のうえ、ログインボタンをクリックしたとき、Eメールがフォーマットエラーとなること', async () => { 101 | const { container } = render(, {}) 102 | const page = new Page(container) 103 | await act(async () => { 104 | page.Email入力('test@test') 105 | page.ログインボタンクリック() 106 | }) 107 | expect(page.Eメールエラーメッセージエリア().textContent).toEqual( 108 | 'メールアドレスを入力してください' 109 | ) 110 | }) 111 | it('パスワード未入力で、ログインボタンをクリックしたとき、パスワードが必須エラーとなること', async () => { 112 | const { container } = render(, {}) 113 | const page = new Page(container) 114 | await act(async () => { 115 | page.ログインボタンクリック() 116 | }) 117 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 118 | '入力してください' 119 | ) 120 | }) 121 | it('パスワードを桁数不足で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => { 122 | const { container } = render(, {}) 123 | const page = new Page(container) 124 | await act(async () => { 125 | page.パスワード入力('Pp1?') 126 | page.ログインボタンクリック() 127 | }) 128 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 129 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 130 | ) 131 | }) 132 | it('パスワードを組み合わせ不足(記号不足)で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => { 133 | const { container } = render(, {}) 134 | const page = new Page(container) 135 | await act(async () => { 136 | page.パスワード入力('Ppppppppppp1') 137 | page.ログインボタンクリック() 138 | }) 139 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 140 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 141 | ) 142 | }) 143 | it('パスワードを組み合わせ不足(数字不足)で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => { 144 | const { container } = render(, {}) 145 | const page = new Page(container) 146 | await act(async () => { 147 | page.パスワード入力('Ppppppppppp?') 148 | page.ログインボタンクリック() 149 | }) 150 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 151 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 152 | ) 153 | }) 154 | it('パスワードを組み合わせ不足(アルファベット小文字不足)で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => { 155 | const { container } = render(, {}) 156 | const page = new Page(container) 157 | await act(async () => { 158 | page.パスワード入力('PXXXXXXXXXXXX1?') 159 | page.ログインボタンクリック() 160 | }) 161 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 162 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 163 | ) 164 | }) 165 | it('パスワードを組み合わせ不足(アルファベット大文字不足)で入力のうえ、ログインボタンをクリックしたとき、パスワードがフォーマットエラーとなること', async () => { 166 | const { container } = render(, {}) 167 | const page = new Page(container) 168 | await act(async () => { 169 | page.パスワード入力('ppppppppppppppp1?') 170 | page.ログインボタンクリック() 171 | }) 172 | expect(page.パスワードエラーメッセージエリア().textContent).toEqual( 173 | 'アルファベット(大文字小文字混在)と数字と特殊記号を組み合わせて8文字以上で入力してください' 174 | ) 175 | }) 176 | }) 177 | 178 | describe('オートログイン設定', () => { 179 | it('オートログイン未設定状態でオートログインチェックボックスをチェックしたとき、有効にする旨の確認ダイアログが表示されること', async () => { 180 | destroyCookie(null, 'rememberMe') 181 | const { container } = render(, {}) 182 | const page = new Page(container) 183 | await act(async () => { 184 | page.オートログインチェック() 185 | }) 186 | expect(page.確認モーダル()).toBeTruthy() 187 | expect(page.確認モーダルメッセージ().textContent).toEqual( 188 | '自動ログインを有効にしますか?' 189 | ) 190 | }) 191 | it('オートログイン設定状態でオートログインチェックボックスをチェックしたとき、無効にする旨の確認ダイアログが表示されること', async () => { 192 | setCookie(null, 'rememberMe', 'true') 193 | const { container } = render(, {}) 194 | const page = new Page(container) 195 | await act(async () => { 196 | page.オートログインチェック() 197 | }) 198 | expect(page.確認モーダル()).toBeTruthy() 199 | expect(page.確認モーダルメッセージ().textContent).toEqual( 200 | '自動ログインを無効にしますか?' 201 | ) 202 | }) 203 | 204 | it('自動ログイン有効の確認ダイアログが表示され、OKボタンを押したとき、自動ログイン設定がcookieに反映されること', async () => { 205 | destroyCookie(null, 'rememberMe') 206 | const { container } = render(, {}) 207 | const page = new Page(container) 208 | await act(async () => { 209 | page.オートログインチェック() 210 | }) 211 | expect(parseCookies(null).rememberMe).not.toBeTruthy() 212 | await act(async () => { 213 | page.確認モーダルサブミットボタンクリック() 214 | }) 215 | expect(parseCookies(null).rememberMe).toEqual('true') 216 | }) 217 | 218 | it('自動ログイン無効の確認ダイアログが表示され、OKボタンを押したとき、自動ログイン設定がcookieに反映されること', async () => { 219 | setCookie(null, 'rememberMe', 'true') 220 | const { container } = render(, {}) 221 | const page = new Page(container) 222 | await act(async () => { 223 | page.オートログインチェック() 224 | }) 225 | expect(parseCookies(null).rememberMe).toEqual('true') 226 | await act(async () => { 227 | page.確認モーダルサブミットボタンクリック() 228 | }) 229 | expect(parseCookies(null).rememberMe).not.toBeTruthy() 230 | }) 231 | }) 232 | 233 | describe('ログイン確認', () => { 234 | it('Emailとパスワードに正しいフォーマットを入力の上、ログインボタンを押したとき、認証APIにID/PasswordをPutすること', async () => { 235 | const { container } = render(, {}) 236 | const page = new Page(container) 237 | axios.put = jest.fn().mockImplementation(() => 238 | Promise.resolve({ 239 | data: {}, 240 | }) 241 | ) 242 | await act(async () => { 243 | page.Email入力('test@test.com') 244 | page.パスワード入力('Password1?') 245 | page.ログインボタンクリック() 246 | }) 247 | expect(axios.put).toBeCalledTimes(1) 248 | expect(axios.put).toBeCalledWith('/api/auth', { 249 | id: 'test@test.com', 250 | password: 'Password1?', 251 | }) 252 | }) 253 | 254 | it('ログイン時に、認証APIが認証エラー(401)を返すとき、認証エラーのアラートダイアログが表示されること', async () => { 255 | const { container } = render(, {}) 256 | const page = new Page(container) 257 | axios.put = jest.fn().mockImplementation(() => 258 | Promise.reject({ 259 | response: { 260 | status: 401, 261 | }, 262 | }) 263 | ) 264 | await act(async () => { 265 | page.Email入力('test@test.com') 266 | page.パスワード入力('Password1?') 267 | page.ログインボタンクリック() 268 | }) 269 | expect(page.確認モーダル()).toBeTruthy() 270 | expect(page.確認モーダルタイトル().textContent).toEqual('認証エラー') 271 | expect(page.確認モーダルメッセージ().textContent).toEqual( 272 | 'Emailもしくはパスワードが誤っています' 273 | ) 274 | }) 275 | 276 | it('ログイン時に、認証APIがシステムエラー(500)を返すとき、システムエラーのアラートダイアログが表示されること', async () => { 277 | const { container } = render(, {}) 278 | const page = new Page(container) 279 | axios.put = jest.fn().mockImplementation(() => 280 | Promise.reject({ 281 | response: { 282 | status: 500, 283 | }, 284 | }) 285 | ) 286 | await act(async () => { 287 | page.Email入力('test@test.com') 288 | page.パスワード入力('Password1?') 289 | page.ログインボタンクリック() 290 | }) 291 | expect(page.確認モーダル()).toBeTruthy() 292 | expect(page.確認モーダルタイトル().textContent).toEqual('システムエラー') 293 | expect(page.確認モーダルメッセージ().textContent).toEqual( 294 | 'エラーが発生しました。しばらくしてからもう一度お試しください。' 295 | ) 296 | }) 297 | }) 298 | }) 299 | -------------------------------------------------------------------------------- /test/testUtils.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement } from 'react' 2 | import { render, RenderOptions } from '@testing-library/react' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import GlobalStateProvider from '../context/global-state-provider' 5 | import ConfirmProvider from '../context/confirm-provider' 6 | 7 | const queryClient = new QueryClient() 8 | 9 | jest.mock('next/router', () => ({ 10 | useRouter() { 11 | return { 12 | push: jest.fn(), 13 | } 14 | }, 15 | })) 16 | 17 | const AllTheProviders: FC = ({ children }) => { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | 27 | const customRender = ( 28 | ui: ReactElement, 29 | options?: Omit 30 | ) => render(ui, { wrapper: AllTheProviders, ...options }) 31 | 32 | export * from '@testing-library/react' 33 | export { customRender as render } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------