├── .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 |
101 | {children}
102 |
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 |
31 | {label}
32 |
38 | {error && {helperText}
}
39 |
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 |
120 | isFirstActive && search(pageItem.page - 1)}
123 | >
124 |
131 |
136 |
137 |
138 | {1 <= pageItem.totalPage && (
139 |
140 | )}
141 | {0 < pages.length &&
142 | pages.map((page, index: number) => (
143 |
144 | {index === 0 && 2 < page && }
145 |
150 | {index === pages.length - 1 &&
151 | page < pageItem.totalPage - 1 && }
152 |
153 | ))}
154 | {2 <= pageItem.totalPage && (
155 |
160 | )}
161 | isLastActive && search(pageItem.page + 1)}
164 | >
165 |
172 |
177 |
178 |
179 |
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 |
26 |
27 |
53 |
54 |
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 |
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 |
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 |
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 |
30 | >
31 | ),
32 | },
33 | {
34 | key: 'warn',
35 | element: (
36 | <>
37 |
51 | >
52 | ),
53 | },
54 | {
55 | key: 'alert',
56 | element: (
57 | <>
58 |
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 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | {icon && DialogIcon()}
132 |
133 | {title && {title} }
134 |
135 |
136 |
137 |
138 |
145 |
151 |
152 |
153 |
154 |
157 |
158 |
159 |
166 | {confirmationText}
167 |
168 | {!alert && (
169 | <>
170 |
181 | {cancellationText}
182 |
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 |
53 |
59 |
66 |
67 |
68 |
96 |
97 |
98 |
99 |
103 |
110 |
116 |
117 |
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 |
158 | {NaviItems.map((item: Item, index: number) => (
159 |
166 | {item.icon}
167 |
168 | ))}
169 |
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 |
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 |
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 |
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 |
8 |
11 | test
12 |
13 |
18 |
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 |
27 |
28 |
31 |
32 | Showing
33 |
34 |
37 | 1
38 |
39 |
40 | to
41 |
42 |
45 | 2
46 |
47 |
48 | of
49 |
50 |
53 | 97
54 |
55 |
56 | results
57 |
58 |
59 |
60 |
138 |
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 |
125 |
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 |
--------------------------------------------------------------------------------