├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .storybook ├── main.js ├── preview-head.html └── preview.jsx ├── LICENSE ├── README.md ├── docs ├── assets │ └── index.1272fa6a.js ├── favicon.svg ├── index.html └── mockServiceWorker.js ├── index.html ├── package-lock.json ├── package.json ├── public ├── favicon.svg └── mockServiceWorker.js ├── src ├── api │ ├── ApiClientBoundary.tsx │ ├── ApiClientProvider.tsx │ ├── resas │ │ ├── ResasApiKeyProvider.tsx │ │ ├── ResasClient.ts │ │ ├── useResasApiKey.ts │ │ └── useResasClient.ts │ ├── useApiClientInitializer.ts │ ├── usePopulationsQueries.ts │ └── usePrefecturesQuery.ts ├── app │ ├── App │ │ └── index.tsx │ ├── layouts │ │ ├── PageLayout │ │ │ ├── ErrorFallback │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ └── UninitializedPageLayout │ │ │ └── index.tsx │ ├── pages │ │ ├── ApiKeyInputPage │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── PrefecturePopulationPage │ │ │ ├── LoadingPrefecturesPanel │ │ │ └── index.tsx │ │ │ ├── PopulationGraph │ │ │ ├── index.tsx │ │ │ └── useHighCharts.ts │ │ │ ├── PrefectureSelector │ │ │ └── index.tsx │ │ │ ├── index.stories.tsx │ │ │ ├── index.tsx │ │ │ ├── usePrefecturePopulations.ts │ │ │ └── usePrefectureSelections.ts │ ├── routes │ │ ├── AppRoutes │ │ │ └── index.tsx │ │ ├── RequireApiClient │ │ │ └── index.tsx │ │ └── routes.ts │ └── themes │ │ ├── AppThemeProvider │ │ └── index.tsx │ │ ├── GlobalStyle │ │ └── index.tsx │ │ └── theme.ts ├── emotion.d.ts ├── libs │ ├── Button │ │ ├── index.stories.tsx │ │ └── index.tsx │ ├── Headline │ │ ├── index.stories.tsx │ │ └── index.tsx │ ├── TextField │ │ ├── index.stories.tsx │ │ └── index.tsx │ └── TopAppBar │ │ ├── index.stories.tsx │ │ └── index.tsx ├── main.tsx ├── mocks │ ├── browser.ts │ ├── handlers.ts │ └── resolvers │ │ ├── mockPopulations.ts │ │ ├── mockPrefectures.ts │ │ ├── pop24.json │ │ ├── pop25.json │ │ ├── pop26.json │ │ ├── pop27.json │ │ ├── pop28.json │ │ ├── pop29.json │ │ └── pop30.json ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | node: circleci/node@5.0.2 5 | 6 | workflows: 7 | deploy-chromatic: 8 | jobs: 9 | - deploy-ghpages: 10 | filters: 11 | branches: 12 | only: 13 | - main 14 | - node/run: 15 | version: '18.2.0' 16 | override-ci-command: npm ci --legacy-peer-deps 17 | npm-run: chromatic 18 | 19 | 20 | jobs: 21 | deploy-ghpages: 22 | executor: 23 | name: node/default 24 | tag: '18.2.0' 25 | steps: 26 | - checkout 27 | - node/install-packages: 28 | override-ci-command: npm ci --legacy-peer-deps 29 | - run: 30 | command: | 31 | npx tsc 32 | npx vite build --outDir docs 33 | - run: 34 | command: | 35 | git config user.email "bufferings+github@gmail.com" 36 | git config user.name "CircleCI (Mitsuyuki Shiiba)" 37 | - add_ssh_keys: 38 | fingerprints: 39 | - "91:ac:ed:60:a2:be:47:cb:27:75:bc:ff:13:b0:6d:4c" 40 | - run: 41 | command: | 42 | git add docs 43 | if ! git diff --staged --quiet --; then 44 | git commit -m "[skip ci] Update GH Pages" 45 | git push origin main 46 | fi 47 | 48 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | docs 2 | .eslintrc.js 3 | vite.config.ts 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'airbnb', 8 | 'airbnb-typescript', 9 | 'airbnb/hooks', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 12 | 'prettier', 13 | ], 14 | overrides: [ 15 | { 16 | files: '*.stories.@(ts|tsx|js)', 17 | extends: 'plugin:storybook/recommended', 18 | rules: { 19 | 'import/no-default-export': 'off', 20 | 'import/no-extraneous-dependencies': 'off', 21 | 'react/jsx-props-no-spreading': 'off', 22 | }, 23 | }, 24 | ], 25 | ignorePatterns: ['dist', 'mockServiceWorker.js'], 26 | parser: '@typescript-eslint/parser', 27 | parserOptions: { 28 | ecmaFeatures: { 29 | jsx: true, 30 | }, 31 | ecmaVersion: 'latest', 32 | sourceType: 'module', 33 | project: './tsconfig.json', 34 | }, 35 | plugins: ['react', '@typescript-eslint', 'unused-imports', 'import'], 36 | rules: { 37 | 'react/react-in-jsx-scope': 'off', 38 | 'no-unused-vars': 'off', 39 | 'unused-imports/no-unused-imports': 'error', 40 | 'unused-imports/no-unused-vars': [ 41 | 'warn', 42 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, 43 | ], 44 | 'import/order': [ 45 | 'error', 46 | { 47 | groups: ['builtin', 'external', 'internal', ['parent', 'sibling'], 'object', 'type', 'index'], 48 | 'newlines-between': 'always', 49 | pathGroupsExcludedImportTypes: ['builtin'], 50 | alphabetize: { order: 'asc', caseInsensitive: true }, 51 | }, 52 | ], 53 | 'react/function-component-definition': [ 54 | 'error', 55 | { 56 | namedComponents: 'arrow-function', 57 | }, 58 | ], 59 | '@typescript-eslint/no-non-null-assertion': 'off', 60 | 'import/prefer-default-export': 'off', 61 | 'import/no-default-export': 'error', 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Storybook 27 | storybook-static/ 28 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], 6 | framework: '@storybook/react', 7 | core: { 8 | builder: '@storybook/builder-vite', 9 | }, 10 | features: { 11 | storyStoreV7: true, 12 | }, 13 | viteFinal: async (config) => { 14 | config.resolve.alias = { 15 | ...(config.resolve.alias || {}), 16 | src: path.resolve(__dirname, '../src'), 17 | }; 18 | return config; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.jsx: -------------------------------------------------------------------------------- 1 | import { AppThemeProvider } from 'src/app/themes/AppThemeProvider'; 2 | import { startMockWorker } from 'src/mocks/browser'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | startMockWorker(); 6 | 7 | export const parameters = { 8 | actions: { argTypesRegex: '^on[A-Z].*' }, 9 | controls: { 10 | matchers: { 11 | color: /(background|color)$/i, 12 | date: /Date$/, 13 | }, 14 | }, 15 | }; 16 | 17 | export const decorators = [ 18 | (Story) => ( 19 | 20 | 21 | 22 | 23 | 24 | ), 25 | ]; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yumemi-frontend 2 | 3 | フロントエンドの勉強にちょうど良さそうなのでやってみる 4 | 5 | - [フロントエンドコーディング試験](https://notion.yumemi.co.jp/0e9ef27b55704d7882aab55cc86c999d) 6 | - [ワイヤーフレーム](https://yumemi.notion.site/7646721865fa47e7b2c9b2a52c8c40ac) 7 | -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 都道府県別総人口推移グラフ 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.42.0). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' 12 | const bypassHeaderName = 'x-msw-bypass' 13 | const activeClientIds = new Set() 14 | 15 | self.addEventListener('install', function () { 16 | return self.skipWaiting() 17 | }) 18 | 19 | self.addEventListener('activate', async function (event) { 20 | return self.clients.claim() 21 | }) 22 | 23 | self.addEventListener('message', async function (event) { 24 | const clientId = event.source.id 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll() 37 | 38 | switch (event.data) { 39 | case 'KEEPALIVE_REQUEST': { 40 | sendToClient(client, { 41 | type: 'KEEPALIVE_RESPONSE', 42 | }) 43 | break 44 | } 45 | 46 | case 'INTEGRITY_CHECK_REQUEST': { 47 | sendToClient(client, { 48 | type: 'INTEGRITY_CHECK_RESPONSE', 49 | payload: INTEGRITY_CHECKSUM, 50 | }) 51 | break 52 | } 53 | 54 | case 'MOCK_ACTIVATE': { 55 | activeClientIds.add(clientId) 56 | 57 | sendToClient(client, { 58 | type: 'MOCKING_ENABLED', 59 | payload: true, 60 | }) 61 | break 62 | } 63 | 64 | case 'MOCK_DEACTIVATE': { 65 | activeClientIds.delete(clientId) 66 | break 67 | } 68 | 69 | case 'CLIENT_CLOSED': { 70 | activeClientIds.delete(clientId) 71 | 72 | const remainingClients = allClients.filter((client) => { 73 | return client.id !== clientId 74 | }) 75 | 76 | // Unregister itself when there are no more clients 77 | if (remainingClients.length === 0) { 78 | self.registration.unregister() 79 | } 80 | 81 | break 82 | } 83 | } 84 | }) 85 | 86 | // Resolve the "main" client for the given event. 87 | // Client that issues a request doesn't necessarily equal the client 88 | // that registered the worker. It's with the latter the worker should 89 | // communicate with during the response resolving phase. 90 | async function resolveMainClient(event) { 91 | const client = await self.clients.get(event.clientId) 92 | 93 | if (client.frameType === 'top-level') { 94 | return client 95 | } 96 | 97 | const allClients = await self.clients.matchAll() 98 | 99 | return allClients 100 | .filter((client) => { 101 | // Get only those clients that are currently visible. 102 | return client.visibilityState === 'visible' 103 | }) 104 | .find((client) => { 105 | // Find the client ID that's recorded in the 106 | // set of clients that have registered the worker. 107 | return activeClientIds.has(client.id) 108 | }) 109 | } 110 | 111 | async function handleRequest(event, requestId) { 112 | const client = await resolveMainClient(event) 113 | const response = await getResponse(event, client, requestId) 114 | 115 | // Send back the response clone for the "response:*" life-cycle events. 116 | // Ensure MSW is active and ready to handle the message, otherwise 117 | // this message will pend indefinitely. 118 | if (client && activeClientIds.has(client.id)) { 119 | ;(async function () { 120 | const clonedResponse = response.clone() 121 | sendToClient(client, { 122 | type: 'RESPONSE', 123 | payload: { 124 | requestId, 125 | type: clonedResponse.type, 126 | ok: clonedResponse.ok, 127 | status: clonedResponse.status, 128 | statusText: clonedResponse.statusText, 129 | body: 130 | clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }) 135 | })() 136 | } 137 | 138 | return response 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event 143 | const requestClone = request.clone() 144 | const getOriginalResponse = () => fetch(requestClone) 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse() 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse() 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName] 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }) 169 | 170 | return fetch(originalRequest) 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers) 175 | const body = await request.text() 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }) 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise( 201 | () => respondWithMock(clientMessage), 202 | clientMessage.payload.delay, 203 | ) 204 | } 205 | 206 | case 'MOCK_NOT_FOUND': { 207 | return getOriginalResponse() 208 | } 209 | 210 | case 'NETWORK_ERROR': { 211 | const { name, message } = clientMessage.payload 212 | const networkError = new Error(message) 213 | networkError.name = name 214 | 215 | // Rejecting a request Promise emulates a network error. 216 | throw networkError 217 | } 218 | 219 | case 'INTERNAL_ERROR': { 220 | const parsedBody = JSON.parse(clientMessage.payload.body) 221 | 222 | console.error( 223 | `\ 224 | [MSW] Uncaught exception in the request handler for "%s %s": 225 | 226 | ${parsedBody.location} 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 229 | `, 230 | request.method, 231 | request.url, 232 | ) 233 | 234 | return respondWithMock(clientMessage) 235 | } 236 | } 237 | 238 | return getOriginalResponse() 239 | } 240 | 241 | self.addEventListener('fetch', function (event) { 242 | const { request } = event 243 | const accept = request.headers.get('accept') || '' 244 | 245 | // Bypass server-sent events. 246 | if (accept.includes('text/event-stream')) { 247 | return 248 | } 249 | 250 | // Bypass navigation requests. 251 | if (request.mode === 'navigate') { 252 | return 253 | } 254 | 255 | // Opening the DevTools triggers the "only-if-cached" request 256 | // that cannot be handled by the worker. Bypass such requests. 257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 258 | return 259 | } 260 | 261 | // Bypass all requests when there are no active clients. 262 | // Prevents the self-unregistered worked from handling requests 263 | // after it's been deleted (still remains active until the next reload). 264 | if (activeClientIds.size === 0) { 265 | return 266 | } 267 | 268 | const requestId = uuidv4() 269 | 270 | return event.respondWith( 271 | handleRequest(event, requestId).catch((error) => { 272 | if (error.name === 'NetworkError') { 273 | console.warn( 274 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 275 | request.method, 276 | request.url, 277 | ) 278 | return 279 | } 280 | 281 | // At this point, any exception indicates an issue with the original request/response. 282 | console.error( 283 | `\ 284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 285 | request.method, 286 | request.url, 287 | `${error.name}: ${error.message}`, 288 | ) 289 | }), 290 | ) 291 | }) 292 | 293 | function serializeHeaders(headers) { 294 | const reqHeaders = {} 295 | headers.forEach((value, name) => { 296 | reqHeaders[name] = reqHeaders[name] 297 | ? [].concat(reqHeaders[name]).concat(value) 298 | : value 299 | }) 300 | return reqHeaders 301 | } 302 | 303 | function sendToClient(client, message) { 304 | return new Promise((resolve, reject) => { 305 | const channel = new MessageChannel() 306 | 307 | channel.port1.onmessage = (event) => { 308 | if (event.data && event.data.error) { 309 | return reject(event.data.error) 310 | } 311 | 312 | resolve(event.data) 313 | } 314 | 315 | client.postMessage(JSON.stringify(message), [channel.port2]) 316 | }) 317 | } 318 | 319 | function delayPromise(cb, duration) { 320 | return new Promise((resolve) => { 321 | setTimeout(() => resolve(cb()), duration) 322 | }) 323 | } 324 | 325 | function respondWithMock(clientMessage) { 326 | return new Response(clientMessage.payload.body, { 327 | ...clientMessage.payload, 328 | headers: clientMessage.payload.headers, 329 | }) 330 | } 331 | 332 | function uuidv4() { 333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 334 | const r = (Math.random() * 16) | 0 335 | const v = c == 'x' ? r : (r & 0x3) | 0x8 336 | return v.toString(16) 337 | }) 338 | } 339 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 都道府県別総人口推移グラフ 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yumemi-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "dev:without-msw": "VITE_WITHOUT_MSW=true vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint . --ext .js,.tsx,.tsx", 11 | "lint:fix": "npm run lint -- --fix", 12 | "format": "prettier --write **/*.{js,ts,tsx,css}", 13 | "prepare": "husky install", 14 | "lint-staged": "lint-staged", 15 | "storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006", 16 | "build-storybook": "NODE_OPTIONS=--openssl-legacy-provider build-storybook", 17 | "chromatic": "npx chromatic --exit-zero-on-changes" 18 | }, 19 | "lint-staged": { 20 | "!(docs)/**/*.{js,ts,tsx}": [ 21 | "eslint --fix", 22 | "prettier --write" 23 | ], 24 | "!(docs)/**/*.css": [ 25 | "prettier --write" 26 | ] 27 | }, 28 | "dependencies": { 29 | "@emotion/react": "^11.9.0", 30 | "@emotion/styled": "^11.8.1", 31 | "emotion-reset": "^3.0.1", 32 | "highcharts": "^10.0.0", 33 | "highcharts-react-official": "^3.1.0", 34 | "msw": "^0.42.0", 35 | "react": "^18.0.0", 36 | "react-dom": "^18.0.0", 37 | "react-error-boundary": "^3.1.4", 38 | "react-icons": "^4.3.1", 39 | "react-loading-overlay-ts": "^2.0.0", 40 | "react-query": "^3.35.0", 41 | "react-router-dom": "^6.3.0", 42 | "react-spinners": "^0.12.0", 43 | "spin-delay": "^1.1.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.18.2", 47 | "@storybook/addon-actions": "^6.5.5", 48 | "@storybook/addon-essentials": "^6.5.5", 49 | "@storybook/addon-interactions": "^6.5.5", 50 | "@storybook/addon-links": "^6.5.5", 51 | "@storybook/builder-vite": "^0.1.35", 52 | "@storybook/react": "^6.5.5", 53 | "@storybook/testing-library": "^0.0.11", 54 | "@types/react": "^18.0.0", 55 | "@types/react-dom": "^18.0.0", 56 | "@typescript-eslint/eslint-plugin": "^5.20.0", 57 | "@typescript-eslint/parser": "^5.20.0", 58 | "@vitejs/plugin-react": "^1.3.0", 59 | "babel-loader": "^8.2.5", 60 | "chromatic": "^6.5.4", 61 | "eslint": "^8.13.0", 62 | "eslint-config-airbnb": "^19.0.4", 63 | "eslint-config-airbnb-typescript": "^17.0.0", 64 | "eslint-config-prettier": "^8.5.0", 65 | "eslint-plugin-import": "^2.26.0", 66 | "eslint-plugin-jsx-a11y": "^6.5.1", 67 | "eslint-plugin-react": "^7.29.4", 68 | "eslint-plugin-react-hooks": "^4.4.0", 69 | "eslint-plugin-storybook": "^0.5.12", 70 | "eslint-plugin-unused-imports": "^2.0.0", 71 | "husky": "^7.0.0", 72 | "lint-staged": "^12.3.8", 73 | "prettier": "^2.6.2", 74 | "typescript": "^4.6.3", 75 | "vite": "^2.9.5", 76 | "vite-tsconfig-paths": "^3.4.1" 77 | }, 78 | "msw": { 79 | "workerDirectory": "public" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // noinspection JSUnusedLocalSymbols,EqualityComparisonWithCoercionJS 4 | 5 | /** 6 | * Mock Service Worker (0.42.0). 7 | * @see https://github.com/mswjs/msw 8 | * - Please do NOT modify this file. 9 | * - Please do NOT serve this file on production. 10 | */ 11 | 12 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'; 13 | const bypassHeaderName = 'x-msw-bypass'; 14 | const activeClientIds = new Set(); 15 | 16 | self.addEventListener('install', function () { 17 | return self.skipWaiting(); 18 | }); 19 | 20 | self.addEventListener('activate', async function (event) { 21 | return self.clients.claim(); 22 | }); 23 | 24 | self.addEventListener('message', async function (event) { 25 | const clientId = event.source.id; 26 | 27 | if (!clientId || !self.clients) { 28 | return; 29 | } 30 | 31 | const client = await self.clients.get(clientId); 32 | 33 | if (!client) { 34 | return; 35 | } 36 | 37 | const allClients = await self.clients.matchAll(); 38 | 39 | switch (event.data) { 40 | case 'KEEPALIVE_REQUEST': { 41 | sendToClient(client, { 42 | type: 'KEEPALIVE_RESPONSE', 43 | }); 44 | break; 45 | } 46 | 47 | case 'INTEGRITY_CHECK_REQUEST': { 48 | sendToClient(client, { 49 | type: 'INTEGRITY_CHECK_RESPONSE', 50 | payload: INTEGRITY_CHECKSUM, 51 | }); 52 | break; 53 | } 54 | 55 | case 'MOCK_ACTIVATE': { 56 | activeClientIds.add(clientId); 57 | 58 | sendToClient(client, { 59 | type: 'MOCKING_ENABLED', 60 | payload: true, 61 | }); 62 | break; 63 | } 64 | 65 | case 'MOCK_DEACTIVATE': { 66 | activeClientIds.delete(clientId); 67 | break; 68 | } 69 | 70 | case 'CLIENT_CLOSED': { 71 | activeClientIds.delete(clientId); 72 | 73 | const remainingClients = allClients.filter((client) => { 74 | return client.id !== clientId; 75 | }); 76 | 77 | // Unregister itself when there are no more clients 78 | if (remainingClients.length === 0) { 79 | self.registration.unregister(); 80 | } 81 | 82 | break; 83 | } 84 | } 85 | }); 86 | 87 | // Resolve the "main" client for the given event. 88 | // Client that issues a request doesn't necessarily equal the client 89 | // that registered the worker. It's with the latter the worker should 90 | // communicate with during the response resolving phase. 91 | async function resolveMainClient(event) { 92 | const client = await self.clients.get(event.clientId); 93 | 94 | if (client.frameType === 'top-level') { 95 | return client; 96 | } 97 | 98 | const allClients = await self.clients.matchAll(); 99 | 100 | return allClients 101 | .filter((client) => { 102 | // Get only those clients that are currently visible. 103 | return client.visibilityState === 'visible'; 104 | }) 105 | .find((client) => { 106 | // Find the client ID that's recorded in the 107 | // set of clients that have registered the worker. 108 | return activeClientIds.has(client.id); 109 | }); 110 | } 111 | 112 | async function handleRequest(event, requestId) { 113 | const client = await resolveMainClient(event); 114 | const response = await getResponse(event, client, requestId); 115 | 116 | // Send back the response clone for the "response:*" life-cycle events. 117 | // Ensure MSW is active and ready to handle the message, otherwise 118 | // this message will pend indefinitely. 119 | if (client && activeClientIds.has(client.id)) { 120 | (async function () { 121 | const clonedResponse = response.clone(); 122 | sendToClient(client, { 123 | type: 'RESPONSE', 124 | payload: { 125 | requestId, 126 | type: clonedResponse.type, 127 | ok: clonedResponse.ok, 128 | status: clonedResponse.status, 129 | statusText: clonedResponse.statusText, 130 | body: clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }); 135 | })(); 136 | } 137 | 138 | return response; 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event; 143 | const requestClone = request.clone(); 144 | const getOriginalResponse = () => fetch(requestClone); 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse(); 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse(); 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers); 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName]; 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }); 169 | 170 | return fetch(originalRequest); 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers); 175 | const body = await request.text(); 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }); 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay); 201 | } 202 | 203 | case 'MOCK_NOT_FOUND': { 204 | return getOriginalResponse(); 205 | } 206 | 207 | case 'NETWORK_ERROR': { 208 | const { name, message } = clientMessage.payload; 209 | const networkError = new Error(message); 210 | networkError.name = name; 211 | 212 | // Rejecting a request Promise emulates a network error. 213 | throw networkError; 214 | } 215 | 216 | case 'INTERNAL_ERROR': { 217 | const parsedBody = JSON.parse(clientMessage.payload.body); 218 | 219 | console.error( 220 | `\ 221 | [MSW] Uncaught exception in the request handler for "%s %s": 222 | 223 | ${parsedBody.location} 224 | 225 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 226 | `, 227 | request.method, 228 | request.url, 229 | ); 230 | 231 | return respondWithMock(clientMessage); 232 | } 233 | } 234 | 235 | return getOriginalResponse(); 236 | } 237 | 238 | self.addEventListener('fetch', function (event) { 239 | const { request } = event; 240 | const accept = request.headers.get('accept') || ''; 241 | 242 | // Bypass server-sent events. 243 | if (accept.includes('text/event-stream')) { 244 | return; 245 | } 246 | 247 | // Bypass navigation requests. 248 | if (request.mode === 'navigate') { 249 | return; 250 | } 251 | 252 | // Opening the DevTools triggers the "only-if-cached" request 253 | // that cannot be handled by the worker. Bypass such requests. 254 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 255 | return; 256 | } 257 | 258 | // Bypass all requests when there are no active clients. 259 | // Prevents the self-unregistered worked from handling requests 260 | // after it's been deleted (still remains active until the next reload). 261 | if (activeClientIds.size === 0) { 262 | return; 263 | } 264 | 265 | const requestId = uuidv4(); 266 | 267 | return event.respondWith( 268 | handleRequest(event, requestId).catch((error) => { 269 | if (error.name === 'NetworkError') { 270 | console.warn( 271 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 272 | request.method, 273 | request.url, 274 | ); 275 | return; 276 | } 277 | 278 | // At this point, any exception indicates an issue with the original request/response. 279 | console.error( 280 | `\ 281 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 282 | request.method, 283 | request.url, 284 | `${error.name}: ${error.message}`, 285 | ); 286 | }), 287 | ); 288 | }); 289 | 290 | function serializeHeaders(headers) { 291 | const reqHeaders = {}; 292 | headers.forEach((value, name) => { 293 | reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value; 294 | }); 295 | return reqHeaders; 296 | } 297 | 298 | function sendToClient(client, message) { 299 | return new Promise((resolve, reject) => { 300 | const channel = new MessageChannel(); 301 | 302 | channel.port1.onmessage = (event) => { 303 | if (event.data && event.data.error) { 304 | return reject(event.data.error); 305 | } 306 | 307 | resolve(event.data); 308 | }; 309 | 310 | client.postMessage(JSON.stringify(message), [channel.port2]); 311 | }); 312 | } 313 | 314 | function delayPromise(cb, duration) { 315 | return new Promise((resolve) => { 316 | setTimeout(() => resolve(cb()), duration); 317 | }); 318 | } 319 | 320 | function respondWithMock(clientMessage) { 321 | return new Response(clientMessage.payload.body, { 322 | ...clientMessage.payload, 323 | headers: clientMessage.payload.headers, 324 | }); 325 | } 326 | 327 | function uuidv4() { 328 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 329 | const r = (Math.random() * 16) | 0; 330 | const v = c == 'x' ? r : (r & 0x3) | 0x8; 331 | return v.toString(16); 332 | }); 333 | } 334 | -------------------------------------------------------------------------------- /src/api/ApiClientBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ErrorBoundary, ErrorBoundaryPropsWithRender } from 'react-error-boundary'; 3 | import { useQueryErrorResetBoundary } from 'react-query'; 4 | 5 | type Props = { 6 | children: ReactNode; 7 | renderErrorFallback: ErrorBoundaryPropsWithRender['fallbackRender']; 8 | }; 9 | 10 | export const ApiClientBoundary = ({ children, renderErrorFallback }: Props) => { 11 | const { reset } = useQueryErrorResetBoundary(); 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/api/ApiClientProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import { ReactQueryDevtools } from 'react-query/devtools'; 4 | import { ResasApiKeyProvider } from 'src/api/resas/ResasApiKeyProvider'; 5 | 6 | const queryClient = new QueryClient({ 7 | defaultOptions: { 8 | queries: { 9 | retry: false, 10 | refetchOnWindowFocus: false, 11 | cacheTime: Infinity, 12 | staleTime: Infinity, 13 | useErrorBoundary: true, 14 | }, 15 | }, 16 | }); 17 | 18 | type Props = { 19 | children: ReactNode; 20 | initialResasApiKey?: string; 21 | }; 22 | 23 | export const ApiClientProvider = ({ children, initialResasApiKey }: Props) => ( 24 | 25 | {children} 26 | 27 | 28 | ); 29 | 30 | ApiClientProvider.defaultProps = { 31 | initialResasApiKey: undefined, 32 | }; 33 | -------------------------------------------------------------------------------- /src/api/resas/ResasApiKeyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, Dispatch, ReactNode, SetStateAction, useState } from 'react'; 2 | 3 | export const ResasApiKeyContext = createContext(undefined); 4 | 5 | export const SetResasApiKeyContext = createContext>>(() => {}); 6 | 7 | type Props = { 8 | children: ReactNode; 9 | initialResasApiKey?: string; 10 | }; 11 | 12 | export const ResasApiKeyProvider = ({ children, initialResasApiKey }: Props) => { 13 | const [apiKey, setApiKey] = useState(initialResasApiKey); 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | ResasApiKeyProvider.defaultProps = { 22 | initialResasApiKey: undefined, 23 | }; 24 | -------------------------------------------------------------------------------- /src/api/resas/ResasClient.ts: -------------------------------------------------------------------------------- 1 | import { PopulationPerYear, Prefecture } from 'src/types'; 2 | 3 | const RESAS_ENDPOINT = 'https://opendata.resas-portal.go.jp'; 4 | 5 | type PrefecturesApiResponse = { 6 | result: Prefecture[]; 7 | }; 8 | 9 | type PopulationApiResponse = { 10 | result: { 11 | data: [{ data: PopulationPerYear[] }]; 12 | }; 13 | }; 14 | 15 | export class ResasClient { 16 | private readonly apiKey: string; 17 | 18 | constructor(apiKey: string) { 19 | this.apiKey = apiKey; 20 | } 21 | 22 | public async fetchPrefectures() { 23 | const response = await fetch(`${RESAS_ENDPOINT}/api/v1/prefectures`, this.option()); 24 | if (!response.ok) { 25 | throw new Error(`RESAS API response was not ok. status=${response.status}`); 26 | } 27 | const json = (await response.json()) as PrefecturesApiResponse; 28 | if (!json.result) { 29 | throw new Error(`RESAS API result was not ok. json=${JSON.stringify(json)}`); 30 | } 31 | return json.result; 32 | } 33 | 34 | public async fetchPopulations(prefCode: number) { 35 | const q = new URLSearchParams({ prefCode: String(prefCode), cityCode: '-' }).toString(); 36 | const response = await fetch(`${RESAS_ENDPOINT}/api/v1/population/composition/perYear?${q}`, this.option()); 37 | if (!response.ok) { 38 | throw new Error(`RESAS API response was not ok. status=${response.status}`); 39 | } 40 | const json = (await response.json()) as PopulationApiResponse; 41 | if (!json.result) { 42 | throw new Error(`RESAS API result was not ok. json=${JSON.stringify(json)}`); 43 | } 44 | return json.result.data[0].data; 45 | } 46 | 47 | private option() { 48 | return { 49 | headers: { 50 | 'x-api-key': this.apiKey, 51 | }, 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/api/resas/useResasApiKey.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useContext } from 'react'; 2 | import { ResasApiKeyContext, SetResasApiKeyContext } from 'src/api/resas/ResasApiKeyProvider'; 3 | 4 | export const useResasApiKey = (): [string | undefined, Dispatch>] => { 5 | const setResasApiKey = useContext(SetResasApiKeyContext); 6 | if (!setResasApiKey) { 7 | throw new Error('The ResasApiKeyProvider is missing.'); 8 | } 9 | const resasApiKey = useContext(ResasApiKeyContext); 10 | return [resasApiKey, setResasApiKey]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/api/resas/useResasClient.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { ResasClient } from 'src/api/resas/ResasClient'; 3 | import { useResasApiKey } from 'src/api/resas/useResasApiKey'; 4 | 5 | export const useResasClient = () => { 6 | const [resasApiKey] = useResasApiKey(); 7 | if (!resasApiKey) { 8 | throw new Error('No ResasApiKey set, useApiClientInitializer to set one'); 9 | } 10 | return useMemo(() => new ResasClient(resasApiKey), [resasApiKey]); 11 | }; 12 | -------------------------------------------------------------------------------- /src/api/useApiClientInitializer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { useQueryClient } from 'react-query'; 3 | import { useResasApiKey } from 'src/api/resas/useResasApiKey'; 4 | 5 | export const useApiClientInitializer = () => { 6 | const [resasApiKey, setResasApiKey] = useResasApiKey(); 7 | const queryClient = useQueryClient(); 8 | 9 | const isInitialized = !!resasApiKey; 10 | 11 | const initialize = useCallback( 12 | (newResasApiKey: string) => { 13 | queryClient.clear(); 14 | setResasApiKey(newResasApiKey); 15 | }, 16 | [queryClient, setResasApiKey], 17 | ); 18 | 19 | const reset = useCallback(() => { 20 | queryClient.clear(); 21 | setResasApiKey(undefined); 22 | }, [queryClient, setResasApiKey]); 23 | 24 | return useMemo(() => ({ isInitialized, initialize, reset }), [isInitialized, initialize, reset]); 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/usePopulationsQueries.ts: -------------------------------------------------------------------------------- 1 | import { useQueries } from 'react-query'; 2 | import { useResasClient } from 'src/api/resas/useResasClient'; 3 | import { PopulationPerYear, Prefecture, PrefecturePopulation } from 'src/types'; 4 | 5 | export const usePopulationsQueries = (prefectures: Prefecture[]) => { 6 | const resasClient = useResasClient(); 7 | return useQueries( 8 | prefectures.map((it) => ({ 9 | queryKey: ['population', it.prefCode], 10 | queryFn: () => resasClient.fetchPopulations(it.prefCode), 11 | select: (populations: PopulationPerYear[]): PrefecturePopulation => ({ 12 | ...it, 13 | populations, 14 | }), 15 | })), 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/api/usePrefecturesQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { useResasClient } from 'src/api/resas/useResasClient'; 3 | 4 | export const usePrefecturesQuery = () => { 5 | const resasClient = useResasClient(); 6 | return useQuery('prefectures', () => resasClient.fetchPrefectures()); 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { MemoryRouter } from 'react-router-dom'; 2 | import { ApiClientProvider } from 'src/api/ApiClientProvider'; 3 | import { AppRoutes } from 'src/app/routes/AppRoutes'; 4 | import { AppThemeProvider } from 'src/app/themes/AppThemeProvider'; 5 | 6 | export const App = () => ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/app/layouts/PageLayout/ErrorFallback/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { MdReplay } from 'react-icons/all'; 4 | import { Button } from 'src/libs/Button'; 5 | import { Headline } from 'src/libs/Headline'; 6 | 7 | const ButtonContainer = styled.a` 8 | justify-self: center; 9 | `; 10 | 11 | const TextWrapper = styled.p` 12 | display: grid; 13 | grid-row-gap: 12px; 14 | `; 15 | 16 | const ErrorMessage = styled.div( 17 | ({ theme }) => css` 18 | ${theme.fonts.bodyL} 19 | `, 20 | ); 21 | 22 | const ErrorFallbackRoot = styled.div` 23 | display: grid; 24 | grid-row-gap: 24px; 25 | 26 | margin: 0 auto 0 auto; 27 | padding: 0 24px; 28 | max-width: 500px; 29 | 30 | @media (max-width: 40em) { 31 | width: 100%; 32 | min-width: 350px; 33 | } 34 | `; 35 | 36 | type Props = { 37 | onReset: () => void; 38 | }; 39 | 40 | export const ErrorFallback = ({ onReset }: Props) => ( 41 | 42 | 43 | エラーが発生しました 44 | しばらく待ってリトライするか、前の画面に戻ってAPIキーを入力しなおしてください。 45 | 46 | 47 |