├── .env ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .storybook ├── decorators │ └── addon-redux-toolkit │ │ ├── index.ts │ │ ├── redux-action-prevent │ │ └── index.ts │ │ ├── withProvider.tsx │ │ └── withRedux.ts ├── main.js └── preview.js ├── .vscode └── settings.json ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── reset.css └── robots.txt ├── src ├── __tests__ │ └── normalize.test.ts ├── api │ └── post.ts ├── components │ ├── App.tsx │ ├── main │ │ ├── Label.tsx │ │ ├── LabelIndex.tsx │ │ └── Main.tsx │ ├── post │ │ ├── Comment.tsx │ │ ├── Post.tsx │ │ └── PostContents.tsx │ ├── shared │ │ ├── error │ │ │ ├── NotFoundPage.tsx │ │ │ └── PopupError.tsx │ │ ├── layout │ │ │ ├── Dimmed.tsx │ │ │ ├── ListWrapper.tsx │ │ │ └── MainContainer.tsx │ │ └── loading │ │ │ └── Loading.tsx │ └── user │ │ ├── User.tsx │ │ └── UserProfile.tsx ├── features │ ├── comment │ │ ├── CommentModel.ts │ │ └── CommentSlice.ts │ ├── common │ │ ├── error │ │ │ └── ErrorSlice.ts │ │ └── loading │ │ │ └── LoadingSlice.ts │ ├── index.ts │ ├── post │ │ ├── Post.test.ts │ │ ├── PostModel.ts │ │ └── PostSlice.ts │ └── user │ │ ├── UserModel.ts │ │ └── UserSlice.ts ├── index.tsx ├── react-app-env.d.ts ├── setupTests.ts ├── stories │ ├── addons.ts │ └── main │ │ ├── Label.stories.tsx │ │ └── Main.stories.tsx ├── styles │ └── colors.ts ├── typings │ └── index.ts └── utils │ ├── history.ts │ └── redux.ts ├── tsconfig.json └── tsconfig.paths.json /.env: -------------------------------------------------------------------------------- 1 | EXTEND_ESLINT=true -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"], 3 | "plugins": ["react-hooks", "simple-import-sort"], 4 | "rules": { 5 | "react-hooks/rules-of-hooks": "error", 6 | "simple-import-sort/sort": "error", 7 | "no-multiple-empty-lines": "error", 8 | "comma-dangle": ["error", "always-multiline"], 9 | "eol-last": ["error", "always"], 10 | "semi": ["error", "never"], 11 | "quotes": ["error", "single"], 12 | "no-tabs": "error", 13 | "padding-line-between-statements": [ 14 | "error", 15 | { "blankLine": "always", "prev": "*", "next": "return" } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/decorators/addon-redux-toolkit/index.ts: -------------------------------------------------------------------------------- 1 | export { withProvider } from './withProvider' 2 | export { withRedux } from './withRedux' -------------------------------------------------------------------------------- /.storybook/decorators/addon-redux-toolkit/redux-action-prevent/index.ts: -------------------------------------------------------------------------------- 1 | import { Action, Middleware, MiddlewareAPI } from '@reduxjs/toolkit' 2 | 3 | export type ActionsPreventMiddlewareOptionType = { 4 | allowedActions?: string[] 5 | debug?: boolean 6 | } 7 | 8 | export const preventActions = (option: ActionsPreventMiddlewareOptionType) => { 9 | const actionPreventMiddleware: Middleware = (store: MiddlewareAPI) => { 10 | option.debug && console.log(`[ACTION_PREVENT] Applied!`) 11 | 12 | return next => (action: A) => { 13 | if (option.allowedActions?.includes(action.type)) { 14 | return next(action) 15 | } 16 | 17 | return store.getState() 18 | } 19 | } 20 | 21 | return actionPreventMiddleware 22 | } 23 | -------------------------------------------------------------------------------- /.storybook/decorators/addon-redux-toolkit/withProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | 4 | import store from '@/features' 5 | 6 | type StoryFunctionType = () => React.ReactNode 7 | 8 | export const withProvider = (reduxStore = store) => (storyFn: StoryFunctionType) => { 9 | return {storyFn()} 10 | } -------------------------------------------------------------------------------- /.storybook/decorators/addon-redux-toolkit/withRedux.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, getDefaultMiddleware, Reducer } from '@reduxjs/toolkit' 2 | 3 | import { withProvider } from './withProvider' 4 | import { 5 | preventActions, 6 | ActionsPreventMiddlewareOptionType, 7 | } from './redux-action-prevent' 8 | 9 | const defaultWithReduxOption: ActionsPreventMiddlewareOptionType = { 10 | allowedActions: [], 11 | debug: false, 12 | } 13 | 14 | export const withRedux = (rootReducer: R) => ( 15 | mockState: any, 16 | customOption: ActionsPreventMiddlewareOptionType = defaultWithReduxOption, 17 | ) => { 18 | const option = { ...defaultWithReduxOption, ...customOption } 19 | const preventActionsMiddleware = preventActions(option) 20 | 21 | const store = configureStore({ 22 | reducer: rootReducer, 23 | preloadedState: mockState, 24 | devTools: true, 25 | middleware: [...getDefaultMiddleware(), preventActionsMiddleware], 26 | }) 27 | 28 | option.debug && console.log('[STORE] -> ', store.getState()) 29 | 30 | return withProvider(store) 31 | } 32 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const customWebpackConfig = require('../craco.config.js') 3 | 4 | module.exports = { 5 | stories: ['../src/**/*.stories.tsx'], 6 | addons: [ 7 | '@storybook/preset-create-react-app', 8 | '@storybook/addon-actions', 9 | '@storybook/addon-links', 10 | '@storybook/addon-viewport/register', 11 | ], 12 | webpackFinal: async config => { 13 | const { webpack } = customWebpackConfig() 14 | 15 | return { 16 | ...config, 17 | resolve: { 18 | ...config.resolve, 19 | alias: { 20 | ...config.resolve.alias, 21 | ...webpack.alias, 22 | '.storybook': path.join(__dirname, '.'), 23 | }, 24 | }, 25 | } 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { addDecorator, addParameters } from '@storybook/react' 2 | import { withProvider } from './decorators/addon-redux-toolkit' 3 | import StoryRouter from 'storybook-react-router' 4 | import { withConsole } from '@storybook/addon-console' 5 | import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport' 6 | 7 | addDecorator((storyFn, context) => withConsole()(storyFn)(context)) 8 | addDecorator(withProvider()) 9 | addDecorator(StoryRouter()) 10 | 11 | addParameters({ 12 | viewport: { 13 | viewports: INITIAL_VIEWPORTS, 14 | defaultViewport: 'iphonex', 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "javascript.format.enable": false, 4 | "eslint.alwaysShowStatus": true, 5 | "eslint.options": { 6 | "extensions": [".html", ".ts", ".js", ".tsx"] 7 | }, 8 | "files.autoSaveDelay": 500, 9 | "eslint.packageManager": "yarn", 10 | "typescript.tsdk": "node_modules/typescript/lib", 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-data-handling-lab 2 | 3 | - [Redux에서 Normalize 다루기](https://jbee.io/react/react-redux-normalize/) 4 | - [고통없는 UI 개발을 위한 Storybook](https://jbee.io/tool/storybook-intro/) 5 | 6 | ## Author 7 | 8 | 👤 **JaeYeopHan (Jbee)** 9 | 10 | - Github: [@JaeYeopHan](https://github.com/JaeYeopHan) 11 | - Twitter: [@JbeeLjyhanll](https://twitter.com/JbeeLjyhanll) 12 | 13 |
14 | 15 | Written by @Jbee 16 | 17 |
-------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const resolve = arg => path.resolve(__dirname, arg) 3 | 4 | module.exports = function() { 5 | return { 6 | babel: { 7 | plugins: [ 8 | [ 9 | 'emotion', 10 | { 11 | labelFormat: '[filename]--[local]', 12 | }, 13 | ], 14 | ], 15 | }, 16 | webpack: { 17 | alias: { 18 | '@': resolve('src'), 19 | }, 20 | }, 21 | jest: { 22 | configure: { 23 | moduleNameMapper: { 24 | '^@/(.*)$': '/src/$1', 25 | }, 26 | }, 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normalize-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^5.6.2", 7 | "@emotion/core": "^10.0.27", 8 | "@emotion/styled": "^10.0.27", 9 | "@reduxjs/toolkit": "^1.1.0", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.3.2", 12 | "@testing-library/user-event": "^7.1.2", 13 | "@types/jest": "^24.0.0", 14 | "@types/node": "^12.0.0", 15 | "@types/react": "^16.9.0", 16 | "@types/react-dom": "^16.9.0", 17 | "@types/react-redux": "^7.1.5", 18 | "@types/react-router": "^5.1.3", 19 | "@types/react-router-dom": "^5.1.3", 20 | "babel-plugin-emotion": "^10.0.27", 21 | "eslint-plugin-simple-import-sort": "^5.0.0", 22 | "normalizr": "^3.5.0", 23 | "react": "^16.12.0", 24 | "react-dom": "^16.12.0", 25 | "react-redux": "^7.1.3", 26 | "react-router": "^5.1.2", 27 | "react-router-dom": "^5.1.2", 28 | "react-scripts": "3.3.0", 29 | "typescript": "~3.7.2" 30 | }, 31 | "scripts": { 32 | "start": "craco start", 33 | "build": "craco build", 34 | "test": "craco test", 35 | "eject": "react-scripts eject", 36 | "lint": "eslint .", 37 | "storybook": "start-storybook -p 9009 -s public", 38 | "build-storybook": "build-storybook -s public" 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | }, 55 | "devDependencies": { 56 | "@storybook/addon-actions": "^5.3.12", 57 | "@storybook/addon-console": "^1.2.1", 58 | "@storybook/addon-links": "^5.3.12", 59 | "@storybook/addon-viewport": "^5.3.12", 60 | "@storybook/addons": "^5.3.12", 61 | "@storybook/preset-create-react-app": "^1.5.2", 62 | "@storybook/react": "^5.3.12", 63 | "@types/storybook-react-router": "^1.0.1", 64 | "storybook-react-router": "^1.0.8" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbee37142/react-data-handling-lab/7add260699eae3ccfb2599b6d430927e9c60b967/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | React Unicorn Template 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbee37142/react-data-handling-lab/7add260699eae3ccfb2599b6d430927e9c60b967/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbee37142/react-data-handling-lab/7add260699eae3ccfb2599b6d430927e9c60b967/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0-modified | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | 95 | /* make sure to set some focus styles for accessibility */ 96 | :focus { 97 | outline: 0; 98 | } 99 | 100 | /* HTML5 display-role reset for older browsers */ 101 | article, 102 | aside, 103 | details, 104 | figcaption, 105 | figure, 106 | footer, 107 | header, 108 | hgroup, 109 | menu, 110 | nav, 111 | section { 112 | display: block; 113 | } 114 | 115 | body { 116 | line-height: 1; 117 | } 118 | 119 | ol, 120 | ul { 121 | list-style: none; 122 | } 123 | 124 | blockquote, 125 | q { 126 | quotes: none; 127 | } 128 | 129 | blockquote:before, 130 | blockquote:after, 131 | q:before, 132 | q:after { 133 | content: ""; 134 | content: none; 135 | } 136 | 137 | table { 138 | border-collapse: collapse; 139 | border-spacing: 0; 140 | } 141 | 142 | input[type="search"]::-webkit-search-cancel-button, 143 | input[type="search"]::-webkit-search-decoration, 144 | input[type="search"]::-webkit-search-results-button, 145 | input[type="search"]::-webkit-search-results-decoration { 146 | -webkit-appearance: none; 147 | -moz-appearance: none; 148 | } 149 | 150 | input[type="search"] { 151 | -webkit-appearance: none; 152 | -moz-appearance: none; 153 | -webkit-box-sizing: content-box; 154 | -moz-box-sizing: content-box; 155 | box-sizing: content-box; 156 | } 157 | 158 | textarea { 159 | overflow: auto; 160 | vertical-align: top; 161 | resize: vertical; 162 | } 163 | 164 | /** 165 | * Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3. 166 | */ 167 | 168 | audio, 169 | canvas, 170 | video { 171 | display: inline-block; 172 | *display: inline; 173 | *zoom: 1; 174 | max-width: 100%; 175 | } 176 | 177 | /** 178 | * Prevent modern browsers from displaying `audio` without controls. 179 | * Remove excess height in iOS 5 devices. 180 | */ 181 | 182 | audio:not([controls]) { 183 | display: none; 184 | height: 0; 185 | } 186 | 187 | /** 188 | * Address styling not present in IE 7/8/9, Firefox 3, and Safari 4. 189 | * Known issue: no IE 6 support. 190 | */ 191 | 192 | [hidden] { 193 | display: none; 194 | } 195 | 196 | /** 197 | * 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using 198 | * `em` units. 199 | * 2. Prevent iOS text size adjust after orientation change, without disabling 200 | * user zoom. 201 | */ 202 | 203 | html { 204 | font-size: 100%; /* 1 */ 205 | -webkit-text-size-adjust: 100%; /* 2 */ 206 | -ms-text-size-adjust: 100%; /* 2 */ 207 | } 208 | 209 | /** 210 | * Address `outline` inconsistency between Chrome and other browsers. 211 | */ 212 | 213 | a:focus { 214 | outline: thin dotted; 215 | } 216 | 217 | /** 218 | * Improve readability when focused and also mouse hovered in all browsers. 219 | */ 220 | 221 | a:active, 222 | a:hover { 223 | outline: 0; 224 | } 225 | 226 | /** 227 | * 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3. 228 | * 2. Improve image quality when scaled in IE 7. 229 | */ 230 | 231 | img { 232 | border: 0; /* 1 */ 233 | -ms-interpolation-mode: bicubic; /* 2 */ 234 | } 235 | 236 | /** 237 | * Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11. 238 | */ 239 | 240 | figure { 241 | margin: 0; 242 | } 243 | 244 | /** 245 | * Correct margin displayed oddly in IE 6/7. 246 | */ 247 | 248 | form { 249 | margin: 0; 250 | } 251 | 252 | /** 253 | * Define consistent border, margin, and padding. 254 | */ 255 | 256 | fieldset { 257 | border: 1px solid #c0c0c0; 258 | margin: 0 2px; 259 | padding: 0.35em 0.625em 0.75em; 260 | } 261 | 262 | /** 263 | * 1. Correct color not being inherited in IE 6/7/8/9. 264 | * 2. Correct text not wrapping in Firefox 3. 265 | * 3. Correct alignment displayed oddly in IE 6/7. 266 | */ 267 | 268 | legend { 269 | border: 0; /* 1 */ 270 | padding: 0; 271 | white-space: normal; /* 2 */ 272 | *margin-left: -7px; /* 3 */ 273 | } 274 | 275 | /** 276 | * 1. Correct font size not being inherited in all browsers. 277 | * 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5, 278 | * and Chrome. 279 | * 3. Improve appearance and consistency in all browsers. 280 | */ 281 | 282 | button, 283 | input, 284 | select, 285 | textarea { 286 | font-size: 100%; /* 1 */ 287 | margin: 0; /* 2 */ 288 | vertical-align: baseline; /* 3 */ 289 | *vertical-align: middle; /* 3 */ 290 | } 291 | 292 | /** 293 | * Address Firefox 3+ setting `line-height` on `input` using `!important` in 294 | * the UA stylesheet. 295 | */ 296 | 297 | button, 298 | input { 299 | line-height: normal; 300 | } 301 | 302 | /** 303 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 304 | * All other form control elements do not inherit `text-transform` values. 305 | * Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+. 306 | * Correct `select` style inheritance in Firefox 4+ and Opera. 307 | */ 308 | 309 | button, 310 | select { 311 | text-transform: none; 312 | } 313 | 314 | /** 315 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 316 | * and `video` controls. 317 | * 2. Correct inability to style clickable `input` types in iOS. 318 | * 3. Improve usability and consistency of cursor style between image-type 319 | * `input` and others. 320 | * 4. Remove inner spacing in IE 7 without affecting normal text inputs. 321 | * Known issue: inner spacing remains in IE 6. 322 | */ 323 | 324 | button, 325 | html input[type="button"], /* 1 */ 326 | input[type="reset"], 327 | input[type="submit"] { 328 | -webkit-appearance: button; /* 2 */ 329 | cursor: pointer; /* 3 */ 330 | *overflow: visible; /* 4 */ 331 | } 332 | 333 | /** 334 | * Re-set default cursor for disabled elements. 335 | */ 336 | 337 | button[disabled], 338 | html input[disabled] { 339 | cursor: default; 340 | } 341 | 342 | /** 343 | * 1. Address box sizing set to content-box in IE 8/9. 344 | * 2. Remove excess padding in IE 8/9. 345 | * 3. Remove excess padding in IE 7. 346 | * Known issue: excess padding remains in IE 6. 347 | */ 348 | 349 | input[type="checkbox"], 350 | input[type="radio"] { 351 | box-sizing: border-box; /* 1 */ 352 | padding: 0; /* 2 */ 353 | *height: 13px; /* 3 */ 354 | *width: 13px; /* 3 */ 355 | } 356 | 357 | /** 358 | * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. 359 | * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome 360 | * (include `-moz` to future-proof). 361 | */ 362 | 363 | input[type="search"] { 364 | -webkit-appearance: textfield; /* 1 */ 365 | -moz-box-sizing: content-box; 366 | -webkit-box-sizing: content-box; /* 2 */ 367 | box-sizing: content-box; 368 | } 369 | 370 | /** 371 | * Remove inner padding and search cancel button in Safari 5 and Chrome 372 | * on OS X. 373 | */ 374 | 375 | input[type="search"]::-webkit-search-cancel-button, 376 | input[type="search"]::-webkit-search-decoration { 377 | -webkit-appearance: none; 378 | } 379 | 380 | /** 381 | * Remove inner padding and border in Firefox 3+. 382 | */ 383 | 384 | button::-moz-focus-inner, 385 | input::-moz-focus-inner { 386 | border: 0; 387 | padding: 0; 388 | } 389 | 390 | /** 391 | * 1. Remove default vertical scrollbar in IE 6/7/8/9. 392 | * 2. Improve readability and alignment in all browsers. 393 | */ 394 | 395 | textarea { 396 | overflow: auto; /* 1 */ 397 | vertical-align: top; /* 2 */ 398 | } 399 | 400 | /** 401 | * Remove most spacing between table cells. 402 | */ 403 | 404 | table { 405 | border-collapse: collapse; 406 | border-spacing: 0; 407 | } 408 | 409 | html, 410 | button, 411 | input, 412 | select, 413 | textarea { 414 | color: #222; 415 | } 416 | 417 | ::-moz-selection { 418 | background: #b3d4fc; 419 | text-shadow: none; 420 | } 421 | 422 | ::selection { 423 | background: #b3d4fc; 424 | text-shadow: none; 425 | } 426 | 427 | img { 428 | vertical-align: middle; 429 | } 430 | 431 | fieldset { 432 | border: 0; 433 | margin: 0; 434 | padding: 0; 435 | } 436 | 437 | textarea { 438 | resize: vertical; 439 | } 440 | 441 | .chromeframe { 442 | margin: 0.2em 0; 443 | background: #ccc; 444 | color: #000; 445 | padding: 0.2em 0; 446 | } 447 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/__tests__/normalize.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizePost } from '@/features/post/PostModel' 2 | 3 | test('should return normalized post data', () => { 4 | // Given 5 | const data = [ 6 | { 7 | id: 'post1', 8 | title: 'First Post', 9 | author: { id: 'user1', name: 'User 1' }, 10 | body: '...post contents 1..', 11 | comments: [ 12 | { 13 | id: 1, 14 | author: { id: 'user2', name: 'User 2' }, 15 | comment: '...comment 1-1..', 16 | }, 17 | { 18 | id: 2, 19 | author: { id: 'user3', name: 'User 3' }, 20 | comment: '...comment 1-2..', 21 | }, 22 | ], 23 | }, 24 | { 25 | id: 'post2', 26 | title: 'Second Post', 27 | author: { id: 'user2', name: 'User 2' }, 28 | body: '...post contents 2...', 29 | comments: [], 30 | }, 31 | ] 32 | 33 | // When 34 | const result = normalizePost(data) 35 | 36 | // Then 37 | expect(result).toEqual({ 38 | entities: { 39 | posts: { 40 | post1: { 41 | id: 'post1', 42 | title: 'First Post', 43 | author: 'user1', 44 | body: '...post contents 1..', 45 | comments: [1, 2], 46 | }, 47 | post2: { 48 | id: 'post2', 49 | title: 'Second Post', 50 | author: 'user2', 51 | body: '...post contents 2...', 52 | comments: [], 53 | }, 54 | }, 55 | comments: { 56 | 1: { 57 | id: 1, 58 | author: 'user2', 59 | comment: '...comment 1-1..', 60 | }, 61 | 2: { 62 | id: 2, 63 | author: 'user3', 64 | comment: '...comment 1-2..', 65 | }, 66 | }, 67 | users: { 68 | user1: { 69 | id: 'user1', 70 | name: 'User 1', 71 | }, 72 | user2: { 73 | id: 'user2', 74 | name: 'User 2', 75 | }, 76 | user3: { 77 | id: 'user3', 78 | name: 'User 3', 79 | }, 80 | }, 81 | }, 82 | result: ['post1', 'post2'], 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/api/post.ts: -------------------------------------------------------------------------------- 1 | import { IPost } from '@/features/post/PostModel' 2 | 3 | export type IGetPostsResponse = IPost[] 4 | 5 | export function getPosts(): Promise { 6 | return Promise.resolve([ 7 | { 8 | id: 'post1', 9 | title: 'First post', 10 | author: { id: 'user1', name: 'User 1' }, 11 | body: 12 | 'First, Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,', 13 | comments: [ 14 | { 15 | id: 1, 16 | author: { id: 'user2', name: 'User 2' }, 17 | comment: '...comment 1-1..', 18 | }, 19 | { 20 | id: 2, 21 | author: { id: 'user3', name: 'User 3' }, 22 | comment: '...comment 1-2..', 23 | }, 24 | ], 25 | }, 26 | { 27 | id: 'post2', 28 | title: 'Second post', 29 | author: { id: 'user2', name: 'User 2' }, 30 | body: 31 | 'Second, Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,', 32 | comments: [ 33 | { 34 | id: 3, 35 | author: { id: 'user3', name: 'User 3' }, 36 | comment: '...comment 2-1..', 37 | }, 38 | { 39 | id: 4, 40 | author: { id: 'user1', name: 'User 1' }, 41 | comment: '...comment 2-2..', 42 | }, 43 | { 44 | id: 5, 45 | author: { id: 'user3', name: 'User 3' }, 46 | comment: '...comment 2-3..', 47 | }, 48 | ], 49 | }, 50 | ]) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Switch } from 'react-router-dom' 3 | 4 | import Main from './main/Main' 5 | import Post from './post/Post' 6 | import { PopupError } from './shared/error/PopupError' 7 | import { User } from './user/User' 8 | 9 | export default () => { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/main/Label.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React from 'react' 3 | import { useSelector } from 'react-redux' 4 | import { Link } from 'react-router-dom' 5 | 6 | import { IRootState } from '@/features' 7 | import { postSelector } from '@/features/post/PostSlice' 8 | import { colors } from '@/styles/colors' 9 | 10 | import { LabelText, StyledLi } from './LabelIndex' 11 | 12 | const StyledLabel = styled(LabelText)` 13 | background-color: ${colors.white}; 14 | ` 15 | 16 | interface ILabelProps { 17 | id: string 18 | } 19 | 20 | export const Label = (props: ILabelProps) => { 21 | const { id } = props 22 | const label = useSelector((state: IRootState) => 23 | postSelector.postLabel(state, { id }), 24 | ) 25 | 26 | return ( 27 | 28 | 29 | {label.title} 30 | 31 | 32 | {label.author} 33 | 34 | 35 | {label.countOfComment} 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/main/LabelIndex.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React from 'react' 3 | 4 | import { colors } from '@/styles/colors' 5 | 6 | export const StyledLi = styled.li` 7 | display: flex; 8 | ` 9 | 10 | export const LabelText = styled.div` 11 | padding: 6px; 12 | flex: 1; 13 | text-align: center; 14 | background-color: ${colors.blue[1]}; 15 | ` 16 | 17 | export const LabelIndex = () => { 18 | return ( 19 | 20 | Title 21 | Author 22 | Comment 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/main/Main.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React, { useEffect } from 'react' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { IRootState } from '@/features' 6 | import { LOADING } from '@/features/common/loading/LoadingSlice' 7 | import { POST, postSelector, postThunks } from '@/features/post/PostSlice' 8 | 9 | import { ListWrapper } from '../shared/layout/ListWrapper' 10 | import { MainContainer } from '../shared/layout/MainContainer' 11 | import { Loading } from '../shared/loading/Loading' 12 | import { Label } from './Label' 13 | import { LabelIndex } from './LabelIndex' 14 | 15 | const StyledH1 = styled.h1` 16 | font-size: 48px; 17 | letter-spacing: -3px; 18 | font-weight: bolder; 19 | ` 20 | 21 | export default () => { 22 | const dispatch = useDispatch() 23 | const loading = useSelector((state: IRootState) => state[LOADING]) 24 | const postIds = useSelector(postSelector.postIds) 25 | 26 | useEffect(() => { 27 | dispatch(postThunks.fetchPosts()) 28 | }, [dispatch]) 29 | 30 | if (loading[POST]) { 31 | return 32 | } 33 | 34 | return ( 35 | 36 | Normalize Example 37 | 38 | 39 | {postIds.map(id => ( 40 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/post/Comment.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { Link } from 'react-router-dom' 4 | 5 | import { IRootState } from '@/features' 6 | import { commentSelector } from '@/features/comment/CommentSlice' 7 | 8 | interface ICommentProps { 9 | id: string 10 | } 11 | 12 | export const Comment = (props: ICommentProps) => { 13 | const { id } = props 14 | const comment = useSelector((state: IRootState) => 15 | commentSelector.comment(state, { id }), 16 | ) 17 | 18 | return ( 19 |
  • 20 |
    {comment.comment}
    21 | {comment.author} 22 |
  • 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/post/Post.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useParams } from 'react-router' 3 | 4 | import { NotFoundPage } from '../shared/error/NotFoundPage' 5 | import { PostContents } from './PostContents' 6 | 7 | export default () => { 8 | const { id } = useParams() 9 | 10 | if (!id) { 11 | return 12 | } 13 | 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /src/components/post/PostContents.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { Link } from 'react-router-dom' 4 | 5 | import { IRootState } from '@/features' 6 | import { postSelector } from '@/features/post/PostSlice' 7 | 8 | import { ListWrapper } from '../shared/layout/ListWrapper' 9 | import { MainContainer } from '../shared/layout/MainContainer' 10 | import { Comment } from './Comment' 11 | 12 | interface IPostContentsProps { 13 | id: string 14 | } 15 | 16 | export const PostContents = (props: IPostContentsProps) => { 17 | const { id } = props 18 | const post = useSelector((state: IRootState) => 19 | postSelector.post(state, { id }), 20 | ) 21 | 22 | return ( 23 | 24 |

    {post.title}

    25 | 26 | {post.author} 27 | 28 |
    {post.body}
    29 |
    30 | 31 | {post.comments.map(commentId => ( 32 | 33 | ))} 34 | 35 |
    36 |
    37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/shared/error/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const NotFoundPage = () => { 4 | return
    [404] Not Found Page!
    5 | } 6 | -------------------------------------------------------------------------------- /src/components/shared/error/PopupError.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React, { useCallback } from 'react' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | 5 | import { IRootState } from '@/features' 6 | import { 7 | ERROR, 8 | ERROR_CODE, 9 | errorActions, 10 | } from '@/features/common/error/ErrorSlice' 11 | import { colors } from '@/styles/colors' 12 | 13 | import { Dimmed } from '../layout/Dimmed' 14 | 15 | const PopupWrapper = styled.div` 16 | margin: 0 auto; 17 | padding: 24px; 18 | width: 100%; 19 | max-width: 320px; 20 | height: 5em; 21 | display: inline-block; 22 | border-radius: 16px; 23 | background-color: ${colors.white}; 24 | z-index: 100; 25 | position: relative; 26 | vertical-align: middle; 27 | ` 28 | 29 | const PopupTitle = styled.h3` 30 | font-size: 24px; 31 | font-weight: bold; 32 | ` 33 | 34 | const PopupMessage = styled.div` 35 | color: ${colors.gray[4]}; 36 | ` 37 | 38 | export const PopupError = () => { 39 | const dispatch = useDispatch() 40 | const { code } = useSelector((state: IRootState) => state[ERROR]) 41 | const close = useCallback(() => dispatch(errorActions.resolve()), [dispatch]) 42 | 43 | return ( 44 | 45 | 46 | Error! 47 | (Code: {code}) 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/shared/layout/Dimmed.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React, { ReactNode } from 'react' 3 | 4 | interface IDimmedProps { 5 | isShow: boolean 6 | onClick: () => void 7 | children: ReactNode 8 | } 9 | 10 | const StyledDiv = styled<'div', IDimmedProps>('div')` 11 | display: ${props => (props.isShow ? 'flex' : 'none')}; 12 | position: fixed; 13 | top: 0; 14 | right: 0; 15 | bottom: 0; 16 | left: 0; 17 | 18 | z-index: 99; 19 | width: 100%; 20 | align-items: center; 21 | 22 | &:before { 23 | position: fixed; 24 | top: 0; 25 | right: 0; 26 | bottom: 0; 27 | left: 0; 28 | background-color: rgba(0, 0, 0, 0.4); 29 | content: ''; 30 | } 31 | ` 32 | 33 | export const Dimmed = (props: IDimmedProps) => { 34 | return {props.children} 35 | } 36 | -------------------------------------------------------------------------------- /src/components/shared/layout/ListWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React, { ReactNode } from 'react' 3 | 4 | const StyledUl = styled.ul` 5 | margin: 12px auto; 6 | ` 7 | 8 | interface IListWrapperProps { 9 | children: ReactNode 10 | } 11 | 12 | export const ListWrapper = (props: IListWrapperProps) => { 13 | return {props.children} 14 | } 15 | -------------------------------------------------------------------------------- /src/components/shared/layout/MainContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import React, { ReactNode } from 'react' 3 | 4 | const StyledMain = styled.main` 5 | margin: auto; 6 | max-width: '480px'; 7 | padding: 12px; 8 | ` 9 | 10 | interface IMainContainerProps { 11 | children: ReactNode 12 | } 13 | 14 | export const MainContainer = (props: IMainContainerProps) => { 15 | return {props.children} 16 | } 17 | -------------------------------------------------------------------------------- /src/components/shared/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const Loading = () => { 4 | return
    Loading...
    5 | } 6 | -------------------------------------------------------------------------------- /src/components/user/User.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useParams } from 'react-router' 3 | 4 | import { NotFoundPage } from '../shared/error/NotFoundPage' 5 | import { UserProfile } from './UserProfile' 6 | 7 | export const User = () => { 8 | const { id } = useParams() 9 | 10 | if (!id) { 11 | return 12 | } 13 | 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /src/components/user/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | 4 | import { IRootState } from '@/features' 5 | import { userSelector } from '@/features/user/UserSlice' 6 | 7 | import { MainContainer } from '../shared/layout/MainContainer' 8 | 9 | interface IUserProfileProps { 10 | id: string 11 | } 12 | 13 | export const UserProfile = (props: IUserProfileProps) => { 14 | const { id } = props 15 | const userInfo = useSelector((state: IRootState) => 16 | userSelector.user(state, { id }), 17 | ) 18 | 19 | return ( 20 | 21 |

    User Profile

    22 |
    {userInfo.id}
    23 |
    {userInfo.name}
    24 |
    25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/features/comment/CommentModel.ts: -------------------------------------------------------------------------------- 1 | import { schema } from 'normalizr' 2 | 3 | import { IEntityTypeOf } from '@/typings' 4 | 5 | import { IUser, user } from '../user/UserModel' 6 | 7 | export interface IComment { 8 | id: number 9 | author: IUser 10 | comment: string 11 | } 12 | 13 | export const comment = new schema.Entity('comments', { 14 | author: user, 15 | }) 16 | 17 | export type ICommentEntity = IEntityTypeOf 18 | 19 | export const NullComment: ICommentEntity = { 20 | id: -1, 21 | author: '', 22 | comment: '', 23 | } 24 | -------------------------------------------------------------------------------- /src/features/comment/CommentSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { connectToRoot } from '@/utils/redux' 4 | 5 | import { ICommentEntity, NullComment } from './CommentModel' 6 | 7 | export interface ICommentState { 8 | comments?: { [key: string]: ICommentEntity } 9 | } 10 | 11 | const name = 'Comment' 12 | const initialState: ICommentState = { 13 | comments: {}, 14 | } 15 | 16 | const _ = createSlice({ 17 | name, 18 | initialState, 19 | reducers: { 20 | fetched(state: ICommentState, action: PayloadAction) { 21 | const { comments } = action.payload 22 | 23 | state.comments = comments 24 | }, 25 | }, 26 | }) 27 | 28 | const getComment = ( 29 | state: ICommentState, 30 | props: { id: string }, 31 | ): ICommentEntity => { 32 | if (!state.comments) { 33 | return NullComment 34 | } 35 | 36 | return state.comments[props.id] 37 | } 38 | 39 | export const COMMENT = _.name 40 | export const commentActions = _.actions 41 | export const commentReducer = _.reducer 42 | export const commentSelector = connectToRoot(name, { 43 | comment: getComment, 44 | }) 45 | -------------------------------------------------------------------------------- /src/features/common/error/ErrorSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | export enum ERROR_CODE { 4 | CLEAR = '', 5 | API_ERROR = 'API_ERROR', 6 | UNKNOWN_ERROR = 'UNKNOWN_ERROR', 7 | } 8 | 9 | export interface IErrorState { 10 | code: ERROR_CODE 11 | } 12 | 13 | const name = 'Error' 14 | const initialState: IErrorState = { 15 | code: ERROR_CODE.CLEAR, 16 | } 17 | const _ = createSlice({ 18 | name, 19 | initialState, 20 | reducers: { 21 | trigger(state: IErrorState, action: PayloadAction) { 22 | state.code = action.payload 23 | }, 24 | resolve(state: IErrorState) { 25 | state.code = ERROR_CODE.CLEAR 26 | }, 27 | }, 28 | }) 29 | 30 | export const ERROR = _.name 31 | export const errorActions = _.actions 32 | export const errorReducer = _.reducer 33 | -------------------------------------------------------------------------------- /src/features/common/loading/LoadingSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | export interface ILoadingState { 4 | [key: string]: boolean 5 | } 6 | 7 | const name = 'Loading' 8 | 9 | const _ = createSlice({ 10 | name, 11 | initialState: {}, 12 | reducers: { 13 | start(state: ILoadingState, action: PayloadAction) { 14 | state[action.payload] = true 15 | }, 16 | finish(state: ILoadingState, action: PayloadAction) { 17 | state[action.payload] = false 18 | }, 19 | }, 20 | }) 21 | 22 | export const LOADING = _.name 23 | export const loadingActions = _.actions 24 | export const loadingReducer = _.reducer 25 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import { Action, combineReducers } from '@reduxjs/toolkit' 2 | import { configureStore } from '@reduxjs/toolkit' 3 | import { ThunkAction } from 'redux-thunk' 4 | 5 | import { COMMENT, commentReducer } from './comment/CommentSlice' 6 | import { ERROR, errorReducer } from './common/error/ErrorSlice' 7 | import { LOADING, loadingReducer } from './common/loading/LoadingSlice' 8 | import { POST, postReducer } from './post/PostSlice' 9 | import { USER, userReducer } from './user/UserSlice' 10 | 11 | export const rootReducer = combineReducers({ 12 | [LOADING]: loadingReducer, 13 | [ERROR]: errorReducer, 14 | 15 | [POST]: postReducer, 16 | [COMMENT]: commentReducer, 17 | [USER]: userReducer, 18 | }) 19 | 20 | const store = configureStore({ reducer: rootReducer }) 21 | 22 | export type IRootState = ReturnType 23 | export type AppThunk = ThunkAction> 24 | 25 | export default store 26 | -------------------------------------------------------------------------------- /src/features/post/Post.test.ts: -------------------------------------------------------------------------------- 1 | import { IPostState, postActions, postReducer } from '@/features/post/PostSlice' 2 | 3 | test('add new post', () => { 4 | // Given 5 | const state: IPostState = { 6 | posts: { 7 | post1: { 8 | id: 'post1', 9 | title: 'First Post', 10 | author: 'user1', 11 | body: '...post contents 1..', 12 | comments: ['1', '2'], 13 | }, 14 | }, 15 | ids: ['post1', 'post2'], 16 | } 17 | const id = 'post99' 18 | const data = { 19 | id, 20 | title: 'New Post', 21 | author: 'Jbee', 22 | body: 'New post body contents', 23 | } 24 | // When 25 | const result = postReducer(state, postActions.added(data)) 26 | 27 | // Then 28 | const expected = { 29 | posts: { 30 | ...state.posts, 31 | [id]: { 32 | ...data, 33 | comments: [], 34 | }, 35 | }, 36 | ids: state.ids.concat(id), 37 | } 38 | expect(result).toEqual(expected) 39 | }) 40 | -------------------------------------------------------------------------------- /src/features/post/PostModel.ts: -------------------------------------------------------------------------------- 1 | import { normalize, NormalizedSchema, schema } from 'normalizr' 2 | 3 | import { IEntityTypeOf, IndexSignatureStringType } from '@/typings' 4 | 5 | import { comment, IComment, ICommentEntity } from '../comment/CommentModel' 6 | import { IUser, IUserEntity, user } from '../user/UserModel' 7 | 8 | export interface IPost { 9 | id: string 10 | title: string 11 | author: IUser 12 | body: string 13 | comments: IComment[] 14 | } 15 | 16 | export type IPostEntity = IEntityTypeOf 17 | 18 | export const post = new schema.Entity('posts', { 19 | author: user, 20 | comments: [comment], 21 | }) 22 | 23 | export type INormalizedPosts = NormalizedSchema< 24 | { 25 | posts: IndexSignatureStringType 26 | comments?: IndexSignatureStringType 27 | users?: IndexSignatureStringType 28 | }, 29 | string[] 30 | > 31 | 32 | export function normalizePost(data: IPost[]): INormalizedPosts { 33 | return normalize(data, [post]) 34 | } 35 | 36 | // export function addPostEntity(normalizedData: INormalizedPosts, newPost: IPost) { 37 | // const { id } = newPost 38 | // const { entities, result } = normalizedData 39 | 40 | // return { 41 | // entities: { 42 | // ...entities: { 43 | // post 44 | // } 45 | // } 46 | // } 47 | // } 48 | 49 | export interface IPostLabel { 50 | title: string 51 | author: string 52 | countOfComment: number 53 | } 54 | -------------------------------------------------------------------------------- /src/features/post/PostSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { batch } from 'react-redux' 3 | 4 | import { getPosts } from '@/api/post' 5 | import { IPostEntity, normalizePost } from '@/features/post/PostModel' 6 | import { connectToRoot } from '@/utils/redux' 7 | 8 | import { AppThunk } from '..' 9 | import { commentActions } from '../comment/CommentSlice' 10 | import { ERROR_CODE, errorActions } from '../common/error/ErrorSlice' 11 | import { loadingActions } from '../common/loading/LoadingSlice' 12 | import { userActions } from '../user/UserSlice' 13 | 14 | export interface IPostState { 15 | posts: { [key: string]: IPostEntity } 16 | ids: string[] 17 | } 18 | 19 | const name = 'Post' 20 | const initialState: IPostState = { 21 | posts: {}, 22 | ids: [], 23 | } 24 | 25 | const _ = createSlice({ 26 | name, 27 | initialState, 28 | reducers: { 29 | fetched(state: IPostState, action: PayloadAction) { 30 | const { posts, ids } = action.payload 31 | 32 | state.posts = posts 33 | state.ids = ids 34 | }, 35 | added( 36 | state: IPostState, 37 | action: PayloadAction>, 38 | ) { 39 | const newPost = { 40 | ...action.payload, // title, author, body 41 | comments: [], 42 | } 43 | const { id } = newPost 44 | 45 | state.ids.push(id) 46 | state.posts[id] = newPost 47 | }, 48 | }, 49 | }) 50 | 51 | const getPostIds = (state: IPostState) => state.ids 52 | const getPost = (state: IPostState, props: { id: string }): IPostEntity => { 53 | return state.posts[props.id] 54 | } 55 | const getPostLabel = createSelector(getPost, post => ({ 56 | title: post.title, 57 | author: post.author, 58 | countOfComment: post.comments.length, 59 | })) 60 | 61 | export function fetchPosts(): AppThunk { 62 | return async function(dispatch) { 63 | dispatch(loadingActions.start(name)) 64 | try { 65 | const response = await getPosts() 66 | const { entities, result } = normalizePost(response) 67 | 68 | batch(() => [ 69 | dispatch(postActions.fetched({ posts: entities.posts, ids: result })), 70 | dispatch(commentActions.fetched({ comments: entities.comments })), 71 | dispatch(userActions.fetched({ users: entities.users })), 72 | ]) 73 | } catch (e) { 74 | dispatch(errorActions.trigger(ERROR_CODE.API_ERROR)) 75 | } finally { 76 | dispatch(loadingActions.finish(name)) 77 | } 78 | } 79 | } 80 | 81 | export const POST = _.name 82 | export const postReducer = _.reducer 83 | export const postActions = _.actions 84 | export const postSelector = connectToRoot(name, { 85 | postIds: getPostIds, 86 | post: getPost, 87 | postLabel: getPostLabel, 88 | }) 89 | export const postThunks = { 90 | fetchPosts, 91 | } 92 | // export const usePost = () => { 93 | // return { 94 | // state: { 95 | // useSelector(), 96 | // }, 97 | // } 98 | // } 99 | -------------------------------------------------------------------------------- /src/features/user/UserModel.ts: -------------------------------------------------------------------------------- 1 | import { schema } from 'normalizr' 2 | 3 | import { IEntityTypeOf } from '@/typings' 4 | 5 | export interface IUser { 6 | id: string 7 | name: string 8 | } 9 | 10 | export const user = new schema.Entity('users', {}, { idAttribute: 'id' }) 11 | 12 | export type IUserEntity = IEntityTypeOf 13 | 14 | export const NullUser = { 15 | id: '-1', 16 | name: '', 17 | } 18 | -------------------------------------------------------------------------------- /src/features/user/UserSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | import { connectToRoot } from '@/utils/redux' 4 | 5 | import { IUserEntity, NullUser } from './UserModel' 6 | 7 | export interface IUserState { 8 | users?: { [key: string]: IUserEntity } 9 | } 10 | 11 | const name = 'User' 12 | const initialState: IUserState = { 13 | users: {}, 14 | } 15 | 16 | const _ = createSlice({ 17 | name, 18 | initialState, 19 | reducers: { 20 | fetched(state: IUserState, action: PayloadAction) { 21 | const { users } = action.payload 22 | 23 | state.users = users 24 | }, 25 | }, 26 | }) 27 | 28 | const getUser = (state: IUserState, props: { id: string }): IUserEntity => { 29 | if (!state.users) { 30 | return NullUser 31 | } 32 | 33 | return state.users[props.id] 34 | } 35 | 36 | export const USER = _.name 37 | export const userActions = _.actions 38 | export const userReducer = _.reducer 39 | export const userSelector = connectToRoot(name, { 40 | user: getUser, 41 | }) 42 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import { Router } from 'react-router-dom' 5 | 6 | import App from '@/components/App' 7 | import store from '@/features' 8 | import history from '@/utils/history' 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('root'), 17 | ) 18 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /src/stories/addons.ts: -------------------------------------------------------------------------------- 1 | import { rootReducer } from '@/features' 2 | 3 | import { withRedux } from '.storybook/decorators/addon-redux-toolkit' 4 | 5 | export const withState = withRedux(rootReducer) 6 | -------------------------------------------------------------------------------- /src/stories/main/Label.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Label } from '@/components/main/Label' 4 | import { LabelIndex } from '@/components/main/LabelIndex' 5 | import { POST } from '@/features/post/PostSlice' 6 | import { withState } from '@/stories/addons' 7 | 8 | export default { 9 | title: 'Main Page/Label', 10 | } 11 | 12 | export const Index = () => 13 | export const Item = () =>