├── .babelrc
├── .circleci
└── config.yml
├── .dockerignore
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ ├── build.yml
│ ├── ncu.yml
│ └── testing.yml
├── .gitignore
├── .huskyrc
├── .hygen.js
├── .hygen
└── generate
│ ├── component
│ ├── component.test.tsx.t
│ ├── component.tsx.t
│ └── prompt.js
│ └── redux
│ ├── action.test.ts.t
│ ├── action.ts.t
│ ├── actionType.ts.t
│ ├── inject_actionTypes.ts.t
│ ├── inject_actions.ts.t
│ ├── inject_actions2.ts.t
│ ├── inject_constant.ts.t
│ ├── inject_reducers.ts.t
│ ├── inject_stateTypes.ts.t
│ ├── prompt.js
│ ├── reducer.test.ts.t
│ └── reducer.ts.t
├── .node-version
├── .prettierrc
├── LICENSE
├── README.md
├── docker-compose.yml
├── docker
├── app
│ └── Dockerfile
└── web
│ ├── Dockerfile
│ └── nginx
│ └── default.conf
├── docs
└── images
│ ├── flux.png
│ └── type.png
├── jest.config.js
├── jest.tsconfig.json
├── next-env.d.ts
├── next.config.js
├── nodemon.json
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── _error.tsx
├── api
│ ├── account.ts
│ ├── signin.ts
│ ├── signout.ts
│ └── ua.ts
├── index.tsx
├── signin.tsx
└── todo.tsx
├── server
└── index.ts
├── setupJestFetchMock.js
├── src
├── __mocks__
│ └── next
│ │ └── config.ts
├── actionTypes
│ ├── account.ts
│ ├── counter.ts
│ └── index.ts
├── actions
│ ├── account
│ │ ├── __test__
│ │ │ └── account.test.ts
│ │ └── index.ts
│ ├── counter
│ │ ├── __test__
│ │ │ └── counter.test.ts
│ │ └── index.ts
│ └── index.ts
├── components
│ ├── Layout
│ │ ├── __test__
│ │ │ └── Layout.test.tsx
│ │ └── index.tsx
│ ├── Nav
│ │ ├── __test__
│ │ │ └── Nav.test.tsx
│ │ └── index.tsx
│ ├── Signout
│ │ ├── __test__
│ │ │ └── index.test.tsx
│ │ └── index.tsx
│ └── TodoLists
│ │ ├── __test__
│ │ └── TodoLists.test.tsx
│ │ └── index.tsx
├── config
│ └── index.ts
├── constant.ts
├── hooks
│ ├── signin
│ │ ├── __test__
│ │ │ ├── useEmail.test.ts
│ │ │ └── usePassword.test.ts
│ │ ├── useEmail.ts
│ │ └── usePassword.ts
│ └── todo
│ │ ├── __test__
│ │ ├── useTodo.test.ts
│ │ └── useTodoList.test.ts
│ │ ├── index.ts
│ │ ├── useTodo.ts
│ │ └── useTodoList.ts
├── lib
│ ├── http
│ │ └── index.ts
│ ├── testing
│ │ └── index.tsx
│ └── ua
│ │ ├── __test__
│ │ └── ua.test.ts
│ │ └── index.ts
├── modelTypes.ts
├── pages
│ └── __test__
│ │ └── todo.test.tsx
├── reducers
│ ├── account
│ │ ├── __test__
│ │ │ ├── __snapshots__
│ │ │ │ └── account.test.ts.snap
│ │ │ └── account.test.ts
│ │ └── index.ts
│ ├── counter
│ │ ├── __test__
│ │ │ ├── __snapshots__
│ │ │ │ └── counter.test.ts.snap
│ │ │ └── counter.test.ts
│ │ └── index.ts
│ ├── index.ts
│ └── reducers.ts
├── sagas
│ ├── __test__
│ │ ├── account.test.ts
│ │ └── counter.test.ts
│ ├── index.ts
│ ├── selectors
│ │ ├── account
│ │ │ └── index.ts
│ │ └── counter
│ │ │ └── index.ts
│ └── tasks
│ │ ├── account
│ │ └── index.ts
│ │ └── counter
│ │ └── index.ts
├── service
│ └── auth
│ │ ├── __test__
│ │ └── auth.test.ts
│ │ └── index.ts
├── stateTypes.ts
└── store
│ └── index.ts
├── tsconfig.json
├── tsconfig.server.json
└── types
└── index.d.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | [
4 | "module-resolver", {
5 | "alias": {
6 | "~": "./src"
7 | }
8 | }
9 | ]
10 | ],
11 | "env": {
12 | "development": {
13 | "presets": [
14 | "next/babel"
15 | ],
16 | "plugins": [
17 | "styled-jsx/babel-test"
18 | ]
19 | },
20 | "production": {
21 | "presets": [
22 | "next/babel"
23 | ],
24 | "plugins": [
25 | "styled-jsx/babel-test"
26 | ]
27 | },
28 | "test": {
29 | "presets": [
30 | "next/babel"
31 | ],
32 | "plugins": [
33 | "styled-jsx/babel-test"
34 | ]
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | aws-ecr: circleci/aws-ecr@6.8.2
5 | node: circleci/node@2.0
6 |
7 | workflows:
8 | test:
9 | jobs:
10 | - node/test:
11 | version: 12.16.3
12 | filters:
13 | branches:
14 | ignore: master
15 |
16 | build_and_push_image:
17 | jobs:
18 | - node/test:
19 | version: 12.16.3
20 | filters:
21 | branches:
22 | only: master
23 |
24 | - request-build_and_push:
25 | requires:
26 | - node/test
27 | type: approval
28 |
29 | - aws-ecr/build-and-push-image:
30 | name: aws-ecr/build-and-push-app-image
31 | requires:
32 | - request-build_and_push
33 | context: ecr-sample
34 | account-url: AWS_ECR_ACCOUNT_URL
35 | aws-access-key-id: AWS_ACCESS_KEY_ID
36 | aws-secret-access-key: AWS_SECRET_ACCESS_KEY
37 | region: AWS_REGION
38 | repo: "${REPO_NAME}"
39 | tag: "latest-app"
40 | dockerfile: "./docker/app/Dockerfile"
41 |
42 | - aws-ecr/build-and-push-image:
43 | name: aws-ecr/build-and-push-web-image
44 | requires:
45 | - request-build_and_push
46 | context: ecr-sample
47 | account-url: AWS_ECR_ACCOUNT_URL
48 | aws-access-key-id: AWS_ACCESS_KEY_ID
49 | aws-secret-access-key: AWS_SECRET_ACCESS_KEY
50 | region: AWS_REGION
51 | repo: "${REPO_NAME}"
52 | tag: "latest-web"
53 | path: "./docker/web"
54 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 4
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | # Hygen Template
16 | [*.t]
17 | insert_final_newline = false
18 |
19 | [{*yml, *.yaml}]
20 | indent_size = 2
21 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | **/*.js
3 | .next
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "project": "./tsconfig.json",
5 | "tsconfigRootDir": "."
6 | },
7 | "plugins": [
8 | "@typescript-eslint",
9 | "react"
10 | ],
11 | "env": {
12 | "es6": true,
13 | "browser": true
14 | },
15 | "extends": [
16 | "plugin:@typescript-eslint/recommended",
17 | "plugin:react/recommended",
18 | "prettier"
19 | ],
20 | "rules": {
21 | "@typescript-eslint/explicit-function-return-type": [
22 | "off",
23 | { "allowExpressions": true }
24 | ],
25 | "@typescript-eslint/explicit-member-accessibility": [
26 | "error",
27 | { "accessibility": "no-public" }
28 | ],
29 | "@typescript-eslint/explicit-module-boundary-types": [
30 | "off",
31 | { "allowTypedFunctionExpressions": false }
32 | ],
33 | "react/prop-types": [
34 | "off"
35 | ],
36 | "@typescript-eslint/naming-convention" :[
37 | "error",
38 | {
39 | "selector": "default",
40 | "format": ["camelCase"]
41 | },
42 | {
43 | "selector": "variable",
44 | "format": ["PascalCase", "camelCase", "UPPER_CASE"],
45 | "leadingUnderscore": "allow"
46 | },
47 | {
48 | "selector": "property",
49 | "format": ["PascalCase", "camelCase", "UPPER_CASE", "snake_case"]
50 | },
51 | {
52 | "selector": "parameter",
53 | "format": ["PascalCase", "camelCase"],
54 | "leadingUnderscore": "allow"
55 | },
56 | {
57 | "selector": "typeLike",
58 | "format": ["PascalCase"]
59 | }
60 | ]
61 | },
62 | "settings": {
63 | "react": {
64 | "version": "detect"
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build and push images to ECR
2 | on:
3 | push:
4 | branches:
5 | - 'hogehoge' # Enter any branch name
6 |
7 | jobs:
8 | build:
9 | name: build and push docker image
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v1
14 |
15 | - name: Configure AWS Credentials
16 | uses: aws-actions/configure-aws-credentials@v1
17 | with:
18 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
19 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
20 | aws-region: ${{ secrets.AWS_REGION }}
21 |
22 | - name: Login to Amazon ECR
23 | id: login-ecr
24 | uses: aws-actions/amazon-ecr-login@v1
25 |
26 | - name: Run build and Push images
27 | env:
28 | ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}
29 | ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
30 | run: |
31 | docker build --target production -t $ECR_REGISTRY/$ECR_REPOSITORY:latest-app -f docker/app/Dockerfile .
32 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest-app
33 |
34 | - name: Run build and Push Web image
35 | env:
36 | ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}
37 | ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
38 | run: |
39 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest-app -f docker/web/Dockerfile .
40 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest-web
41 |
--------------------------------------------------------------------------------
/.github/workflows/ncu.yml:
--------------------------------------------------------------------------------
1 | name: ncu
2 | on:
3 | pull_request:
4 | type: [opened]
5 |
6 | jobs:
7 | ncu:
8 | name: npm check updates
9 | runs-on: macOS-latest
10 | strategy:
11 | matrix:
12 | node_version: [12]
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v1
16 | - name: Setup node ${{ matrix.node_version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node_version }}
20 |
21 | - name: Install ncu
22 | run: |
23 | npm install -D npm-check-updates
24 |
25 | - name: Report PR comment
26 | uses: actions/github-script@v3
27 | with:
28 | github-token: ${{secrets.GITHUB_TOKEN}}
29 | script: |
30 | const pr = context.payload.pull_request;
31 | if (!pr) {
32 | console.log('github.context.payload.pull_request not exist');
33 | return;
34 | }
35 |
36 | const ncu = require(`${process.env.GITHUB_WORKSPACE}/node_modules/npm-check-updates/lib/index.js`);
37 | const upgraded = await ncu.run();
38 |
39 | let bodyMessage;
40 | if (Object.keys(upgraded).length > 0) {
41 | bodyMessage = `⚠️ Check dependencies to upgrade: ${JSON.stringify(
42 | upgraded
43 | )}`;
44 | } else {
45 | bodyMessage = '👍 All the latest modules 🙆♀️';
46 | }
47 |
48 | await github.issues.createComment({
49 | issue_number: context.issue.number,
50 | owner: context.repo.owner,
51 | repo: context.repo.repo,
52 | body: bodyMessage
53 | })
--------------------------------------------------------------------------------
/.github/workflows/testing.yml:
--------------------------------------------------------------------------------
1 | name: testing
2 | on: push
3 | jobs:
4 | test:
5 | name: Node ${{ matrix.node_version }} testing
6 | runs-on: macOS-latest
7 | strategy:
8 | matrix:
9 | node_version: [12]
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v1
13 | - name: Setup node ${{ matrix.node_version }}
14 | uses: actions/setup-node@v1
15 | with:
16 | node-version: ${{ matrix.node_version }}
17 |
18 | - name: Run npm install and testing
19 | run: |
20 | npm install
21 | npm run test
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directories
2 | node_modules/
3 |
4 | # next.js build output
5 | .next
6 | dist
7 |
8 | # Logs
9 | logs
10 | *.log
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 |
15 | # System Files
16 | .DS_Store
17 | Thumbs.db
18 |
19 | # VS code
20 | .vscode
21 |
22 | # WebStorm
23 | .idea
24 |
25 | # jest
26 | coverage
27 |
28 | # Environment Variables
29 | .env
30 |
--------------------------------------------------------------------------------
/.huskyrc:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "pre-commit": "lint-staged"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.hygen.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | templates: `${__dirname}/.hygen`
3 | }
4 |
--------------------------------------------------------------------------------
/.hygen/generate/component/component.test.tsx.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: 'src/<%= type === "pages" ? `pages/__test__/${name}` : `components/${h.changeCase.pascal(name)}/__test__/${h.changeCase.pascal(name)}` %>.test.tsx'
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | Props = Name + 'Props';
7 | -%>
8 | /* eslint-env jest */
9 | import * as React from 'react';
10 | import { render } from '@testing-library/react';
11 |
12 | import <%= Name %>, { <%= Props %> } from '~/components/<%= Name %>';
13 |
14 | describe('<%= name %>', () => {
15 | let props: <%= Props %>;
16 |
17 | // Initialize
18 | beforeEach(() => {
19 | props = {};
20 | });
21 |
22 | it('renders without errors', () => {
23 | const { container } = render(<<%= Name %> {...props} />);
24 | expect(container).not.toBeNull();
25 | });
26 | });
--------------------------------------------------------------------------------
/.hygen/generate/component/component.tsx.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: '<%= type === "pages" ? `${type}/${name}.tsx` : `src/components/${h.changeCase.pascal(name)}/index.tsx` %>'
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | Props = Name + 'Props';
7 | -%>
8 | import * as React from 'react';
9 |
10 | export type <%= Props %> = {};
11 |
12 | const <%= Name %>: React.FC<<%= Props %>> = () => {
13 | return
Functional Component
;
14 | };
15 |
16 | export default <%= Name %>;
--------------------------------------------------------------------------------
/.hygen/generate/component/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'select',
4 | name: 'type',
5 | message: 'What is the component type?',
6 | choices: ['pages', 'default']
7 | },
8 | {
9 | type: 'input',
10 | name: 'name',
11 | message: 'What is the component name?',
12 | validate: answer => {
13 | if (answer !== '') {
14 | return true;
15 | }
16 | }
17 | }
18 | ];
19 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/action.test.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actions/<%= name %>/__test__/<%= name %>.test.ts
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | NAME = h.changeCase.upper(name);
7 | Actions = name + 'Actions';
8 | State = Name + 'State';
9 | -%>
10 | /* eslint-env jest */
11 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant';
12 | import { <%= Actions %> } from '~/actions';
13 | import { <%= State %> } from '~/stateTypes';
14 |
15 | describe('<%= Name %> actions', () => {
16 | it('should create get <%= name %> action', () => {
17 | const expectedAction = {
18 | type: GET_<%= NAME %>
19 | };
20 |
21 | expect(<%= Actions %>.get<%= Name %>()).toEqual(expectedAction);
22 | });
23 |
24 | it('should create update <%= name %> action', () => {
25 | const expectedPayload: <%= State %> = {
26 | value: 'hoge'
27 | };
28 | const expectedAction = {
29 | type: UPDATE_<%= NAME %>,
30 | payload: {
31 | value: expectedPayload.value
32 | }
33 | };
34 |
35 | expect(<%= Actions %>.update<%= Name %>(expectedPayload)).toEqual(
36 | expectedAction
37 | );
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/action.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actions/<%= name %>/index.ts
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | NAME = h.changeCase.upper(name);
7 | ActionTypes = Name + 'ActionTypes';
8 | State = Name + 'State';
9 | -%>
10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant';
11 | import { <%= ActionTypes %> } from '~/actionTypes';
12 | import { <%= State %> } from '~/stateTypes';
13 |
14 | export const get<%= Name %> = (): <%= ActionTypes %> => ({
15 | type: GET_<%= NAME %>
16 | });
17 |
18 | export const update<%= Name %> = (payload: <%= State %>): <%= ActionTypes %> => ({
19 | type: UPDATE_<%= NAME %>,
20 | payload,
21 | });
22 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/actionType.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actionTypes/<%= name %>.ts
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | NAME = h.changeCase.upper(name);
7 | ActionTypes = Name + 'ActionTypes';
8 | Action = Name + 'Action';
9 | -%>
10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant';
11 | import { <%= Name %>State } from '~/stateTypes';
12 |
13 | interface Get<%= Action %> {
14 | type: typeof GET_<%= NAME %>;
15 | }
16 |
17 | interface Update<%= Action %> {
18 | type: typeof UPDATE_<%= NAME %>;
19 | payload: <%= Name %>State;
20 | }
21 |
22 | export type <%= ActionTypes %> = Get<%= Action %> | Update<%= Action %>;
23 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_actionTypes.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actionTypes/index.ts
3 | inject: true
4 | append: true
5 | skip_if: <%= name %>
6 | ---
7 | export * from './<%= name %>';
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_actions.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actions/index.ts
3 | inject: true
4 | prepend: true
5 | skip_if: './<%= name %>'
6 | ---
7 | import * as <%= name %>Actions from './<%= name %>';
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_actions2.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/actions/index.ts
3 | inject: true
4 | after: 'export {'
5 | skip_if: '<%= name %>Actions,'
6 | ---
7 | <%= name %>Actions,
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_constant.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/constant.ts
3 | inject: true
4 | append: true
5 | skip_if: <%= h.changeCase.upper(name) %>
6 | ---
7 | <% NAME = h.changeCase.upper(name) -%>
8 |
9 | export const GET_<%= NAME %> = 'GET_<%= NAME %>';
10 | export const UPDATE_<%= NAME %> = 'UPDATE_<%= NAME %>';
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_reducers.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/reducers/reducers.ts
3 | inject: true
4 | append: true
5 | skip_if: <%= name %>
6 | ---
7 | export { <%= name %> } from '~/reducers/<%= name %>';
--------------------------------------------------------------------------------
/.hygen/generate/redux/inject_stateTypes.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/stateTypes.ts
3 | inject: true
4 | append: true
5 | skip_if: <%= h.changeCase.pascal(name) %>
6 | ---
7 |
8 | export interface <%= h.changeCase.pascal(name) %>State {
9 | value: string;
10 | }
--------------------------------------------------------------------------------
/.hygen/generate/redux/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'What is the base name of reducer, action?',
6 | validate: answer => {
7 | if (answer !== '') {
8 | return true;
9 | }
10 | }
11 | }
12 | ];
13 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/reducer.test.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/reducers/<%= name %>/__test__/<%= name %>.test.ts
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | State = Name + 'State';
7 | -%>
8 | import snapshotDiff from 'snapshot-diff';
9 | import { <%= name %>Actions } from '~/actions';
10 |
11 | import { initial<%= Name %>, <%= name %> } from '~/reducers/<%= name %>';
12 | import { <%= State %> } from '~/stateTypes';
13 |
14 | const testing<%= Name %>: <%= State %> = {
15 | value: 'hoge'
16 | };
17 |
18 | describe('<%= Name %> reducer', () => {
19 | it('update <%= name %>', () => {
20 | expect(
21 | snapshotDiff(
22 | initial<%= Name %>,
23 | <%= name %>(initial<%= Name %>, <%= name %>Actions.update<%= Name %>(testing<%= Name %>))
24 | )
25 | ).toMatchSnapshot();
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/.hygen/generate/redux/reducer.ts.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/reducers/<%= name %>/index.ts
3 | ---
4 | <%
5 | Name = h.changeCase.pascal(name);
6 | NAME = h.changeCase.upper(name);
7 | ActionTypes = Name + 'ActionTypes';
8 | State = Name + 'State';
9 | -%>
10 | import { GET_<%= NAME %>, UPDATE_<%= NAME %> } from '~/constant';
11 | import { <%= ActionTypes %> } from '~/actionTypes';
12 | import { <%= State %> } from '~/stateTypes';
13 |
14 | export const initial<%= Name %>: <%= State %> = {
15 | value: ''
16 | };
17 |
18 | export const <%= name %> = (
19 | state = initial<%= Name %>,
20 | action: <%= ActionTypes %>
21 | ): <%= State %> => {
22 | switch (action.type) {
23 | case GET_<%= NAME %>:
24 | return state;
25 |
26 | case UPDATE_<%= NAME %>:
27 | return { ...state, ...action.payload };
28 |
29 | default:
30 | return state;
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 12.16.3
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "semi": true,
4 | "singleQuote": true,
5 | "useTabs": false,
6 | "tabWidth": 4,
7 | "trailingComma": "none"
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Kotaro Chiba, Hiroaki Sasaki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js with Typescript
2 | [Next.js](https://nextjs.org/) simple boiler plate.
3 |
4 | - [Motivation](#motivation)
5 | 1. [Practical](#1-practical)
6 | 2. [Simple](#2-simple)
7 | 3. [Type annotation](#3-type-annotation)
8 | - [Spec](#wpec)
9 | - [Directory](#directory)
10 | - [Environment](#environment)
11 | - [How to use](#how-to-use)
12 | - [Test policy](#test-policy)
13 | - [Component](#component)
14 | - [Redux / Redux-saga](#redux--redux-saga)
15 | - [Others](#others)
16 |
17 |
18 | ## Motivation
19 | ### 1. Practical
20 | Most of Web FrontEnd App need a web server.
21 | This project deferred node.js (Koa.js) web server is prepared.
22 |
23 | ### 2. Simple
24 | As mush as possible simple archtecture.
25 | It is clear what you are doing by looking at the code.
26 |
27 | #### Flux
28 | No business logic is brought into Action and Reducer.
29 | Business logic concentrates on Saga tasks.
30 |
31 | ##### Action
32 | - Naming "actually happend"
33 | - As a result, REST-like naming
34 | - No operation payload
35 | - Action has two opreration
36 | 1. Start any redux-saga task
37 | 2. Deliver `Action` type and payload to reducer
38 |
39 | ##### Reducer
40 | - Only do the work of updating which state for each `Action`
41 | - No operation payload
42 |
43 | ##### Middleware (Redux-saga)
44 | - Always start a task with an `Action`
45 | - End the task with `Action`, or let the `routing process` finish the task
46 | - Asynchronous processing uses the call API
47 | - Can operation payload (※ keep the following promise)
48 | 1. It is possible to take a value from `State` (using select API)
49 | 2. It is also possible to manipulate the value retrieved from `State` in the task
50 | 3. However, state must be updated via `Action => Reducer` (does not update `State` directly with the manipulated value)
51 |
52 |
53 |
54 | 
55 |
56 | ### 3. Type annotation
57 | - Use typescript
58 |
59 | ## Spec
60 | - Application
61 | - Next.js
62 | - HttpClient
63 | - [Isomorphic-unfetch](https://github.com/developit/unfetch/tree/master/packages/isomorphic-unfetch)
64 | - Flux
65 | - [Redux](https://github.com/reduxjs/redux)
66 | - Middleware
67 | - [Redux-saga](https://github.com/redux-saga/redux-saga)
68 | - Web server
69 | - [Koa.js](https://github.com/koajs/koa)
70 | - Linter
71 | - [Eslint](https://github.com/eslint/eslint)
72 | - Code format
73 | - [Prettier](https://github.com/prettier/prettier)
74 | - Pre commit
75 | - [Husky](https://github.com/typicode/husky)
76 | - [Lint-staged](https://github.com/okonet/lint-staged)
77 | - Language
78 | - [Typescript](https://www.typescriptlang.org/)
79 | - Logging
80 | - [Bunyan](https://github.com/trentm/node-bunyan)
81 | - Web server logger
82 | - Unit test
83 | - [Jest](https://github.com/facebook/jest)
84 | - [Testing-library](https://github.com/testing-library/react-testing-library)
85 | - Component testing
86 | - [Snapshot-diff](https://github.com/jest-community/snapshot-diff)
87 | - Reducer testing
88 | - [Redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan)
89 | - Saga task testing
90 | - Code Generator
91 | - [Hygen](http://www.hygen.io/)
92 | - Generating code of basic component, redux actions and reducers
93 |
94 | ## Directory
95 | ```
96 | .
97 | ├─ docker #
98 | │ ├─ app # FrontEnd App (multi stage build)
99 | │ └─ web # nginx (proxy)
100 | │
101 | ├─ pages # web pages
102 | │ └─ api # BFF api
103 | │
104 | ├─ server # web server
105 | │
106 | └─ src #
107 | ├─ actions # redux: action
108 | ├─ components # react: component
109 | ├─ hooks # react: hooks
110 | ├─ pages # only next.js pages testing
111 | ├─ reducers # redux: reducer
112 | ├─ sagas # redux[middleware]: redux-saga
113 | ├─ service # libs | utils
114 | └─ store # redux: configure store
115 | ```
116 | The testing directory is distributed in parallel with the directory that has each function.
117 | ```
118 | .
119 | ├─ hoge
120 | │ ├─ __test__ # hoge testing directory
121 | ```
122 |
123 | ## Environment
124 | ```
125 | - node.js >= 12.14.1
126 | - next.js >= 9.3.1
127 | - docker
128 | - engine >= 19.03.x
129 | - compose >= 3.6
130 | ```
131 |
132 | ## How to use
133 | ### Quick development start
134 | ```
135 | 1. npm i install
136 | 2. docker-compose up
137 |
138 | > http://localhost(:80)
139 | ```
140 |
141 | This project use nginx for reverse proxy and static resource cache control.
142 |
143 | ```
144 | localhost:80 localhost:3000
145 | ----------- ----------------
146 | --> | nginx | --> | app |
147 | | [proxy] | | [next + koa] |
148 | ----------- ----------------
149 | ```
150 |
151 | ### Code Generator
152 | you may create React Component / Redux Action, Reducers from Hygen template.
153 |
154 | ```shell script
155 | npm run gen component
156 | npm run gen redux
157 | ```
158 |
159 | ### Deployment to ECR
160 | You can choose from two methods
161 |
162 | - Github Actions
163 | - CircleCI
164 |
165 | ## Test policy
166 | ### Component
167 | Mainly use [testing-library/react](https://github.com/testing-library/react-testing-library), [react-hooks](https://github.com/testing-library/react-hooks-testing-library).
168 |
169 | #### Checkpoints
170 | 1. After fire event, compare target state or props differences
171 | 2. With or without props, examine the state of the output HTMLElement contained by Component
172 |
173 | ### Redux / Redux-saga
174 | #### Action [priority: *LOW*]
175 | Normaly **simple unit testing**.
176 | Because of actions is pure function and have types for I/O,
177 | If types is safe, there is without having to do little testing.
178 |
179 | #### Reducer [priority: *MIDDLE*]
180 | Reducer testing have two types, **unit** and **snapshot** testing.
181 | Unit testing is compare initial state to updated state.
182 | Snapshot testing is check the difference visually for target state.
183 |
184 | ##### Why do snapshot testing ?
185 | The reducer updates the state (object) on the defined reducer scope for any action.
186 | In other words, it is necessary to check the change of state only for the variation of action, and it is not realistic to cover by comparing the values.
187 | Therefore, we propose to perform a regression test for any action.
188 | If it is a regression, you can always check the state of the state visually with a pull request.
189 | If the specs change, the team will be able to discuss the changes in the PR.
190 | No need to consider values in regression tests. Just see what the reducer changes the state with the code.
191 |
192 | #### Redux-saga [priority: *HIGH*]
193 | Redux-saga testing is integration test using [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan).
194 | If there is a unit test that can be separately performed during the task, let's do that as a separate test.
195 | Since the I/O of the task is guaranteed by the benefits of TypeScript, it is recommended that you evaluate whether the actual processing of the task is as intended.
196 |
197 | ### Others
198 | Basically, do a unit test.
199 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.6"
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: ./docker/app/Dockerfile
8 | target: development
9 | args:
10 | BUILD_ENV: development
11 | command: sh -c "npm install && npm run dev"
12 | volumes:
13 | - .:/app:delegated
14 | - node_modules:/app/node_modules:delegated
15 | environment:
16 | API_BASE_URL: http://localhost
17 |
18 | nginx:
19 | build: ./docker/web
20 | ports:
21 | - 80:80
22 | depends_on:
23 | - app
24 | restart: on-failure
25 |
26 | volumes:
27 | node_modules:
28 | driver: 'local'
--------------------------------------------------------------------------------
/docker/app/Dockerfile:
--------------------------------------------------------------------------------
1 | #=================
2 | # development image
3 | #=================
4 | FROM node:12.14.1-alpine AS development
5 |
6 | WORKDIR /app
7 |
8 | COPY package*.json ./
9 | RUN npm install -g npm && \
10 | npm install
11 |
12 | ARG BUILD_ENV=production
13 | ENV NODE_ENV=${BUILD_ENV}
14 |
15 | COPY . ./
16 |
17 | EXPOSE 3000
18 |
19 | #=================
20 | # production image
21 | # CI building test image
22 | #=================
23 | FROM node:12.14.1-alpine AS builder
24 |
25 | WORKDIR /app
26 |
27 | COPY --from=development ./app .
28 |
29 | RUN npm run build
30 |
31 | #=================
32 | FROM node:12.14.1-alpine AS production
33 |
34 | WORKDIR /app
35 |
36 | COPY --from=builder ./app/package.json .
37 | COPY --from=builder ./app/package-lock.json .
38 | COPY --from=builder ./app/next.config.js .
39 | COPY --from=builder ./app/.next ./.next
40 | COPY --from=builder ./app/dist ./dist
41 |
42 | RUN npm install -g npm && \
43 | npm install --production
44 |
45 | ENV NODE_ENV=production
46 |
47 | EXPOSE 3000
48 |
49 | CMD ["npm", "run", "start"]
--------------------------------------------------------------------------------
/docker/web/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.17-alpine
2 |
3 | ADD ./nginx/default.conf /etc/nginx/conf.d/default.conf
4 |
5 | EXPOSE 80
6 |
7 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/docker/web/nginx/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 |
5 | access_log off;
6 | error_log off;
7 |
8 | gzip on;
9 | gzip_types text/css text/javascript application/javascript application/json image/gif image/png image/jpeg image/svg+xml;
10 |
11 | server_tokens off;
12 |
13 | # static resources cache
14 | location ~ .*\.(jpe?g|gif|png|ico) {
15 | expires 1d;
16 | access_log off;
17 | }
18 |
19 | # upstream
20 | location / {
21 | proxy_pass http://app:3000;
22 | proxy_redirect off;
23 | proxy_set_header Host $host;
24 | proxy_set_header X-Real-IP $remote_addr;
25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
26 | proxy_set_header X-Forwarded-Proto $scheme;
27 | proxy_read_timeout 1m;
28 | proxy_connect_timeout 1m;
29 | }
30 | }
--------------------------------------------------------------------------------
/docs/images/flux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uruha/next-with-ts/94aae9774660f748f0355a9af8a91729396fadf9/docs/images/flux.png
--------------------------------------------------------------------------------
/docs/images/type.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uruha/next-with-ts/94aae9774660f748f0355a9af8a91729396fadf9/docs/images/type.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const TEST_REGEX = '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$';
2 |
3 | module.exports = {
4 | testRegex: TEST_REGEX,
5 | transform: {
6 | '^.+\\.tsx?$': 'ts-jest'
7 | },
8 | testPathIgnorePatterns: [
9 | '/.next/',
10 | '/out/',
11 | '/node_modules/',
12 | '/dist/'
13 | ],
14 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
15 | collectCoverage: true,
16 | moduleNameMapper: {
17 | '^~/(.*)$': '/src/$1',
18 | '^~pages/(.*)$': '/pages/$1'
19 | },
20 | globals: {
21 | 'ts-jest': {
22 | babelConfig: true,
23 | tsconfig: 'jest.tsconfig.json'
24 | }
25 | },
26 | automock: false,
27 | setupFiles: ['./setupJestFetchMock.js']
28 | };
29 |
--------------------------------------------------------------------------------
/jest.tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "jsx": "react",
5 | "lib": [
6 | "es2019",
7 | "dom"
8 | ],
9 | "module": "commonjs",
10 | "esModuleInterop": true,
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": true,
15 | "preserveConstEnums": true,
16 | "removeComments": true,
17 | "skipLibCheck": true,
18 | "sourceMap": false,
19 | "strict": true,
20 | "target": "es2019",
21 | "baseUrl": "./",
22 | "paths": {
23 | "~/*": ["src/*"],
24 | "~pages/*": ["pages/*"]
25 | },
26 | "rootDirs": ["./pages", "./src"],
27 | "typeRoots": [
28 | "node_modules/@types",
29 | "./types"
30 | ]
31 | },
32 | "exclude": [
33 | "node_modules",
34 | "out",
35 | ".next"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const { API_BASE_URL } = process.env;
2 |
3 | module.exports = {
4 | poweredByHeader: false,
5 | publicRuntimeConfig: {
6 | API_BASE_URL
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["server"],
3 | "exec": "ts-node --project tsconfig.server.json server/index.ts",
4 | "ext": "js ts"
5 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-with-ts",
3 | "version": "0.1.0",
4 | "description": "next.js with typescript boiler plate",
5 | "repository": {
6 | "type": "git",
7 | "url": "git@github.com:uruha/next-with-ts.git"
8 | },
9 | "scripts": {
10 | "dev": "nodemon",
11 | "build": "next build && tsc --project tsconfig.server.json",
12 | "start": "cross-env NODE_ENV=production node dist/index.js",
13 | "type:check": "tsc",
14 | "lint:check": "eslint . --ext .ts,.tsx",
15 | "lint:fmt": "eslint . --fix --ext .ts,.tsx",
16 | "format": "prettier '{pages,server,src}/**/*.{js,ts,tsx}' --write",
17 | "test": "jest",
18 | "gen": "hygen generate"
19 | },
20 | "lint-staged": {
21 | "*.{ts,tsx}": [
22 | "eslint . --ext .ts,.tsx --fix"
23 | ]
24 | },
25 | "keywords": [
26 | "next.js",
27 | "typescript"
28 | ],
29 | "author": "Kotaro Chiba",
30 | "license": "MIT",
31 | "dependencies": {
32 | "@koa/router": "^10.0.0",
33 | "babel-plugin-module-resolver": "^4.0.0",
34 | "bunyan": "^1.8.14",
35 | "cross-env": "^7.0.2",
36 | "jwt-decode": "^3.0.0",
37 | "koa": "^2.13.0",
38 | "koa-helmet": "^6.0.0",
39 | "module-alias": "^2.2.2",
40 | "next": "^11.1.1",
41 | "next-redux-wrapper": "^6.0.2",
42 | "react": "^17.0.0",
43 | "react-dom": "^17.0.0",
44 | "react-redux": "^7.2.1",
45 | "redux": "^4.0.5",
46 | "redux-logger": "^3.0.6",
47 | "redux-saga": "^1.1.3",
48 | "universal-cookie": "^4.0.4"
49 | },
50 | "devDependencies": {
51 | "@testing-library/react": "^11.1.0",
52 | "@testing-library/react-hooks": "^5.1.1",
53 | "@types/bunyan": "^1.8.6",
54 | "@types/enzyme": "^3.10.7",
55 | "@types/jest": "^26.0.15",
56 | "@types/jwt-decode": "^3.1.0",
57 | "@types/koa": "^2.11.6",
58 | "@types/koa__router": "^8.0.3",
59 | "@types/koa-helmet": "^6.0.1",
60 | "@types/koa-router": "^7.4.1",
61 | "@types/react": "^17.0.3",
62 | "@types/react-dom": "^17.0.3",
63 | "@types/react-redux": "^7.1.9",
64 | "@types/redux-logger": "^3.0.8",
65 | "@types/universal-cookie": "^3.0.0",
66 | "@typescript-eslint/eslint-plugin": "^4.5.0",
67 | "@typescript-eslint/parser": "^4.5.0",
68 | "@typescript-eslint/typescript-estree": "^4.5.0",
69 | "babel-jest": "^26.6.0",
70 | "enzyme": "^3.11.0",
71 | "eslint": "^7.11.0",
72 | "eslint-config-prettier": "^8.2.0",
73 | "eslint-plugin-react": "^7.21.5",
74 | "husky": "^4.3.0",
75 | "hygen": "^6.0.4",
76 | "jest": "^26.6.0",
77 | "jest-fetch-mock": "^3.0.3",
78 | "lint-staged": "^10.4.2",
79 | "nodemon": "^2.0.6",
80 | "prettier": "^2.1.2",
81 | "redux-saga-test-plan": "^4.0.1",
82 | "snapshot-diff": "^0.8.1",
83 | "ts-jest": "^26.4.1",
84 | "ts-node": "^9.0.0",
85 | "typescript": "^4.0.3"
86 | },
87 | "_moduleAliases": {
88 | "~": "src",
89 | "~pages": "pages"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import App, { AppContext } from 'next/app';
3 |
4 | import { Dispatch } from 'redux';
5 | import { wrapper, StoreWithSaga } from '~/store';
6 |
7 | import { counterActions } from '~/actions';
8 | import { NextPageContext } from 'next';
9 |
10 | interface NextPageContextWithStore extends NextPageContext {
11 | store: StoreWithSaga;
12 | }
13 |
14 | class CustomApp extends App {
15 | static async getInitialProps({ Component, ctx }: AppContext) {
16 | let pageProps = {};
17 |
18 | if (Component.getInitialProps) {
19 | pageProps = await Component.getInitialProps(ctx);
20 | }
21 |
22 | /**
23 | * @see https://github.com/uruha/next-with-ts/blob/af76ea71b8aeb5d3d970834e57f065a382cf71d6/src/store/index.ts#L39
24 | * Can't extended AppContext to include CustomProps Type,
25 | * tentatively forcibly expand ctx.store via `as`.
26 | */
27 | await (ctx as NextPageContextWithStore).store.execSagaTask(
28 | !!ctx.req,
29 | (dispatch: Dispatch) => {
30 | dispatch(counterActions.increment(1));
31 | }
32 | );
33 |
34 | return { pageProps };
35 | }
36 |
37 | render() {
38 | const { Component, pageProps } = this.props;
39 |
40 | return ;
41 | }
42 | }
43 |
44 | export default wrapper.withRedux(CustomApp);
45 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Document, {
3 | Html,
4 | Head,
5 | Main,
6 | NextScript,
7 | DocumentContext
8 | } from 'next/document';
9 |
10 | import crypto from 'crypto';
11 |
12 | const cspHashOf = (text: string) => {
13 | const hash = crypto.createHash('sha256');
14 | hash.update(text);
15 | return `'sha256-${hash.digest('base64')}'`;
16 | };
17 |
18 | class CustomDocument extends Document {
19 | static async getInitialProps(ctx: DocumentContext) {
20 | const initialProps = await Document.getInitialProps(ctx);
21 | return { ...initialProps };
22 | }
23 |
24 | render() {
25 | let csp = `default-src 'self'; script-src 'self' ${cspHashOf(
26 | NextScript.getInlineScriptSource(this.props)
27 | )}`;
28 | if (process.env.NODE_ENV !== 'production') {
29 | csp = `style-src 'self' 'unsafe-inline'; font-src 'self' data:; default-src 'self'; script-src 'unsafe-eval' 'self' ${cspHashOf(
30 | NextScript.getInlineScriptSource(this.props)
31 | )}`;
32 | }
33 |
34 | return (
35 |
36 |
37 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | export default CustomDocument;
54 |
--------------------------------------------------------------------------------
/pages/_error.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { NextPageContext } from 'next';
3 |
4 | interface ErrorProps {
5 | statusCode?: number;
6 | }
7 |
8 | class CustomError extends React.Component {
9 | static async getInitialProps({ res, err }: NextPageContext) {
10 | const statusCode = res ? res.statusCode : err ? err.stack : null;
11 |
12 | return { statusCode };
13 | }
14 |
15 | render() {
16 | const { statusCode } = this.props;
17 | return (
18 |
19 |
20 | {statusCode
21 | ? `An error ${statusCode} occurred on server`
22 | : 'An error occurred on client'}
23 |
24 |
25 | );
26 | }
27 | }
28 |
29 | export default CustomError;
30 |
--------------------------------------------------------------------------------
/pages/api/account.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { Account } from '~/modelTypes';
3 |
4 | import Cookies from 'universal-cookie';
5 | import Auth from '~/service/auth';
6 |
7 | const pseudoAccount: Account = {
8 | email: 'kotaro@example.com',
9 | nickname: 'Jankovic'
10 | };
11 |
12 | export default (req: NextApiRequest, res: NextApiResponse) => {
13 | const cookies = new Cookies(req.headers.cookie);
14 | const _token = cookies.get('_token');
15 |
16 | if (!_token) {
17 | res.setHeader('Content-Type', 'application/json');
18 | res.statusCode = 200;
19 | res.end(JSON.stringify({ email: '', nickname: '' }));
20 | return;
21 | }
22 |
23 | const accessAuth = new Auth(_token);
24 |
25 | if (!accessAuth.isAuthenticated) {
26 | res.status(401).send('Expire is over.');
27 | return;
28 | }
29 |
30 | /**
31 | * @TODO
32 | * GET api `/accounts/me`
33 | */
34 | res.setHeader('Content-Type', 'application/json');
35 | res.setHeader('Authorization', `${accessAuth.authorizationString}`);
36 | res.statusCode = 200;
37 | res.end(JSON.stringify(pseudoAccount));
38 | };
39 |
--------------------------------------------------------------------------------
/pages/api/signin.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { Token } from '~/modelTypes';
3 |
4 | import Cookies from 'universal-cookie';
5 | import Auth from '~/service/auth';
6 |
7 | const pseudoToken: Token = {
8 | access_token:
9 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNoaWJhIEtvdGFybyIsImlhdCI6MTU3NTQ0MzI4MiwiZXhwIjoxNjA5NDI2Nzk5fQ.DHZMvPjPCWMMyqo4v5oRM6ho3Miv4XwAxOr7gJBLyvc',
10 | refresh_token: 'GEbRxBN_pseudoRefreshToken_edjnXbL',
11 | type: 'Bearer'
12 | };
13 |
14 | export default (req: NextApiRequest, res: NextApiResponse) => {
15 | const cookies = new Cookies(req.headers.cookie);
16 | console.log(cookies); // get HttpOnly directive cookie
17 | console.log(req.body); // get request body
18 |
19 | if (req.method === 'POST') {
20 | /**
21 | * @TODO
22 | * POST api `/auth/singin`
23 | */
24 | const accessAuth = new Auth(pseudoToken.access_token);
25 | res.setHeader('Content-Type', 'application/json');
26 | res.setHeader(
27 | 'Set-Cookie',
28 | `_token=${pseudoToken.access_token}; Path=/; Max-Age=${accessAuth.maxAgeAt}; HttpOnly`
29 | );
30 | res.statusCode = 201;
31 | res.end(JSON.stringify({ token: pseudoToken }));
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/pages/api/signout.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 |
3 | // import Cookies from 'universal-cookie';
4 |
5 | export default (req: NextApiRequest, res: NextApiResponse) => {
6 | // const cookies = new Cookies(req.headers.cookie);
7 |
8 | if (req.method === 'POST') {
9 | /**
10 | * @TODO
11 | * POST api `/auth/signout`
12 | */
13 | res.setHeader('Content-Type', 'application/json');
14 | res.setHeader('Set-Cookie', '_token=; Path=/; Max-Age=0; HttpOnly');
15 | res.statusCode = 204;
16 | res.end();
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/pages/api/ua.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { judgeIsMobile } from '~/lib/ua';
3 |
4 | export default (req: NextApiRequest, res: NextApiResponse) => {
5 | const legacyUA = req.headers['user-agent'];
6 | const CHUAM = req.headers['sec-ch-ua-mobile'];
7 |
8 | const isMobile = judgeIsMobile(legacyUA, CHUAM);
9 |
10 | res.setHeader('Content-Type', 'application/json');
11 | res.statusCode = 200;
12 | res.end(JSON.stringify({ isMobile }));
13 | };
14 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Layout from '~/components/Layout';
3 |
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { counterActions } from '~/actions';
6 | import { RootState } from '~/reducers';
7 |
8 | import { CountState } from '~/stateTypes';
9 |
10 | const Index: React.FC = () => {
11 | const counter = useSelector(state => state.counter);
12 | const dispatch = useDispatch();
13 |
14 | return (
15 |
16 | Hello Next.js with Koa.js and Typescript
17 | count: {counter && counter.count}
18 |
19 |
22 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Index;
31 |
--------------------------------------------------------------------------------
/pages/signin.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { FormEvent } from 'react';
3 | import useEmail from '~/hooks/signin/useEmail';
4 | import usePassword from '~/hooks/signin/usePassword';
5 | import Router from 'next/router';
6 | import http from '~/lib/http';
7 |
8 | import { useDispatch } from 'react-redux';
9 | import { accountActions } from '~/actions';
10 |
11 | import { SigninRequest } from '~/modelTypes';
12 |
13 | const Signin: React.FC = () => {
14 | const email = useEmail('');
15 | const password = usePassword('');
16 | const dispatch = useDispatch();
17 |
18 | const handleSubmit = async (e: FormEvent) => {
19 | e.preventDefault();
20 | const signinRequest: SigninRequest = {
21 | email: email.entertedValue,
22 | password: password.entertedValue
23 | };
24 |
25 | try {
26 | const res = await http.post('/api/signin', {
27 | body: JSON.stringify(signinRequest)
28 | });
29 |
30 | if (res.status === 201) {
31 | dispatch(accountActions.getAccount());
32 | Router.push('/');
33 | }
34 | } catch (error) {
35 | console.log(error);
36 | }
37 | };
38 |
39 | return (
40 |
68 | );
69 | };
70 |
71 | export default Signin;
72 |
--------------------------------------------------------------------------------
/pages/todo.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, KeyboardEvent, useState } from 'react';
2 | import TodoLists, { Task } from '~/components/TodoLists';
3 | import { useTodo, useTodoList } from '~/hooks/todo';
4 |
5 | const initialTasksState: Task[] = [];
6 |
7 | const Todo: React.FC = () => {
8 | // 入力フォームの一時保管用
9 | const todo = useTodo('');
10 |
11 | // タスク一覧
12 | const tasks = useTodoList(initialTasksState);
13 |
14 | // 編集モードかどうかのフラグ
15 | const [isEditable, setEditable] = useState(false);
16 |
17 | const handleSubmit = (e: FormEvent) => {
18 | e.preventDefault();
19 | };
20 |
21 | const handleOnKeyDown = (e: KeyboardEvent) => {
22 | // INFO: ENTER Key
23 | if (e.keyCode !== 13) return;
24 |
25 | tasks.add(todo.value);
26 | todo.reset();
27 | };
28 |
29 | const hasTaskList = tasks.list.length > 0;
30 |
31 | return (
32 | <>
33 |
34 |
35 |
今日のやること
36 | {hasTaskList && (
37 |
43 | )}
44 |
45 | {hasTaskList ? (
46 |
52 | ) : (
53 | タスクはありません!
54 | )}
55 |
56 |
76 |
77 |
78 |
130 | >
131 | );
132 | };
133 |
134 | export default Todo;
135 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import Koa, { Context, DefaultState } from 'koa';
2 | import Router from '@koa/router';
3 | import KoaHelmet from 'koa-helmet';
4 | import next from 'next';
5 |
6 | import Logger from 'bunyan';
7 |
8 | const logOptions: Logger.LoggerOptions = {
9 | name: 'app',
10 | streams: [
11 | {
12 | level: 'info',
13 | stream: process.stdout
14 | },
15 | {
16 | level: 'error',
17 | stream: process.stderr
18 | }
19 | ],
20 | serializers: {
21 | req: req => ({
22 | method: req.method,
23 | url: req.url,
24 | referer: req.headers.referer
25 | }),
26 | err: Logger.stdSerializers.err
27 | }
28 | };
29 |
30 | interface CtxWithLogger extends Context {
31 | logger: Logger;
32 | }
33 |
34 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
35 | const dev = process.env.NODE_ENV !== 'production';
36 | const app = next({ dev });
37 | const handle = app.getRequestHandler();
38 |
39 | const logger = Logger.createLogger(logOptions);
40 |
41 | app.prepare()
42 | .then(() => {
43 | const server = new Koa();
44 | const router = new Router();
45 |
46 | server.use(async (ctx, next) => {
47 | ctx.logger = logger;
48 | await next();
49 | });
50 |
51 | router.get('/health', async ctx => {
52 | await ctx.logger.info({ req: ctx.req }, 'HEALTH');
53 | ctx.status = 200;
54 | ctx.type = 'application/json';
55 | ctx.body = JSON.stringify({ uptime: process.uptime() });
56 | });
57 |
58 | router.get(/.*/, async ctx => {
59 | await ctx.logger.info({ req: ctx.req }, 'REQUEST');
60 | await handle(ctx.req, ctx.res);
61 | ctx.respond = false;
62 | });
63 |
64 | router.post(/.*/, async ctx => {
65 | await ctx.logger.info({ req: ctx.req }, 'REQUEST');
66 | await handle(ctx.req, ctx.res);
67 | ctx.respond = false;
68 | });
69 |
70 | server.use(async (ctx, next) => {
71 | ctx.res.statusCode = 200;
72 | await next();
73 | });
74 |
75 | /**
76 | * @MEMO
77 | * CSP (Content Security Policy) is false for Web Server,
78 | * so CSP is controlled by next.js `_document.tsx`
79 | */
80 | server.use(
81 | KoaHelmet({
82 | contentSecurityPolicy: false
83 | })
84 | );
85 | server.use(router.routes());
86 | server.listen(port, () => {
87 | logger.info(`> Ready on localhost:${port}`);
88 | });
89 | })
90 | .catch((err: Error) => logger.error(err, 'Server prepared is faild.'));
91 |
--------------------------------------------------------------------------------
/setupJestFetchMock.js:
--------------------------------------------------------------------------------
1 | require('jest-fetch-mock').enableMocks();
2 | global.fetch.mockResponse(JSON.stringify({ data: 'ok' }));
3 |
--------------------------------------------------------------------------------
/src/__mocks__/next/config.ts:
--------------------------------------------------------------------------------
1 | export default () => ({
2 | publicRuntimeConfig: {}
3 | });
4 |
--------------------------------------------------------------------------------
/src/actionTypes/account.ts:
--------------------------------------------------------------------------------
1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant';
2 | import { Account } from '~/modelTypes';
3 |
4 | interface GetAccountAction {
5 | type: typeof GET_ACCOUNT;
6 | }
7 |
8 | interface UpdateAccountAction {
9 | type: typeof UPDATE_ACCOUNT;
10 | payload: { data: Account };
11 | }
12 |
13 | export type AccountActionTypes = GetAccountAction | UpdateAccountAction;
14 |
--------------------------------------------------------------------------------
/src/actionTypes/counter.ts:
--------------------------------------------------------------------------------
1 | import { INCREMENT, DECREMENT, UPDATE_COUNT } from '~/constant';
2 | import { HYDRATE } from 'next-redux-wrapper';
3 | import { RootState } from '~/reducers';
4 |
5 | interface IncrementAction {
6 | type: typeof INCREMENT;
7 | payload: number;
8 | }
9 |
10 | interface DecrementAction {
11 | type: typeof DECREMENT;
12 | payload: number;
13 | }
14 |
15 | interface UpdateCountAction {
16 | type: typeof UPDATE_COUNT;
17 | payload: number;
18 | }
19 |
20 | interface HydrateCountAction {
21 | type: typeof HYDRATE;
22 | payload: RootState;
23 | }
24 |
25 | export type CounterActionTypes =
26 | | IncrementAction
27 | | DecrementAction
28 | | UpdateCountAction
29 | | HydrateCountAction;
30 |
--------------------------------------------------------------------------------
/src/actionTypes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './account';
2 | export * from './counter';
3 |
--------------------------------------------------------------------------------
/src/actions/account/__test__/account.test.ts:
--------------------------------------------------------------------------------
1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant';
2 | import { Account } from '~/modelTypes';
3 | import { accountActions } from '~/actions';
4 |
5 | describe('Account actions', () => {
6 | it('should create get account action', () => {
7 | const expectedAction = {
8 | type: GET_ACCOUNT
9 | };
10 |
11 | expect(accountActions.getAccount()).toEqual(expectedAction);
12 | });
13 |
14 | it('should create update accont action', () => {
15 | const expectedPayload: Account = {
16 | nickname: 'Test',
17 | email: 'accout@test.com'
18 | };
19 | const expectedAction = {
20 | type: UPDATE_ACCOUNT,
21 | payload: {
22 | data: {
23 | nickname: expectedPayload.nickname,
24 | email: expectedPayload.email
25 | }
26 | }
27 | };
28 |
29 | expect(accountActions.updateAccount(expectedPayload)).toEqual(
30 | expectedAction
31 | );
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/actions/account/index.ts:
--------------------------------------------------------------------------------
1 | import { GET_ACCOUNT, UPDATE_ACCOUNT } from '~/constant';
2 | import { AccountActionTypes } from '~/actionTypes';
3 | import { Account } from '~/modelTypes';
4 |
5 | export const getAccount = (): AccountActionTypes => ({
6 | type: GET_ACCOUNT
7 | });
8 |
9 | export const updateAccount = (account: Account): AccountActionTypes => ({
10 | type: UPDATE_ACCOUNT,
11 | payload: { data: account }
12 | });
13 |
--------------------------------------------------------------------------------
/src/actions/counter/__test__/counter.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import { INCREMENT, DECREMENT } from '~/constant';
3 | import { counterActions } from '~/actions';
4 |
5 | describe('Counter actions', () => {
6 | it('should create increment action', () => {
7 | const wantToIncrementNumber = 1;
8 | const expectedAction = {
9 | type: INCREMENT,
10 | payload: wantToIncrementNumber
11 | };
12 |
13 | expect(counterActions.increment(1)).toEqual(expectedAction);
14 | });
15 |
16 | it('should create decrement action', () => {
17 | const wantToDecrementNumber = 3;
18 | const expectedAction = {
19 | type: DECREMENT,
20 | payload: wantToDecrementNumber
21 | };
22 |
23 | expect(counterActions.decrement(3)).toEqual(expectedAction);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/actions/counter/index.ts:
--------------------------------------------------------------------------------
1 | import { INCREMENT, DECREMENT, UPDATE_COUNT } from '~/constant';
2 | import { CounterActionTypes } from '~/actionTypes';
3 |
4 | export const increment = (number: number): CounterActionTypes => ({
5 | type: INCREMENT,
6 | payload: number
7 | });
8 |
9 | export const decrement = (number: number): CounterActionTypes => ({
10 | type: DECREMENT,
11 | payload: number
12 | });
13 |
14 | export const updateCount = (number: number): CounterActionTypes => ({
15 | type: UPDATE_COUNT,
16 | payload: number
17 | });
18 |
--------------------------------------------------------------------------------
/src/actions/index.ts:
--------------------------------------------------------------------------------
1 | import * as accountActions from './account';
2 | import * as counterActions from './counter';
3 |
4 | // eslint-disable-next-line
5 | export { accountActions, counterActions };
6 |
--------------------------------------------------------------------------------
/src/components/Layout/__test__/Layout.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import * as React from 'react';
3 | import { cleanup } from '@testing-library/react';
4 |
5 | import Layout from '~/components/Layout';
6 |
7 | import { renderWithProvider, withMockedRouter } from '~/lib/testing';
8 |
9 | describe('Layout', () => {
10 | afterEach(cleanup);
11 |
12 | it('render navigation component', () => {
13 | const { container } = renderWithProvider(
14 | withMockedRouter(
15 | {
16 | pathname: '/'
17 | },
18 |
19 | )
20 | );
21 | const navComponent = container.getElementsByTagName('nav');
22 | expect(navComponent.length > 0).toStrictEqual(true);
23 | });
24 |
25 | it('render children component', () => {
26 | const { container } = renderWithProvider(
27 | withMockedRouter(
28 | {
29 | pathname: '/'
30 | },
31 |
32 |
33 |
34 | )
35 | );
36 | const childComponent = container.getElementsByClassName('test');
37 | expect(childComponent.length > 0).toStrictEqual(true);
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/components/Layout/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Head from 'next/head';
3 |
4 | import Nav from '~/components/Nav';
5 |
6 | import { useEffect } from 'react';
7 | import { useRouter } from 'next/router';
8 | import { useSelector, useDispatch } from 'react-redux';
9 | import { RootState } from '~/reducers';
10 | import { AccountState } from '~/stateTypes';
11 | import { accountActions } from '~/actions';
12 |
13 | interface LayoutProps {
14 | title?: string;
15 | }
16 |
17 | const Layout: React.FC = ({
18 | children,
19 | title = 'Default title'
20 | }) => {
21 | const account = useSelector(
22 | state => state.account
23 | );
24 | const hasAccountData = Boolean(account.data.email && account.data.nickname);
25 |
26 | const { pathname } = useRouter();
27 | const dispatch = useDispatch();
28 | useEffect(() => {
29 | /** User Agent detection */
30 | (async () => {
31 | fetch('/api/ua')
32 | .then(r => r.json())
33 | .then(ua => console.log(ua));
34 | })();
35 |
36 | if (pathname !== '/signin') {
37 | if (!hasAccountData) {
38 | dispatch(accountActions.getAccount());
39 | }
40 | }
41 | }, []);
42 |
43 | return (
44 | <>
45 |
46 |
47 |
51 | {title}
52 |
53 |
56 | {children}
57 |
62 | >
63 | );
64 | };
65 |
66 | export default Layout;
67 |
--------------------------------------------------------------------------------
/src/components/Nav/__test__/Nav.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import * as React from 'react';
3 | import Nav from '~/components/Nav';
4 | import { render } from '@testing-library/react';
5 |
6 | describe('Navigation', () => {
7 | it('renders navigation list items collection', () => {
8 | const account = {
9 | data: {
10 | email: 'test@example.com',
11 | nickname: 'Geronimo'
12 | }
13 | };
14 | const hasAccountData = true;
15 | const { container } = render(
16 |
17 | );
18 |
19 | const lists = container.querySelectorAll('.Nav-list li');
20 | expect(lists).toHaveLength(4);
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/Nav/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Link from 'next/link';
3 |
4 | import Signout from '~/components/Signout';
5 | import { AccountState } from '~/stateTypes';
6 |
7 | interface NavProps {
8 | account: AccountState;
9 | hasAccountData: boolean;
10 | }
11 |
12 | const Nav: React.FC = ({ account, hasAccountData }) => (
13 |
51 | );
52 |
53 | export default Nav;
54 |
--------------------------------------------------------------------------------
/src/components/Signout/__test__/index.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import * as React from 'react';
3 | import { cleanup, fireEvent, getByText, render } from '@testing-library/react';
4 | import http from '~/lib/http';
5 | import Signout from '~/components/Signout';
6 |
7 | jest.mock('~/lib/http', () => ({
8 | post: jest.fn(() => ({ status: 204 }))
9 | }));
10 |
11 | describe('Signout', () => {
12 | afterEach(cleanup);
13 |
14 | it('can sign out with the button', async () => {
15 | const { container } = render();
16 |
17 | const button = container.querySelectorAll('button[type="button"]');
18 | expect(button).not.toBeNull();
19 |
20 | button && fireEvent.click(getByText(container, 'signout'));
21 | expect(http.post).toHaveBeenCalledWith('/api/signout');
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/Signout/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import http from '~/lib/http';
3 |
4 | const Signout: React.FC = () => {
5 | const handleSignout = async () => {
6 | try {
7 | const res = await http.post('/api/signout');
8 |
9 | if (res.status === 204) {
10 | window.location.href = '/';
11 | }
12 | } catch (error) {
13 | console.log(error);
14 | }
15 | };
16 |
17 | return (
18 |
21 | );
22 | };
23 |
24 | export default Signout;
25 |
--------------------------------------------------------------------------------
/src/components/TodoLists/__test__/TodoLists.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import * as React from 'react';
3 | import { render } from '@testing-library/react';
4 |
5 | import TodoLists, { Task, TodoListsProps } from '~/components/TodoLists';
6 |
7 | describe('TodoLists', () => {
8 | let props: TodoListsProps;
9 |
10 | // Initialize
11 | beforeEach(() => {
12 | const tasks: Task[] = [
13 | {
14 | text: 'Todo1',
15 | checked: true
16 | },
17 | {
18 | text: 'Todo2',
19 | checked: true
20 | }
21 | ];
22 | props = {
23 | editTask: jest.fn(),
24 | removeTask: jest.fn(),
25 | tasks,
26 | isEditable: false
27 | };
28 | });
29 |
30 | it('rendering todo list items', () => {
31 | const { container } = render();
32 |
33 | const lists = container.querySelectorAll('.Todo-list li');
34 | expect(lists).toHaveLength(2);
35 | });
36 |
37 | it('delete button is not exsisted when isEditable is false', () => {
38 | const { container } = render();
39 |
40 | const buttons = container.querySelectorAll('.Button');
41 | expect(buttons).toHaveLength(0);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/components/TodoLists/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { KeyboardEvent } from 'react';
2 |
3 | export type Task = {
4 | text: string;
5 | checked: boolean;
6 | };
7 |
8 | export type TodoListsProps = {
9 | tasks: Task[];
10 | editTask: (index: number, task: Task) => void;
11 | removeTask: (index: number) => void;
12 | isEditable: boolean;
13 | };
14 |
15 | const TodoLists: React.FC = ({
16 | editTask,
17 | removeTask,
18 | tasks,
19 | isEditable
20 | }) => {
21 | const handleOnKeyDown = (e: KeyboardEvent) => {
22 | // INFO: ENTER Key
23 | if (e.keyCode !== 13) return;
24 |
25 | e.currentTarget.blur();
26 | };
27 |
28 | return (
29 | <>
30 |
70 |
71 |
108 | >
109 | );
110 | };
111 |
112 | export default TodoLists;
113 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import getConfig from 'next/config';
2 |
3 | const { publicRuntimeConfig } = getConfig();
4 | const { API_BASE_URL } = publicRuntimeConfig;
5 |
6 | export { API_BASE_URL };
7 |
--------------------------------------------------------------------------------
/src/constant.ts:
--------------------------------------------------------------------------------
1 | export const INCREMENT = 'INCREMENT';
2 | export const DECREMENT = 'DECREMENT';
3 | export const UPDATE_COUNT = 'UPDATE_COUNT';
4 |
5 | export const GET_ACCOUNT = 'GET_ACCOUNT';
6 | export const UPDATE_ACCOUNT = 'UPDATE_ACCOUNT';
7 |
--------------------------------------------------------------------------------
/src/hooks/signin/__test__/useEmail.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react-hooks';
2 | import useEmail from '~/hooks/signin/useEmail';
3 |
4 | describe('useEmail', () => {
5 | it('should use email', () => {
6 | const { result } = renderHook(() => useEmail(''));
7 |
8 | expect(result.current.entertedValue).toBe('');
9 | });
10 |
11 | it('should update email string', () => {
12 | const { result } = renderHook(() => useEmail(''));
13 |
14 | act(() => result.current.setInputValue('email'));
15 |
16 | expect(result.current.entertedValue).toBe('email');
17 | });
18 |
19 | it('after onChange event fired, should set email string', () => {
20 | const { result } = renderHook(() => useEmail(''));
21 | const pseudoEvent = {
22 | target: {
23 | value: 'test'
24 | }
25 | } as React.ChangeEvent;
26 |
27 | act(() => result.current.changedValue(pseudoEvent));
28 |
29 | expect(result.current.entertedValue).toBe('test');
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/hooks/signin/__test__/usePassword.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react-hooks';
2 | import usePassword from '~/hooks/signin/usePassword';
3 |
4 | describe('useEmail', () => {
5 | it('should use password', () => {
6 | const { result } = renderHook(() => usePassword(''));
7 |
8 | expect(result.current.entertedValue).toBe('');
9 | });
10 |
11 | it('should update password string', () => {
12 | const { result } = renderHook(() => usePassword(''));
13 |
14 | act(() => result.current.setInputValue('password'));
15 |
16 | expect(result.current.entertedValue).toBe('password');
17 | });
18 |
19 | it('after onChange event fired, should set password string', () => {
20 | const { result } = renderHook(() => usePassword(''));
21 | const pseudoEvent = {
22 | target: {
23 | value: '1234qwer'
24 | }
25 | } as React.ChangeEvent;
26 |
27 | act(() => result.current.changedValue(pseudoEvent));
28 |
29 | expect(result.current.entertedValue).toBe('1234qwer');
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/hooks/signin/useEmail.ts:
--------------------------------------------------------------------------------
1 | import { useState, ChangeEvent } from 'react';
2 | import { Email } from '~/modelTypes';
3 |
4 | const useEmail = (initialValue: Email = '') => {
5 | const [entertedValue, setInputValue] = useState(initialValue);
6 | const changedValue = (e: ChangeEvent) =>
7 | setInputValue(e.target.value);
8 | return { entertedValue, setInputValue, changedValue };
9 | };
10 |
11 | export default useEmail;
12 |
--------------------------------------------------------------------------------
/src/hooks/signin/usePassword.ts:
--------------------------------------------------------------------------------
1 | import { useState, ChangeEvent } from 'react';
2 | import { Password } from '~/modelTypes';
3 |
4 | const usePassword = (initialValue: Password = '') => {
5 | const [entertedValue, setInputValue] = useState(initialValue);
6 | const changedValue = (e: ChangeEvent) =>
7 | setInputValue(e.target.value);
8 | return { entertedValue, setInputValue, changedValue };
9 | };
10 |
11 | export default usePassword;
12 |
--------------------------------------------------------------------------------
/src/hooks/todo/__test__/useTodo.test.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent } from 'react';
2 | import { act, cleanup, renderHook } from '@testing-library/react-hooks';
3 | import { useTodo } from '~/hooks/todo';
4 |
5 | describe('useTodo', () => {
6 | afterEach(cleanup);
7 |
8 | it('should set initial value unless argument ', () => {
9 | const { result } = renderHook(() => useTodo());
10 |
11 | expect(result.current.value).toBe('');
12 | });
13 |
14 | it('should be argument value if the argument is specified', () => {
15 | const { result } = renderHook(() => useTodo('initial value'));
16 |
17 | expect(result.current.value).toBe('initial value');
18 | });
19 |
20 | it('should update value, after onChange event fired', () => {
21 | const { result } = renderHook(() => useTodo(''));
22 | const event = {
23 | target: {
24 | value: 'test'
25 | }
26 | } as ChangeEvent;
27 |
28 | act(() => result.current.change(event));
29 |
30 | expect(result.current.value).toBe('test');
31 | });
32 |
33 | it('should be empty value, after reset method is fired', () => {
34 | const { result } = renderHook(() => useTodo('test'));
35 |
36 | act(() => result.current.reset());
37 |
38 | expect(result.current.value).toBe('');
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/hooks/todo/__test__/useTodoList.test.ts:
--------------------------------------------------------------------------------
1 | import { act, cleanup, renderHook } from '@testing-library/react-hooks';
2 | import { useTodoList } from '~/hooks/todo';
3 |
4 | describe('useTodoList', () => {
5 | const initialTodoListValue = [
6 | {
7 | text: 'test1',
8 | checked: true
9 | },
10 | {
11 | text: 'test2',
12 | checked: false
13 | }
14 | ];
15 | afterEach(cleanup);
16 |
17 | it('should set initial value unless argument ', () => {
18 | const { result } = renderHook(() => useTodoList());
19 |
20 | expect(result.current.list).toEqual([]);
21 | });
22 |
23 | it('should be argument value if the argument is specified', () => {
24 | const { result } = renderHook(() => useTodoList(initialTodoListValue));
25 |
26 | expect(result.current.list).toEqual(initialTodoListValue);
27 | });
28 |
29 | it('should not add in list, if argument is falsy value', () => {
30 | const { result } = renderHook(() => useTodoList(initialTodoListValue));
31 | const task = '';
32 |
33 | act(() => result.current.add(task));
34 |
35 | expect(result.current.list).toEqual(initialTodoListValue);
36 | expect(result.current.list.length).toBe(2);
37 | });
38 |
39 | it('should be added in lists, after add method is fired', () => {
40 | const { result } = renderHook(() => useTodoList(initialTodoListValue));
41 | const newTask = {
42 | text: 'test3',
43 | checked: false
44 | };
45 |
46 | expect(result.current.list.length).toBe(2);
47 |
48 | act(() => result.current.add(newTask.text));
49 |
50 | const expected = [...initialTodoListValue, newTask];
51 |
52 | expect(result.current.list).toEqual(expected);
53 | expect(result.current.list.length).toBe(3);
54 | });
55 |
56 | it('should be edited, after edit method is fired', () => {
57 | const { result } = renderHook(() => useTodoList(initialTodoListValue));
58 | const targetIndex = 0;
59 | const newTask = {
60 | text: 'test3',
61 | checked: false
62 | };
63 |
64 | act(() => result.current.edit(targetIndex, newTask));
65 |
66 | expect(result.current.list[targetIndex]).toEqual(newTask);
67 | expect(result.current.list.length).toBe(2);
68 | });
69 |
70 | it('should be removed, after remove method is fired', () => {
71 | const { result } = renderHook(() => useTodoList(initialTodoListValue));
72 | const targetIndex = 0;
73 |
74 | act(() => result.current.remove(targetIndex));
75 |
76 | const expected = initialTodoListValue.filter(
77 | (_, index) => index !== targetIndex
78 | );
79 |
80 | expect(result.current.list).toEqual(expected);
81 | expect(result.current.list.length).toBe(1);
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/hooks/todo/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useTodo';
2 | export * from './useTodoList';
3 |
--------------------------------------------------------------------------------
/src/hooks/todo/useTodo.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, useState } from 'react';
2 |
3 | export const useTodo = (initialState = '') => {
4 | const [value, setValue] = useState(initialState);
5 |
6 | const change = (e: ChangeEvent) => {
7 | setValue(e.target.value);
8 | };
9 |
10 | const reset = () => {
11 | setValue('');
12 | };
13 |
14 | return { value, change, reset };
15 | };
16 |
--------------------------------------------------------------------------------
/src/hooks/todo/useTodoList.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Task } from '~/components/TodoLists';
3 |
4 | export const useTodoList = (initialState: Task[] = []) => {
5 | const [list, setList] = useState(initialState);
6 |
7 | const add = (text?: string) => {
8 | if (!text) return;
9 |
10 | const task: Task = { text, checked: false };
11 | setList([...list, task]);
12 | };
13 |
14 | const edit = (index: number, task: Task) => {
15 | const l = [...list];
16 | l[index] = task;
17 | setList(l);
18 | };
19 |
20 | const remove = (index: number) => {
21 | const l = list.filter((_, i) => index !== i);
22 | setList(l);
23 | };
24 |
25 | return { list, add, edit, remove };
26 | };
27 |
--------------------------------------------------------------------------------
/src/lib/http/index.ts:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from '~/config';
2 |
3 | const get = (path: string, options?: RequestInit) =>
4 | fetch(API_BASE_URL + path, {
5 | ...options,
6 | method: 'GET'
7 | });
8 |
9 | const post = (path: string, options?: RequestInit) =>
10 | fetch(API_BASE_URL + path, {
11 | ...options,
12 | method: 'POST'
13 | });
14 |
15 | const put = (path: string, options?: RequestInit) =>
16 | fetch(API_BASE_URL + path, {
17 | ...options,
18 | method: 'PUT'
19 | });
20 |
21 | const del = (path: string, options?: RequestInit) =>
22 | fetch(API_BASE_URL + path, {
23 | ...options,
24 | method: 'DELETE'
25 | });
26 |
27 | export default { get, post, put, delete: del };
28 |
--------------------------------------------------------------------------------
/src/lib/testing/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { createStore } from 'redux';
5 | import { rootReducer } from '~/reducers';
6 | import { Provider } from 'react-redux';
7 |
8 | export const renderWithProvider = (
9 | testComponent: React.ReactElement,
10 | initialState = {}
11 | ) => {
12 | return render(
13 |
14 | {testComponent}
15 |
16 | );
17 | };
18 |
19 | import { NextRouter } from 'next/router';
20 | import { RouterContext } from 'next/dist/next-server/lib/router-context';
21 |
22 | export const withMockedRouter = (
23 | router: Partial = {},
24 | children: React.ReactElement
25 | ): React.ReactElement => {
26 | const mockedRouter: NextRouter = {
27 | route: '',
28 | pathname: '',
29 | query: {},
30 | asPath: '',
31 | basePath: '',
32 | isLocaleDomain: true,
33 | push: async () => true,
34 | replace: async () => true,
35 | reload: () => null,
36 | back: () => null,
37 | prefetch: async () => undefined,
38 | beforePopState: () => null,
39 | isFallback: true,
40 | isReady: true,
41 | isPreview: true,
42 | events: {
43 | on: () => null,
44 | off: () => null,
45 | emit: () => null
46 | },
47 | ...router
48 | };
49 |
50 | return (
51 |
52 | {children}
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/lib/ua/__test__/ua.test.ts:
--------------------------------------------------------------------------------
1 | import { judgeIsMobile } from '~/lib/ua';
2 |
3 | describe('User agent judge for mobile', () => {
4 | describe('[mobile: true]', () => {
5 | it(' [legacy: iOS] [Client Hints: ?1]', () => {
6 | const legacyUA =
7 | 'Mozilla /5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B5110e Safari/601.1';
8 | const CHUAM = '?1';
9 |
10 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(true);
11 | });
12 |
13 | it('[legacy: Android] [Client Hints: ?1]', () => {
14 | const legacyUA =
15 | 'Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; Nexus One Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1';
16 | const CHUAM = '?1';
17 |
18 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(true);
19 | });
20 |
21 | it('[legacy: iOS] [Client Hints: NaN]', () => {
22 | const legacyUA =
23 | 'Mozilla /5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B5110e Safari/601.1';
24 | const CHUAM = undefined;
25 |
26 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(true);
27 | });
28 |
29 | it('[legacy: Android] [Client Hints: NaN]', () => {
30 | const legacyUA =
31 | 'Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; Nexus One Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1';
32 | const CHUAM = undefined;
33 |
34 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(true);
35 | });
36 |
37 | it('[legacy: NaN] [Client Hints: ?1]', () => {
38 | const legacyUA = undefined;
39 | const CHUAM = '?1';
40 |
41 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(true);
42 | });
43 | });
44 |
45 | describe('[mobile: false]', () => {
46 | it('[legacy: any PC] [Client Hints: ?0]', () => {
47 | const legacyUA =
48 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36';
49 | const CHUAM = '?0';
50 |
51 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(false);
52 | });
53 |
54 | it('[legacy: any PC] [Client Hints: NaN]', () => {
55 | const legacyUA =
56 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36';
57 | const CHUAM = undefined;
58 |
59 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(false);
60 | });
61 |
62 | it('[legacy: NaN] [Client Hints: ?0]', () => {
63 | const legacyUA = undefined;
64 | const CHUAM = '?0';
65 |
66 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(false);
67 | });
68 |
69 | it('[legacy: NaN] [Client Hints: NaN]', () => {
70 | const legacyUA = undefined;
71 | const CHUAM = undefined;
72 |
73 | expect(judgeIsMobile(legacyUA, CHUAM)).toBe(false);
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/src/lib/ua/index.ts:
--------------------------------------------------------------------------------
1 | type LegacyUA = string | undefined;
2 | type CHUAM = string | string[] | undefined;
3 |
4 | export const judgeIsMobile = (legacyUA: LegacyUA, CHUAM: CHUAM): boolean => {
5 | let isLegacyMobile = false;
6 | let isCHMobile = false;
7 |
8 | /** Legacy User-Agent judgment */
9 | if (legacyUA) {
10 | const isIOS = /iP(hone|(o|a)d)/.test(legacyUA);
11 | const isAndroid = /Android/.test(legacyUA);
12 | isLegacyMobile = isIOS || isAndroid;
13 | }
14 |
15 | /**
16 | * Client Hints judgment for Chromium
17 | * CHUAM (sec-ch-ua-mobile) has `?0 (false)` or `?1 (true)` judgment value.
18 | */
19 | if (typeof CHUAM === 'string') {
20 | const capture = /^\?(?\d{1})$/.exec(CHUAM);
21 | const hasMobile = capture?.groups?.mobile || '0';
22 | isCHMobile = Boolean(parseInt(hasMobile));
23 | }
24 |
25 | return isLegacyMobile || isCHMobile;
26 | };
27 |
--------------------------------------------------------------------------------
/src/modelTypes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Email
3 | *
4 | * @example "xxxx\u0040example.com"
5 | */
6 | export type Email = string;
7 |
8 | /**
9 | * Nickname
10 | *
11 | * @example "sid_jankovic_jeronimo_mongo"
12 | */
13 | export type Nickname = string;
14 |
15 | /**
16 | * Password
17 | */
18 | export type Password = string;
19 |
20 | /**
21 | * Access token (JWT)
22 | *
23 | * @example "eyJz93a...k4laUWw"
24 | */
25 | export type AccessToken = string;
26 |
27 | /**
28 | * Refresh token (JWT)
29 | *
30 | * @example "GEbRxBN...edjnXbL"
31 | */
32 | export type RefreshToken = string;
33 |
34 | /**
35 | * Account
36 | */
37 | export interface Account {
38 | /**
39 | * Email
40 | */
41 | email: Email;
42 |
43 | /**
44 | * Nickname
45 | */
46 | nickname: Nickname;
47 | }
48 |
49 | export interface UpdateAccount extends Account {
50 | /**
51 | * Password as plain text
52 | */
53 | password?: Password;
54 | }
55 |
56 | export interface Token {
57 | /**
58 | * Access token (JWT)
59 | */
60 | access_token: AccessToken;
61 |
62 | /**
63 | * Refresh token (JWT)
64 | */
65 | refresh_token: RefreshToken;
66 |
67 | /**
68 | * Always 'Bearer'
69 | *
70 | * @example "Bearer"
71 | */
72 | type: string;
73 | }
74 |
75 | export interface RefreshTokenRequest {
76 | /**
77 | * Refresh token (JWT)
78 | */
79 | refresh_token: RefreshToken;
80 | }
81 |
82 | export interface SignupRequest extends Account {
83 | /**
84 | * Password as plain text
85 | */
86 | password: Password;
87 | }
88 |
89 | export interface SigninRequest {
90 | /**
91 | * Email
92 | */
93 | email: Email;
94 |
95 | /**
96 | * Password as plain text
97 | */
98 | password: Password;
99 | }
100 |
--------------------------------------------------------------------------------
/src/pages/__test__/todo.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import * as React from 'react';
3 | import {
4 | cleanup,
5 | fireEvent,
6 | getAllByText,
7 | getByPlaceholderText,
8 | render
9 | } from '@testing-library/react';
10 |
11 | import Todo from '~pages/todo';
12 |
13 | describe('todo', () => {
14 | afterEach(cleanup);
15 |
16 | it('can remove a task', () => {
17 | let lists;
18 | const { container } = render();
19 | lists = container.querySelectorAll('.Todo-list li');
20 | expect(lists).toHaveLength(0);
21 |
22 | fireEvent.change(
23 | getByPlaceholderText(container, 'タスクを追加しよう!!'),
24 | { target: { value: 'タスク1' } }
25 | );
26 | fireEvent.click(getAllByText(container, 'タスクを追加')[0]);
27 | lists = container.querySelectorAll('.Todo-list li');
28 | expect(lists).toHaveLength(1);
29 |
30 | fireEvent.click(getAllByText(container, '編集')[0]);
31 | fireEvent.click(getAllByText(container, '削除')[0]);
32 | lists = container.querySelectorAll('.Todo-list li');
33 | expect(lists).toHaveLength(0);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/reducers/account/__test__/__snapshots__/account.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Account reducer update account 1`] = `
4 | "Snapshot Diff:
5 | - First value
6 | + Second value
7 |
8 | Object {
9 | \\"data\\": Object {
10 | - \\"email\\": \\"\\",
11 | - \\"nickname\\": \\"\\",
12 | + \\"email\\": \\"test@example.com\\",
13 | + \\"nickname\\": \\"snapshot testing\\",
14 | },
15 | }"
16 | `;
17 |
--------------------------------------------------------------------------------
/src/reducers/account/__test__/account.test.ts:
--------------------------------------------------------------------------------
1 | import snapshotDiff from 'snapshot-diff';
2 | import { accountActions } from '~/actions';
3 |
4 | import { initialAccount, account } from '~/reducers/account';
5 | import { Account } from '~/modelTypes';
6 |
7 | const testingAccount: Account = {
8 | email: 'test@example.com',
9 | nickname: 'snapshot testing'
10 | };
11 |
12 | describe('Account reducer', () => {
13 | it('update account', () => {
14 | expect(
15 | snapshotDiff(
16 | initialAccount,
17 | account(
18 | initialAccount,
19 | accountActions.updateAccount(testingAccount)
20 | )
21 | )
22 | ).toMatchSnapshot();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/reducers/account/index.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from 'redux';
2 | import { UPDATE_ACCOUNT } from '~/constant';
3 | import { AccountActionTypes } from '~/actionTypes';
4 | import { AccountState } from '~/stateTypes';
5 |
6 | export const initialAccount: AccountState = {
7 | data: { email: '', nickname: '' }
8 | };
9 |
10 | export const account: Reducer = (
11 | state = initialAccount,
12 | action
13 | ) => {
14 | switch (action.type) {
15 | case UPDATE_ACCOUNT:
16 | return { ...state, data: action.payload.data };
17 | default:
18 | return state;
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/reducers/counter/__test__/__snapshots__/counter.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Counter reducer update testing 1`] = `
4 | "Snapshot Diff:
5 | - First value
6 | + Second value
7 |
8 | Object {
9 | - \\"count\\": 0,
10 | + \\"count\\": 1,
11 | }"
12 | `;
13 |
--------------------------------------------------------------------------------
/src/reducers/counter/__test__/counter.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | import snapshotDiff from 'snapshot-diff';
3 | import { counterActions } from '~/actions';
4 |
5 | import { counter, initialCounter } from '~/reducers/counter';
6 |
7 | describe('Counter reducer', () => {
8 | it('update testing', () => {
9 | expect(
10 | snapshotDiff(
11 | initialCounter,
12 | counter(initialCounter, counterActions.updateCount(1))
13 | )
14 | ).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/reducers/counter/index.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from 'redux';
2 | import { UPDATE_COUNT } from '~/constant';
3 | import { CounterActionTypes } from '~/actionTypes';
4 | import { CountState } from '~/stateTypes';
5 |
6 | import { HYDRATE } from 'next-redux-wrapper';
7 |
8 | export const initialCounter: CountState = { count: 0 };
9 |
10 | export const counter: Reducer = (
11 | state = initialCounter,
12 | action
13 | ) => {
14 | switch (action.type) {
15 | case HYDRATE:
16 | return { ...state, count: action.payload.counter.count };
17 |
18 | case UPDATE_COUNT:
19 | return { ...state, count: action.payload };
20 |
21 | default:
22 | return state;
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/src/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import * as reducers from './reducers';
3 |
4 | export const rootReducer = combineReducers(reducers);
5 |
6 | export type RootState = ReturnType;
7 |
--------------------------------------------------------------------------------
/src/reducers/reducers.ts:
--------------------------------------------------------------------------------
1 | export { account } from '~/reducers/account';
2 | export { counter } from '~/reducers/counter';
3 |
--------------------------------------------------------------------------------
/src/sagas/__test__/account.test.ts:
--------------------------------------------------------------------------------
1 | import { expectSaga } from 'redux-saga-test-plan';
2 | import * as matchers from 'redux-saga-test-plan/matchers';
3 | import { throwError } from 'redux-saga-test-plan/providers';
4 | import { accountActions } from '~/actions';
5 |
6 | import {
7 | handleRequestGetAccount,
8 | runRequestGetAccount
9 | } from '~/sagas/tasks/account';
10 |
11 | import { Account } from '~/modelTypes';
12 |
13 | const testingAccount: Account = {
14 | email: 'kotaro@example.com',
15 | nickname: 'Jankovic'
16 | };
17 |
18 | describe('Account Tasks', () => {
19 | it('takeevery run handleRequestGetAccount', () => {
20 | return expectSaga(handleRequestGetAccount)
21 | .dispatch(accountActions.getAccount())
22 | .silentRun();
23 | });
24 |
25 | /**
26 | * @NOTE
27 | * when this test insert `.put(accountAction.updateAccount(testingAccout))`,
28 | * return unknown error.
29 | */
30 | it('get account API call [success]', () => {
31 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
32 | // @ts-ignore
33 | return expectSaga(runRequestGetAccount)
34 | .provide([
35 | [matchers.call.fn(() => ''), JSON.stringify(testingAccount)]
36 | ])
37 | .run(false);
38 | });
39 |
40 | it('get account API call [failed]', () => {
41 | const error = new Error('Expire is over.');
42 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
43 | // @ts-ignore
44 | return expectSaga(runRequestGetAccount)
45 | .provide([[matchers.call.fn(() => ''), throwError(error)]])
46 | .run(false);
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/sagas/__test__/counter.test.ts:
--------------------------------------------------------------------------------
1 | import { expectSaga } from 'redux-saga-test-plan';
2 | import { counterActions } from '~/actions';
3 |
4 | import {
5 | handleRequestIncrementCount,
6 | handleRequestDecrementCount
7 | } from '~/sagas/tasks/counter';
8 |
9 | describe('Counter tasks', () => {
10 | it('increment task', () => {
11 | return expectSaga(handleRequestIncrementCount)
12 | .withState({ counter: { count: 0 } })
13 | .dispatch(counterActions.increment(1))
14 | .put(counterActions.updateCount(1))
15 | .silentRun();
16 | });
17 |
18 | it('decrement task', () => {
19 | return expectSaga(handleRequestDecrementCount)
20 | .withState({ counter: { count: 1 } })
21 | .dispatch(counterActions.decrement(1))
22 | .put(counterActions.updateCount(0))
23 | .silentRun();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { fork } from 'redux-saga/effects';
2 | import {
3 | handleRequestIncrementCount,
4 | handleRequestDecrementCount
5 | } from '~/sagas/tasks/counter';
6 | import { handleRequestGetAccount } from '~/sagas/tasks/account';
7 |
8 | export default function* rootSaga() {
9 | yield fork(handleRequestIncrementCount);
10 | yield fork(handleRequestDecrementCount);
11 | yield fork(handleRequestGetAccount);
12 | }
13 |
--------------------------------------------------------------------------------
/src/sagas/selectors/account/index.ts:
--------------------------------------------------------------------------------
1 | import { SelectAnyState } from 'types';
2 | import { RootState } from '~/reducers';
3 | import { AccountState } from '~/stateTypes';
4 |
5 | export const getAccountState: SelectAnyState = state =>
6 | state.account;
7 |
--------------------------------------------------------------------------------
/src/sagas/selectors/counter/index.ts:
--------------------------------------------------------------------------------
1 | import { SelectAnyState } from 'types';
2 | import { RootState } from '~/reducers';
3 | import { CountState } from '~/stateTypes';
4 |
5 | export const getCountState: SelectAnyState = state =>
6 | state.counter;
7 |
--------------------------------------------------------------------------------
/src/sagas/tasks/account/index.ts:
--------------------------------------------------------------------------------
1 | import { takeEvery, call, put } from 'redux-saga/effects';
2 | import { GET_ACCOUNT } from '~/constant';
3 | import { accountActions } from '~/actions';
4 | import { Account } from '~/modelTypes';
5 |
6 | export function* runRequestGetAccount() {
7 | try {
8 | const response: Response = yield call(() => fetch('/api/account'));
9 | if (response.status === 200) {
10 | const account: Account = yield response.json();
11 | yield put(accountActions.updateAccount(account));
12 | }
13 | } catch (err) {
14 | // WIP
15 | yield console.log(err);
16 | }
17 | }
18 |
19 | export function* handleRequestGetAccount() {
20 | yield takeEvery(GET_ACCOUNT, runRequestGetAccount);
21 | }
22 |
--------------------------------------------------------------------------------
/src/sagas/tasks/counter/index.ts:
--------------------------------------------------------------------------------
1 | import { take, select, put } from 'redux-saga/effects';
2 | import { INCREMENT, DECREMENT } from '~/constant';
3 | import { counterActions } from '~/actions';
4 | import { CounterActionTypes } from '~/actionTypes';
5 | import { CountState } from '~/stateTypes';
6 |
7 | import { getCountState } from '~/sagas/selectors/counter';
8 |
9 | export function* handleRequestIncrementCount() {
10 | while (true) {
11 | const {
12 | payload
13 | }: Extract = yield take(
14 | INCREMENT
15 | );
16 | const counter: CountState = yield select(getCountState);
17 |
18 | const res = counter.count + payload;
19 | yield put(counterActions.updateCount(res));
20 | }
21 | }
22 |
23 | export function* handleRequestDecrementCount() {
24 | while (true) {
25 | const {
26 | payload
27 | }: Extract = yield take(
28 | DECREMENT
29 | );
30 | const counter: CountState = yield select(getCountState);
31 |
32 | const res = counter.count - payload;
33 | yield put(counterActions.updateCount(res));
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/service/auth/__test__/auth.test.ts:
--------------------------------------------------------------------------------
1 | import Auth from '~/service/auth';
2 | import { AccessToken } from '~/modelTypes';
3 |
4 | /**
5 | * @NOTE
6 | * when to decode pseduoAccessToken by jwt,
7 | * this breakdown payload is as follows...
8 | * [PAYLOAD]:
9 | * {
10 | * "sub": "1234567890",
11 | * "name": "Chiba Kotaro",
12 | * "iat": 1575443282,
13 | * "exp": 4133948399
14 | * }
15 | */
16 | const pseudoAccessToken: AccessToken =
17 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNoaWJhIEtvdGFybyIsImlhdCI6MTU3NTQ0MzI4MiwiZXhwIjo0MTMzOTQ4Mzk5fQ.qOtJ374Wch52YXmIshB-z2hqIydHDj0RgkxvancC96c';
18 |
19 | /**
20 | * @NOTE
21 | * this expire is '2100/12/31 23:59:59 GMT+0900'
22 | */
23 | const testingExpire = 4133948399;
24 |
25 | describe('Auth service [Normal system]', () => {
26 | let auth: Auth;
27 |
28 | beforeAll(() => {
29 | auth = new Auth(pseudoAccessToken);
30 | });
31 |
32 | it('when Auth class instantiate, decoded token expire is expected', () => {
33 | expect(auth.decodedToken.exp).toBe(testingExpire);
34 | });
35 |
36 | it('get expire', () => {
37 | expect(auth.expiresAt).toStrictEqual(new Date(testingExpire * 1000));
38 | });
39 |
40 | it('get max-age', () => {
41 | expect(auth.maxAgeAt).toBe(
42 | testingExpire - Date.parse(new Date().toString()) / 1000
43 | );
44 | });
45 |
46 | it('determining if it has expired', () => {
47 | expect(auth.isExpired).toBe(false);
48 | });
49 |
50 | it('determining if it has authenticated', () => {
51 | expect(auth.isAuthenticated).toBe(true);
52 | });
53 |
54 | it('get authorization bearer string', () => {
55 | expect(auth.authorizationString).toBe(`Bearer ${pseudoAccessToken}`);
56 | });
57 | });
58 |
59 | describe('Auth service [Abnormal]', () => {
60 | let auth: Auth;
61 |
62 | beforeAll(() => {
63 | auth = new Auth();
64 | });
65 |
66 | it('when Auth class instantiate with no token, decoded token expire is 0', () => {
67 | expect(auth.decodedToken.exp).toBe(0);
68 | });
69 |
70 | it('determining if it has expired', () => {
71 | expect(auth.isExpired).toBe(true);
72 | });
73 |
74 | it('determining if it has authenticated', () => {
75 | expect(auth.isAuthenticated).toBe(false);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/service/auth/index.ts:
--------------------------------------------------------------------------------
1 | import { AccessToken, RefreshToken } from '~/modelTypes';
2 | import jwtDecode from 'jwt-decode';
3 |
4 | type DecodedToken = {
5 | readonly exp: number;
6 | };
7 |
8 | export default class Auth {
9 | readonly decodedToken: DecodedToken;
10 | constructor(readonly token?: AccessToken | RefreshToken) {
11 | this.decodedToken = { exp: 0 };
12 |
13 | try {
14 | if (token) this.decodedToken = jwtDecode(token);
15 | } catch (e) {}
16 | }
17 |
18 | get expiresAt(): Date {
19 | return new Date(this.decodedToken.exp * 1000);
20 | }
21 |
22 | /**
23 | * @CAUTION
24 | * Max-Age needs number is seconds, not milliseconds
25 | */
26 | get maxAgeAt(): number {
27 | return this.decodedToken.exp - Date.parse(new Date().toString()) / 1000;
28 | }
29 |
30 | get isExpired(): boolean {
31 | return new Date() > this.expiresAt;
32 | }
33 |
34 | get isAuthenticated(): boolean {
35 | return !this.isExpired;
36 | }
37 |
38 | get authorizationString() {
39 | return `Bearer ${this.token}`;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/stateTypes.ts:
--------------------------------------------------------------------------------
1 | import { Account } from '~/modelTypes';
2 |
3 | export interface CountState {
4 | count: number;
5 | }
6 |
7 | export interface AccountState {
8 | data: Account;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, Store, Middleware } from 'redux';
2 | import createSagaMiddleware, { Task, END } from 'redux-saga';
3 | import { MakeStore, Context, createWrapper } from 'next-redux-wrapper';
4 | import { createLogger } from 'redux-logger';
5 | import { rootReducer, RootState } from '~/reducers';
6 | import rootSaga from '~/sagas';
7 | import { Dispatch } from 'redux';
8 |
9 | interface TasksCallbackType {
10 | (argv: Dispatch): void;
11 | }
12 | export interface StoreWithSaga extends Store {
13 | sagaTask: Task | null;
14 | runSagaTask: () => void;
15 | stopSaga: () => Promise;
16 | execSagaTask: (
17 | isServer: boolean,
18 | tasks: TasksCallbackType
19 | ) => Promise;
20 | }
21 |
22 | const makeStore: MakeStore = (_ctx: Context) => {
23 | const sagaMiddleware = createSagaMiddleware();
24 | const middlewares: Middleware[] = [sagaMiddleware];
25 |
26 | const logger = createLogger();
27 | middlewares.push(logger);
28 |
29 | const store = createStore(
30 | rootReducer,
31 | applyMiddleware(...middlewares)
32 | ) as StoreWithSaga;
33 |
34 | store.runSagaTask = () => {
35 | if (store.sagaTask) return;
36 | store.sagaTask = sagaMiddleware.run(rootSaga);
37 | };
38 |
39 | store.stopSaga = async () => {
40 | if (!store.sagaTask) return;
41 | store.dispatch(END);
42 | await store.sagaTask.toPromise();
43 | store.sagaTask = null;
44 | };
45 |
46 | store.execSagaTask = async (isServer, tasks) => {
47 | tasks(store.dispatch);
48 |
49 | if (isServer) {
50 | store.stopSaga();
51 | }
52 | };
53 |
54 | store.runSagaTask();
55 |
56 | return store;
57 | };
58 |
59 | export const wrapper = createWrapper(makeStore, { debug: true });
60 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "allowSyntheticDefaultImports": true,
5 | "jsx": "preserve",
6 | "lib": [
7 | "dom",
8 | "es2017"
9 | ],
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": true,
15 | "preserveConstEnums": true,
16 | "removeComments": false,
17 | "skipLibCheck": true,
18 | "sourceMap": true,
19 | "strict": true,
20 | "target": "esnext",
21 | "baseUrl": "./",
22 | "paths": {
23 | "~/*": [
24 | "src/*"
25 | ],
26 | "~pages/*": [
27 | "pages/*"
28 | ]
29 | },
30 | "rootDirs": [
31 | "./pages",
32 | "./src"
33 | ],
34 | "typeRoots": [
35 | "node_modules/@types",
36 | "./types"
37 | ],
38 | "forceConsistentCasingInFileNames": true,
39 | "esModuleInterop": true,
40 | "resolveJsonModule": true,
41 | "isolatedModules": true
42 | },
43 | "include": [
44 | "next-env.d.ts",
45 | "**/*.ts",
46 | "**/*.tsx"
47 | ],
48 | "exclude": [
49 | "node_modules",
50 | "out",
51 | ".next"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/tsconfig.server.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "outDir": "dist",
6 | "target": "es2017",
7 | "isolatedModules": false,
8 | "noEmit": false
9 | },
10 | "include": ["server/**/*.ts"]
11 | }
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | export declare type SelectAnyState = (rootState: R) => Pick;
2 |
--------------------------------------------------------------------------------