├── src
├── react-env.d.ts
├── react-app-env.d.ts
├── misc
│ ├── useSocket.js
│ ├── gifts.json
│ ├── users.js
│ └── index.css
├── gifts.js
├── index.js
└── gifts.spec.js
├── .vscode
└── settings.json
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── README.md
├── .gitignore
├── tsconfig.json
├── package.json
└── server.js
/src/react-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweststrate/immer-gifts/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is the demo project of the egghead.io advanced immer course.
2 |
3 | To run
4 |
5 | ```
6 | yarn
7 | yarn start-server &
8 | yarn start
9 | ```
10 |
11 | Use `yarn test` to run tests.
12 |
13 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
14 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Immer Gifts",
3 | "name": "In-depth immer course demo",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "preserve"
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------
/src/misc/useSocket.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback, useRef } from "react"
2 |
3 | export function useSocket(url, onMessage) {
4 | const socket = useRef()
5 | const msgHandler = useRef()
6 | msgHandler.current = onMessage
7 |
8 | useEffect(() => {
9 | const createdSocket = new WebSocket(url)
10 | createdSocket.onmessage = event => {
11 | const data = JSON.parse(event.data)
12 | msgHandler.current(data)
13 | }
14 | socket.current = createdSocket
15 | console.log("created socket to " + url)
16 | return () => {
17 | console.log("socket disconnected")
18 | createdSocket.close()
19 | }
20 | }, [url])
21 |
22 | return useCallback(data => {
23 | socket.current.send(JSON.stringify(data))
24 | }, [])
25 | }
26 |
--------------------------------------------------------------------------------
/src/misc/gifts.json:
--------------------------------------------------------------------------------
1 | {
2 | "ade6f3cb-c36e-45c2-b4d7-d1e50d129801": {
3 | "id": "ade6f3cb-c36e-45c2-b4d7-d1e50d129801",
4 | "description": "Immer license",
5 | "image": "https://raw.githubusercontent.com/immerjs/immer/master/images/immer-logo.png",
6 | "reservedBy": 5
7 | },
8 | "fe54c511-737a-4e6f-bc50-aa221bcf43cd": {
9 | "id": "fe54c511-737a-4e6f-bc50-aa221bcf43cd",
10 | "description": "Egghead.io subscription",
11 | "image": "https://pbs.twimg.com/profile_images/735242324293210112/H8YfgQHP_400x400.jpg",
12 | "reservedBy": 21
13 | },
14 | "0acbb5ad-128e-4256-8f8d-cbc0a7737456": {
15 | "id": "0acbb5ad-128e-4256-8f8d-cbc0a7737456",
16 | "description": "The Complete Circle Series",
17 | "image": "https://images-na.ssl-images-amazon.com/images/I/41kCKME78kL.jpg"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/misc/users.js:
--------------------------------------------------------------------------------
1 | export const allUsers = [
2 | "🐶",
3 | "🐱",
4 | "🐭",
5 | "🐹",
6 | "🐰",
7 | "🦊",
8 | "🐻",
9 | "🐼",
10 | "🐨",
11 | "🐯",
12 | "🦁",
13 | "🐮",
14 | "🐷",
15 | "🐸",
16 | "🐒",
17 | "🦇",
18 | "🦉",
19 | "🦅",
20 | "🦆",
21 | "🐦",
22 | "🐧",
23 | "🐔",
24 | "🐺",
25 | "🐗",
26 | "🐴",
27 | "🦄",
28 | "🐝",
29 | "🐛",
30 | "🦋",
31 | "🐌",
32 | "🐜",
33 | "🐢"
34 | ].map((emoji, idx) => ({
35 | id: idx,
36 | name: emoji
37 | }))
38 |
39 | export function getCurrentUser() {
40 | if (typeof sessionStorage === "undefined") return { id: -1, name: "Test" } // not a browser no current user
41 | // picks a random user, and stores it on the session storage to preserve identity during hot reloads
42 | const currentUserId = sessionStorage.getItem("user") || Math.round(Math.random() * (allUsers.length - 1))
43 | sessionStorage.setItem("user", currentUserId)
44 | return allUsers[parseInt(currentUserId)]
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "immer-gifts",
3 | "type": "module",
4 | "version": "0.1.0",
5 | "private": true,
6 | "dependencies": {
7 | "immer": "4.0.0",
8 | "react": "^16.8.6",
9 | "react-dom": "^16.8.6",
10 | "react-scripts": "3.0.1",
11 | "use-immer": "^0.3.3",
12 | "uuid": "^3.3.3",
13 | "ws": "^7.1.0"
14 | },
15 | "scripts": {
16 | "start": "react-scripts start",
17 | "build": "react-scripts build",
18 | "test": "react-scripts test",
19 | "eject": "react-scripts eject",
20 | "start-server": "node -r esm server.js"
21 | },
22 | "eslintConfig": {
23 | "extends": "react-app"
24 | },
25 | "browserslist": {
26 | "production": [
27 | ">0.2%",
28 | "not dead",
29 | "not op_mini all"
30 | ],
31 | "development": [
32 | "last 1 chrome version",
33 | "last 1 firefox version",
34 | "last 1 safari version"
35 | ]
36 | },
37 | "devDependencies": {
38 | "@types/jest": "^24.0.15",
39 | "@types/react": "^16.9.2",
40 | "@types/react-dom": "^16.9.0",
41 | "deepfreeze": "^2.0.0",
42 | "esm": "^3.2.25",
43 | "node-fetch": "^2.6.0",
44 | "prettier": "^1.18.2",
45 | "typescript": "^3.5.3"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/misc/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font: 12pt "Century Gothic", Futura, sans-serif;
3 | margin: 20px;
4 | background: #3a2418;
5 | color: white;
6 | }
7 |
8 | h1 {
9 | color: orange;
10 | font-size: 40pt;
11 | }
12 |
13 | h2 {
14 | color: orange;
15 | }
16 |
17 | .app {
18 | margin: auto;
19 | max-width: 800px;
20 | }
21 |
22 | .actions {
23 | padding: 8px 0;
24 | margin-bottom: 20px;
25 | border-bottom: 1px dashed orangered;
26 | border-top: 1px dashed orangered;
27 | }
28 |
29 | .actions button {
30 | margin-right: 8px;
31 | }
32 |
33 | button {
34 | cursor: pointer;
35 | font-size: 14pt;
36 | padding: 4px 8px;
37 | }
38 |
39 | .gift {
40 | border-radius: 4px;
41 | display: flex;
42 | flex-direction: row;
43 | align-items: center;
44 | margin-bottom: 20px;
45 | }
46 |
47 | .gift img {
48 | height: 80px;
49 | width: 80px;
50 | object-fit: cover;
51 | border: 4px solid orange;
52 | border-radius: 80px;
53 | }
54 |
55 | .gift .description {
56 | text-align: center;
57 | flex-grow: 1;
58 | }
59 |
60 | .gift.reserved h2 {
61 | text-decoration: line-through;
62 | color: gray;
63 | }
64 |
65 | .gift .reservation {
66 | width: 120px;
67 | text-align: center;
68 | }
69 |
70 | .gift .reservation span {
71 | font-size: 30pt;
72 | }
73 |
74 | .gift .reservation span::before {
75 | content: "reserved by";
76 | font-size: 12pt;
77 | color: gray;
78 | }
79 |
80 | .gift .reservation button {
81 | margin-left: 8px;
82 | }
83 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import { Server as WebSocketServer } from "ws"
2 | import { applyPatches, produceWithPatches } from "immer"
3 |
4 | import gifts from "./src/misc/gifts.json"
5 |
6 | const wss = new WebSocketServer({ port: 5001 })
7 |
8 | /**
9 | * Connected clients
10 | */
11 | const connections = []
12 |
13 | /**
14 | * State as seen by server
15 | */
16 | let history = []
17 |
18 | wss.on("connection", function connection(ws) {
19 | /**
20 | * Assign player, save WS connection
21 | */
22 | connections.push(ws)
23 | console.log("New client connected")
24 |
25 | /**
26 | * Hanle new messages / closing
27 | */
28 | ws.on("message", function incoming(message) {
29 | console.log(message)
30 | history.push(...JSON.parse(message))
31 | connections
32 | .filter(client => client !== ws)
33 | .forEach(client => {
34 | client.send(message)
35 | })
36 | })
37 |
38 | /**
39 | * Remove connection upon close
40 | */
41 | ws.on("close", function() {
42 | const idx = connections.indexOf(ws)
43 | if (idx !== -1) connections.splice(idx, 1)
44 | })
45 |
46 | /**
47 | * Send initial state
48 | */
49 | ws.send(JSON.stringify(history))
50 | })
51 |
52 | const initialState = { gifts }
53 |
54 | export function compressHistory(currentPatches) {
55 | console.log("COMPRESSING HISTORY")
56 | const [_finalState, patches] = produceWithPatches(initialState, draft => {
57 | return applyPatches(draft, currentPatches)
58 | })
59 | console.log(`compressed patches from ${history.length} to ${patches.length} patches`)
60 | console.log(JSON.stringify(patches, null, 2))
61 | return patches
62 | }
63 |
64 | setInterval(() => {
65 | history = compressHistory(history)
66 | }, 5000)
67 |
--------------------------------------------------------------------------------
/src/gifts.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable default-case */
2 | import produce, { produceWithPatches, applyPatches } from "immer"
3 |
4 | import { allUsers, getCurrentUser } from "./misc/users"
5 | import defaultGifts from "./misc/gifts"
6 |
7 | export const giftsRecipe = (draft, action) => {
8 | switch (action.type) {
9 | case "ADD_GIFT":
10 | const { id, description, image } = action
11 | draft.gifts[id] = {
12 | id,
13 | description,
14 | image,
15 | reservedBy: undefined
16 | }
17 | break
18 | case "TOGGLE_RESERVATION":
19 | const gift = draft.gifts[action.id]
20 | gift.reservedBy =
21 | gift.reservedBy === undefined
22 | ? draft.currentUser.id
23 | : gift.reservedBy === draft.currentUser.id
24 | ? undefined
25 | : gift.reservedBy
26 | break
27 | case "ADD_BOOK":
28 | const { book } = action
29 | const isbn = book.identifiers.isbn_10[0]
30 | draft.gifts[isbn] = {
31 | id: isbn,
32 | description: book.title,
33 | image: book.cover.medium,
34 | reservedBy: undefined
35 | }
36 | break
37 | case "RESET":
38 | draft.gifts = getInitialState().gifts
39 | break
40 | case "APPLY_PATCHES":
41 | return applyPatches(draft, action.patches)
42 | }
43 | }
44 |
45 | export const giftsReducer = produce(giftsRecipe)
46 |
47 | export const patchGeneratingGiftsReducer = produceWithPatches(giftsRecipe)
48 |
49 | export async function getBookDetails(isbn) {
50 | const response = await fetch(`http://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&jscmd=data&format=json`, {
51 | mode: "cors"
52 | })
53 | const book = (await response.json())["ISBN:" + isbn]
54 | return book
55 | }
56 |
57 | export function getInitialState() {
58 | return {
59 | users: allUsers,
60 | currentUser: getCurrentUser(),
61 | gifts: defaultGifts
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import React, { useState, memo, useCallback, useRef } from "react"
3 | import ReactDOM from "react-dom"
4 | import uuidv4 from "uuid/v4"
5 |
6 | import { getInitialState, getBookDetails, patchGeneratingGiftsReducer } from "./gifts"
7 |
8 | import "./misc/index.css"
9 | import { useSocket } from "./misc/useSocket"
10 | import { giftsReducer } from "./gifts"
11 |
12 | const Gift = memo(({ gift, users, currentUser, onReserve }) => (
13 |
14 |

15 |
16 |
{gift.description}
17 |
18 |
19 | {!gift.reservedBy || gift.reservedBy === currentUser.id ? (
20 |
21 | ) : (
22 | {users[gift.reservedBy].name}
23 | )}
24 |
25 |
26 | ))
27 |
28 | function GiftList() {
29 | const [state, setState] = useState(() => getInitialState())
30 | const undoStack = useRef([])
31 | const undoStackPointer = useRef(-1)
32 |
33 | const { users, gifts, currentUser } = state
34 |
35 | const dispatch = useCallback((action, undoable = true) => {
36 | setState(currentState => {
37 | const [nextState, patches, inversePatches] = patchGeneratingGiftsReducer(currentState, action)
38 | send(patches) // always send patches
39 | if (undoable) {
40 | const pointer = ++undoStackPointer.current
41 | undoStack.current.length = pointer
42 | undoStack.current[pointer] = { patches, inversePatches }
43 | }
44 | return nextState
45 | })
46 | }, [])
47 |
48 | const send = useSocket("ws://localhost:5001", function onMessage(patches) {
49 | // we received some patches
50 | setState(state => giftsReducer(state, { type: "APPLY_PATCHES", patches }))
51 | })
52 |
53 | const handleUndo = () => {
54 | if (undoStackPointer.current < 0) return
55 | const patches = undoStack.current[undoStackPointer.current].inversePatches
56 | dispatch({ type: "APPLY_PATCHES", patches }, false)
57 | undoStackPointer.current--
58 | }
59 |
60 | const handleRedo = () => {
61 | if (undoStackPointer.current === undoStack.current.length - 1) return
62 | undoStackPointer.current++
63 | const patches = undoStack.current[undoStackPointer.current].patches
64 | dispatch({ type: "APPLY_PATCHES", patches }, false)
65 | }
66 |
67 | const handleAdd = () => {
68 | const description = prompt("Gift to add")
69 | if (description)
70 | dispatch({
71 | type: "ADD_GIFT",
72 | id: uuidv4(),
73 | description,
74 | image: `https://picsum.photos/id/${Math.round(Math.random() * 1000)}/200/200`
75 | })
76 | }
77 |
78 | const handleReserve = useCallback(id => {
79 | dispatch({
80 | type: "TOGGLE_RESERVATION",
81 | id
82 | })
83 | }, [])
84 |
85 | const handleAddBook = async () => {
86 | const isbn = prompt("Enter ISBN number", "0201558025")
87 | if (isbn) {
88 | const book = await getBookDetails(isbn)
89 | dispatch({
90 | type: "ADD_BOOK",
91 | book
92 | })
93 | }
94 | }
95 |
96 | const handleReset = () => {
97 | dispatch({ type: "RESET" })
98 | }
99 |
100 | return (
101 |
102 |
103 |
Hi, {currentUser.name}
104 |
105 |
106 |
107 |
108 |
109 |
112 |
115 |
116 |
117 | {Object.values(gifts).map(gift => (
118 |
119 | ))}
120 |
121 |
122 | )
123 | }
124 |
125 | ReactDOM.render(, document.getElementById("root"))
126 |
--------------------------------------------------------------------------------
/src/gifts.spec.js:
--------------------------------------------------------------------------------
1 | import { giftsReducer, getBookDetails, patchGeneratingGiftsReducer } from "./gifts"
2 | import { applyPatches } from "immer"
3 |
4 | const initialState = {
5 | users: [
6 | {
7 | id: 1,
8 | name: "Test user"
9 | },
10 | {
11 | id: 2,
12 | name: "Someone else"
13 | }
14 | ],
15 | currentUser: {
16 | id: 1,
17 | name: "Test user"
18 | },
19 | gifts: {
20 | immer_license: {
21 | id: "immer_license",
22 | description: "Immer license",
23 | image: "https://raw.githubusercontent.com/immerjs/immer/master/images/immer-logo.png",
24 | reservedBy: 2
25 | },
26 | egghead_subscription: {
27 | id: "egghead_subscription",
28 | description: "Egghead.io subscription",
29 | image: "https://pbs.twimg.com/profile_images/735242324293210112/H8YfgQHP_400x400.jpg",
30 | reservedBy: undefined
31 | }
32 | }
33 | }
34 |
35 | describe("Adding a gift", () => {
36 | const nextState = giftsReducer(initialState, {
37 | type: "ADD_GIFT",
38 | id: "mug",
39 | description: "Coffee mug",
40 | image: ""
41 | })
42 |
43 | test("added a gift to the collection", () => {
44 | expect(Object.keys(nextState.gifts).length).toBe(3)
45 | })
46 |
47 | test("didn't modify the original state", () => {
48 | expect(Object.keys(initialState.gifts).length).toBe(2)
49 | })
50 | })
51 |
52 | describe("Reserving an unreserved gift", () => {
53 | const nextState = giftsReducer(initialState, {
54 | type: "TOGGLE_RESERVATION",
55 | id: "egghead_subscription"
56 | })
57 |
58 | test("correctly stores reservedBy", () => {
59 | expect(nextState.gifts["egghead_subscription"].reservedBy).toBe(1) // Test user
60 | })
61 |
62 | test("didn't the original state", () => {
63 | expect(initialState.gifts["egghead_subscription"].reservedBy).toBe(undefined)
64 | })
65 |
66 | test("does structurally share unchanged state parts", () => {
67 | expect(nextState.gifts["immer_license"]).toBe(initialState.gifts["immer_license"])
68 | })
69 |
70 | test("can't accidentally modify the produced state", () => {
71 | expect(() => {
72 | nextState.gifts["egghead_subscription"].reservedBy = undefined
73 | }).toThrow("read only")
74 | })
75 | })
76 |
77 | describe("Reserving an unreserved gift with patches", () => {
78 | const [nextState, patches, inversePatches] = patchGeneratingGiftsReducer(initialState, {
79 | type: "TOGGLE_RESERVATION",
80 | id: "egghead_subscription"
81 | })
82 |
83 | test("correctly stores reservedBy", () => {
84 | expect(nextState.gifts["egghead_subscription"].reservedBy).toBe(1) // Test user
85 | })
86 |
87 | test("generates the correct patches", () => {
88 | expect(patches).toEqual([
89 | {
90 | op: "replace",
91 | path: ["gifts", "egghead_subscription", "reservedBy"],
92 | value: 1
93 | }
94 | ])
95 | })
96 |
97 | test("generates the correct inverse patches", () => {
98 | expect(inversePatches).toMatchInlineSnapshot(`
99 | Array [
100 | Object {
101 | "op": "replace",
102 | "path": Array [
103 | "gifts",
104 | "egghead_subscription",
105 | "reservedBy",
106 | ],
107 | "value": undefined,
108 | },
109 | ]
110 | `)
111 | })
112 |
113 | test("replaying patches produces the same state - 1", () => {
114 | expect(applyPatches(initialState, patches)).toEqual(nextState)
115 | })
116 |
117 | test("reversing patches goes back to the original", () => {
118 | expect(applyPatches(nextState, inversePatches)).toEqual(initialState)
119 | })
120 |
121 | test("replaying patches produces the same state - 2", () => {
122 | expect(
123 | giftsReducer(initialState, {
124 | type: "APPLY_PATCHES",
125 | patches
126 | })
127 | ).toEqual(nextState)
128 | })
129 | })
130 |
131 | describe("Reserving an already reserved gift", () => {
132 | const nextState = giftsReducer(initialState, {
133 | type: "TOGGLE_RESERVATION",
134 | id: "immer_license"
135 | })
136 |
137 | test("preserves stored reservedBy", () => {
138 | expect(nextState.gifts["immer_license"].reservedBy).toBe(2) // Someone else
139 | })
140 |
141 | test("still produces a new gift", () => {
142 | expect(nextState.gifts["immer_license"]).toEqual(initialState.gifts["immer_license"])
143 | expect(nextState.gifts["immer_license"]).toBe(initialState.gifts["immer_license"])
144 | })
145 |
146 | test("still produces a new state", () => {
147 | expect(nextState).toEqual(initialState)
148 | expect(nextState).toBe(initialState)
149 | })
150 | })
151 |
152 | describe("Can add books async", () => {
153 | test("Can add math book", async () => {
154 | const book = await getBookDetails("0201558025")
155 | const nextState = giftsReducer(initialState, { type: "ADD_BOOK", book })
156 | expect(nextState.gifts["0201558025"].description).toBe("Concrete mathematics")
157 | })
158 |
159 | test("Can add two books in parallel", async () => {
160 | const promise1 = getBookDetails("0201558025")
161 | const promise2 = getBookDetails("9781598560169")
162 | const nextState = giftsReducer(giftsReducer(initialState, { type: "ADD_BOOK", book: await promise1 }), {
163 | type: "ADD_BOOK",
164 | book: await promise2
165 | })
166 | expect(Object.keys(nextState.gifts).length).toBe(4)
167 | })
168 | })
169 |
--------------------------------------------------------------------------------