├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── project.clj ├── resources └── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── test.js ├── src └── conduit │ ├── core.cljs │ ├── db.cljs │ ├── events.cljs │ ├── subs.cljs │ └── views.cljs └── test ├── core_test.cljs └── runner.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /target 3 | /*-init.clj 4 | /resources/public/js 5 | /resources/public/test 6 | .nrepl-port 7 | .lein-failures 8 | out 9 | .DS_Store 10 | node_modules 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](https://cloud.githubusercontent.com/assets/556934/25448267/85369fdc-2a7d-11e7-9613-ab5ce5e1800f.png) 2 | 3 | ## :warning: Depreciation warning :warning: 4 | 5 | Due to historical reasons this repo has been created after I did the implementation on my repo. Please refer to the following repo for up-to-date version: 6 | 7 | 8 | ### [clojurescript re-frame](https://github.com/jacekschae/conduit) 9 | 10 | 11 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "@cljs-oss/module-deps": { 6 | "version": "1.1.1", 7 | "resolved": "https://registry.npmjs.org/@cljs-oss/module-deps/-/module-deps-1.1.1.tgz", 8 | "integrity": "sha1-YmZ/KCFk8/EParnxJLpBb9EkOfo=", 9 | "requires": { 10 | "JSONStream": "1.3.2", 11 | "babel-traverse": "6.26.0", 12 | "babylon": "6.18.0", 13 | "browser-resolve": "1.11.2", 14 | "cached-path-relative": "1.0.1", 15 | "concat-stream": "1.5.2", 16 | "defined": "1.0.0", 17 | "detective": "4.7.0", 18 | "duplexer2": "0.1.4", 19 | "enhanced-resolve": "3.4.1", 20 | "inherits": "2.0.3", 21 | "konan": "1.1.0", 22 | "parents": "1.0.1", 23 | "readable-stream": "2.3.3", 24 | "resolve": "1.5.0", 25 | "stream-combiner2": "1.1.1", 26 | "subarg": "1.0.0", 27 | "through2": "2.0.3", 28 | "xtend": "4.0.1" 29 | } 30 | }, 31 | "JSONStream": { 32 | "version": "1.3.2", 33 | "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.2.tgz", 34 | "integrity": "sha1-wQI3G27Dp887hHygDCC7D85Mbeo=", 35 | "requires": { 36 | "jsonparse": "1.3.1", 37 | "through": "2.3.8" 38 | } 39 | }, 40 | "acorn": { 41 | "version": "5.2.1", 42 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.2.1.tgz", 43 | "integrity": "sha512-jG0u7c4Ly+3QkkW18V+NRDN+4bWHdln30NL1ZL2AvFZZmQe/BfopYCtghCKKVBUSetZ4QKcyA0pY6/4Gw8Pv8w==" 44 | }, 45 | "ansi-regex": { 46 | "version": "2.1.1", 47 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 48 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 49 | }, 50 | "ansi-styles": { 51 | "version": "2.2.1", 52 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", 53 | "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" 54 | }, 55 | "asap": { 56 | "version": "2.0.6", 57 | "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", 58 | "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" 59 | }, 60 | "babel-code-frame": { 61 | "version": "6.26.0", 62 | "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", 63 | "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", 64 | "requires": { 65 | "chalk": "1.1.3", 66 | "esutils": "2.0.2", 67 | "js-tokens": "3.0.2" 68 | } 69 | }, 70 | "babel-messages": { 71 | "version": "6.23.0", 72 | "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", 73 | "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", 74 | "requires": { 75 | "babel-runtime": "6.26.0" 76 | } 77 | }, 78 | "babel-runtime": { 79 | "version": "6.26.0", 80 | "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", 81 | "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", 82 | "requires": { 83 | "core-js": "2.5.3", 84 | "regenerator-runtime": "0.11.1" 85 | } 86 | }, 87 | "babel-traverse": { 88 | "version": "6.26.0", 89 | "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", 90 | "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", 91 | "requires": { 92 | "babel-code-frame": "6.26.0", 93 | "babel-messages": "6.23.0", 94 | "babel-runtime": "6.26.0", 95 | "babel-types": "6.26.0", 96 | "babylon": "6.18.0", 97 | "debug": "2.6.9", 98 | "globals": "9.18.0", 99 | "invariant": "2.2.2", 100 | "lodash": "4.17.4" 101 | } 102 | }, 103 | "babel-types": { 104 | "version": "6.26.0", 105 | "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", 106 | "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", 107 | "requires": { 108 | "babel-runtime": "6.26.0", 109 | "esutils": "2.0.2", 110 | "lodash": "4.17.4", 111 | "to-fast-properties": "1.0.3" 112 | } 113 | }, 114 | "babylon": { 115 | "version": "6.18.0", 116 | "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", 117 | "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" 118 | }, 119 | "browser-resolve": { 120 | "version": "1.11.2", 121 | "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.2.tgz", 122 | "integrity": "sha1-j/CbCixCFxihBRwmCzLkj0QpOM4=", 123 | "requires": { 124 | "resolve": "1.1.7" 125 | }, 126 | "dependencies": { 127 | "resolve": { 128 | "version": "1.1.7", 129 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", 130 | "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=" 131 | } 132 | } 133 | }, 134 | "cached-path-relative": { 135 | "version": "1.0.1", 136 | "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.1.tgz", 137 | "integrity": "sha1-0JxLUoAKpMB44t2BqGmqyQ0uVOc=" 138 | }, 139 | "chalk": { 140 | "version": "1.1.3", 141 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", 142 | "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", 143 | "requires": { 144 | "ansi-styles": "2.2.1", 145 | "escape-string-regexp": "1.0.5", 146 | "has-ansi": "2.0.0", 147 | "strip-ansi": "3.0.1", 148 | "supports-color": "2.0.0" 149 | } 150 | }, 151 | "concat-stream": { 152 | "version": "1.5.2", 153 | "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.2.tgz", 154 | "integrity": "sha1-cIl4Yk2FavQaWnQd790mHadSwmY=", 155 | "requires": { 156 | "inherits": "2.0.3", 157 | "readable-stream": "2.0.6", 158 | "typedarray": "0.0.6" 159 | }, 160 | "dependencies": { 161 | "readable-stream": { 162 | "version": "2.0.6", 163 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", 164 | "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", 165 | "requires": { 166 | "core-util-is": "1.0.2", 167 | "inherits": "2.0.3", 168 | "isarray": "1.0.0", 169 | "process-nextick-args": "1.0.7", 170 | "string_decoder": "0.10.31", 171 | "util-deprecate": "1.0.2" 172 | } 173 | } 174 | } 175 | }, 176 | "core-js": { 177 | "version": "2.5.3", 178 | "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.3.tgz", 179 | "integrity": "sha1-isw4NFgk8W2DZbfJtCWRaOjtYD4=" 180 | }, 181 | "core-util-is": { 182 | "version": "1.0.2", 183 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", 184 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" 185 | }, 186 | "create-react-class": { 187 | "version": "15.6.2", 188 | "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.2.tgz", 189 | "integrity": "sha1-zx7RXxKq1/FO9fLf4F5sQvke8Co=", 190 | "requires": { 191 | "fbjs": "0.8.16", 192 | "loose-envify": "1.3.1", 193 | "object-assign": "4.1.1" 194 | } 195 | }, 196 | "debug": { 197 | "version": "2.6.9", 198 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 199 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 200 | "requires": { 201 | "ms": "2.0.0" 202 | } 203 | }, 204 | "defined": { 205 | "version": "1.0.0", 206 | "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", 207 | "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" 208 | }, 209 | "detective": { 210 | "version": "4.7.0", 211 | "resolved": "https://registry.npmjs.org/detective/-/detective-4.7.0.tgz", 212 | "integrity": "sha512-4mBqSEdMfBpRAo/DQZnTcAXenpiSIJmVKbCMSotS+SFWWcrP/CKM6iBRPdTiEO+wZhlfEsoZlGqpG6ycl5vTqw==", 213 | "requires": { 214 | "acorn": "5.2.1", 215 | "defined": "1.0.0" 216 | } 217 | }, 218 | "duplexer2": { 219 | "version": "0.1.4", 220 | "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", 221 | "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", 222 | "requires": { 223 | "readable-stream": "2.3.3" 224 | } 225 | }, 226 | "encoding": { 227 | "version": "0.1.12", 228 | "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", 229 | "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", 230 | "requires": { 231 | "iconv-lite": "0.4.19" 232 | } 233 | }, 234 | "enhanced-resolve": { 235 | "version": "3.4.1", 236 | "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz", 237 | "integrity": "sha1-BCHjOf1xQZs9oT0Smzl5BAIwR24=", 238 | "requires": { 239 | "graceful-fs": "4.1.11", 240 | "memory-fs": "0.4.1", 241 | "object-assign": "4.1.1", 242 | "tapable": "0.2.8" 243 | } 244 | }, 245 | "errno": { 246 | "version": "0.1.6", 247 | "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.6.tgz", 248 | "integrity": "sha512-IsORQDpaaSwcDP4ZZnHxgE85werpo34VYn1Ud3mq+eUsF593faR8oCZNXrROVkpFu2TsbrNhHin0aUrTsQ9vNw==", 249 | "requires": { 250 | "prr": "1.0.1" 251 | } 252 | }, 253 | "escape-string-regexp": { 254 | "version": "1.0.5", 255 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 256 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" 257 | }, 258 | "esutils": { 259 | "version": "2.0.2", 260 | "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", 261 | "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" 262 | }, 263 | "fbjs": { 264 | "version": "0.8.16", 265 | "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz", 266 | "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=", 267 | "requires": { 268 | "core-js": "1.2.7", 269 | "isomorphic-fetch": "2.2.1", 270 | "loose-envify": "1.3.1", 271 | "object-assign": "4.1.1", 272 | "promise": "7.3.1", 273 | "setimmediate": "1.0.5", 274 | "ua-parser-js": "0.7.17" 275 | }, 276 | "dependencies": { 277 | "core-js": { 278 | "version": "1.2.7", 279 | "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", 280 | "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" 281 | } 282 | } 283 | }, 284 | "globals": { 285 | "version": "9.18.0", 286 | "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", 287 | "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==" 288 | }, 289 | "graceful-fs": { 290 | "version": "4.1.11", 291 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", 292 | "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=" 293 | }, 294 | "has-ansi": { 295 | "version": "2.0.0", 296 | "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", 297 | "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", 298 | "requires": { 299 | "ansi-regex": "2.1.1" 300 | } 301 | }, 302 | "iconv-lite": { 303 | "version": "0.4.19", 304 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", 305 | "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" 306 | }, 307 | "inherits": { 308 | "version": "2.0.3", 309 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 310 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 311 | }, 312 | "invariant": { 313 | "version": "2.2.2", 314 | "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", 315 | "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=", 316 | "requires": { 317 | "loose-envify": "1.3.1" 318 | } 319 | }, 320 | "is-stream": { 321 | "version": "1.1.0", 322 | "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", 323 | "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" 324 | }, 325 | "isarray": { 326 | "version": "1.0.0", 327 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 328 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" 329 | }, 330 | "isomorphic-fetch": { 331 | "version": "2.2.1", 332 | "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", 333 | "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", 334 | "requires": { 335 | "node-fetch": "1.7.3", 336 | "whatwg-fetch": "2.0.3" 337 | } 338 | }, 339 | "js-tokens": { 340 | "version": "3.0.2", 341 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", 342 | "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" 343 | }, 344 | "jsonparse": { 345 | "version": "1.3.1", 346 | "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", 347 | "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" 348 | }, 349 | "konan": { 350 | "version": "1.1.0", 351 | "resolved": "https://registry.npmjs.org/konan/-/konan-1.1.0.tgz", 352 | "integrity": "sha1-M3dDxLl7S9Hvi2KiSzFeuLxLIJQ=", 353 | "requires": { 354 | "babel-traverse": "6.26.0", 355 | "babylon": "6.18.0" 356 | } 357 | }, 358 | "lodash": { 359 | "version": "4.17.4", 360 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", 361 | "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" 362 | }, 363 | "loose-envify": { 364 | "version": "1.3.1", 365 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", 366 | "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", 367 | "requires": { 368 | "js-tokens": "3.0.2" 369 | } 370 | }, 371 | "memory-fs": { 372 | "version": "0.4.1", 373 | "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", 374 | "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", 375 | "requires": { 376 | "errno": "0.1.6", 377 | "readable-stream": "2.3.3" 378 | } 379 | }, 380 | "minimist": { 381 | "version": "1.2.0", 382 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 383 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 384 | }, 385 | "ms": { 386 | "version": "2.0.0", 387 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 388 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 389 | }, 390 | "node-fetch": { 391 | "version": "1.7.3", 392 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", 393 | "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", 394 | "requires": { 395 | "encoding": "0.1.12", 396 | "is-stream": "1.1.0" 397 | } 398 | }, 399 | "object-assign": { 400 | "version": "4.1.1", 401 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 402 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 403 | }, 404 | "parents": { 405 | "version": "1.0.1", 406 | "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", 407 | "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", 408 | "requires": { 409 | "path-platform": "0.11.15" 410 | } 411 | }, 412 | "path-parse": { 413 | "version": "1.0.5", 414 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", 415 | "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" 416 | }, 417 | "path-platform": { 418 | "version": "0.11.15", 419 | "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", 420 | "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=" 421 | }, 422 | "process-nextick-args": { 423 | "version": "1.0.7", 424 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", 425 | "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" 426 | }, 427 | "promise": { 428 | "version": "7.3.1", 429 | "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", 430 | "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", 431 | "requires": { 432 | "asap": "2.0.6" 433 | } 434 | }, 435 | "prop-types": { 436 | "version": "15.6.0", 437 | "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz", 438 | "integrity": "sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=", 439 | "requires": { 440 | "fbjs": "0.8.16", 441 | "loose-envify": "1.3.1", 442 | "object-assign": "4.1.1" 443 | } 444 | }, 445 | "prr": { 446 | "version": "1.0.1", 447 | "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", 448 | "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" 449 | }, 450 | "react": { 451 | "version": "15.6.2", 452 | "resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz", 453 | "integrity": "sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=", 454 | "requires": { 455 | "create-react-class": "15.6.2", 456 | "fbjs": "0.8.16", 457 | "loose-envify": "1.3.1", 458 | "object-assign": "4.1.1", 459 | "prop-types": "15.6.0" 460 | } 461 | }, 462 | "react-dom": { 463 | "version": "15.6.2", 464 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz", 465 | "integrity": "sha1-Qc+t9pO3V/rycIRDodH9WgK+9zA=", 466 | "requires": { 467 | "fbjs": "0.8.16", 468 | "loose-envify": "1.3.1", 469 | "object-assign": "4.1.1", 470 | "prop-types": "15.6.0" 471 | } 472 | }, 473 | "readable-stream": { 474 | "version": "2.3.3", 475 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", 476 | "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", 477 | "requires": { 478 | "core-util-is": "1.0.2", 479 | "inherits": "2.0.3", 480 | "isarray": "1.0.0", 481 | "process-nextick-args": "1.0.7", 482 | "safe-buffer": "5.1.1", 483 | "string_decoder": "1.0.3", 484 | "util-deprecate": "1.0.2" 485 | }, 486 | "dependencies": { 487 | "string_decoder": { 488 | "version": "1.0.3", 489 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", 490 | "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", 491 | "requires": { 492 | "safe-buffer": "5.1.1" 493 | } 494 | } 495 | } 496 | }, 497 | "regenerator-runtime": { 498 | "version": "0.11.1", 499 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", 500 | "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" 501 | }, 502 | "resolve": { 503 | "version": "1.5.0", 504 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz", 505 | "integrity": "sha512-hgoSGrc3pjzAPHNBg+KnFcK2HwlHTs/YrAGUr6qgTVUZmXv1UEXXl0bZNBKMA9fud6lRYFdPGz0xXxycPzmmiw==", 506 | "requires": { 507 | "path-parse": "1.0.5" 508 | } 509 | }, 510 | "safe-buffer": { 511 | "version": "5.1.1", 512 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", 513 | "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" 514 | }, 515 | "setimmediate": { 516 | "version": "1.0.5", 517 | "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", 518 | "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" 519 | }, 520 | "stream-combiner2": { 521 | "version": "1.1.1", 522 | "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", 523 | "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", 524 | "requires": { 525 | "duplexer2": "0.1.4", 526 | "readable-stream": "2.3.3" 527 | } 528 | }, 529 | "string_decoder": { 530 | "version": "0.10.31", 531 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", 532 | "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" 533 | }, 534 | "strip-ansi": { 535 | "version": "3.0.1", 536 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 537 | "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", 538 | "requires": { 539 | "ansi-regex": "2.1.1" 540 | } 541 | }, 542 | "subarg": { 543 | "version": "1.0.0", 544 | "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", 545 | "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", 546 | "requires": { 547 | "minimist": "1.2.0" 548 | } 549 | }, 550 | "supports-color": { 551 | "version": "2.0.0", 552 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", 553 | "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" 554 | }, 555 | "tapable": { 556 | "version": "0.2.8", 557 | "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz", 558 | "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI=" 559 | }, 560 | "through": { 561 | "version": "2.3.8", 562 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 563 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 564 | }, 565 | "through2": { 566 | "version": "2.0.3", 567 | "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", 568 | "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", 569 | "requires": { 570 | "readable-stream": "2.3.3", 571 | "xtend": "4.0.1" 572 | } 573 | }, 574 | "to-fast-properties": { 575 | "version": "1.0.3", 576 | "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", 577 | "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=" 578 | }, 579 | "typedarray": { 580 | "version": "0.0.6", 581 | "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", 582 | "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" 583 | }, 584 | "ua-parser-js": { 585 | "version": "0.7.17", 586 | "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz", 587 | "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g==" 588 | }, 589 | "util-deprecate": { 590 | "version": "1.0.2", 591 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 592 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 593 | }, 594 | "whatwg-fetch": { 595 | "version": "2.0.3", 596 | "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", 597 | "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" 598 | }, 599 | "xtend": { 600 | "version": "4.0.1", 601 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", 602 | "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" 603 | } 604 | } 605 | } 606 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@cljs-oss/module-deps": "^1.1.1", 4 | "create-react-class": "^15.6.2", 5 | "react": "^15.6.2", 6 | "react-dom": "^15.6.2" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject conduit "0.1.0-SNAPSHOT" 2 | :dependencies [[org.clojure/clojure "1.9.0"] 3 | [org.clojure/clojurescript "1.9.946"] 4 | [reagent "0.8.0-alpha2"] 5 | [re-frame "0.10.2"] 6 | [cljs-ajax "0.7.3"] 7 | [day8.re-frame/http-fx "0.1.4"] 8 | [secretary "1.2.3"] 9 | [com.andrewmcveigh/cljs-time "0.5.2"] 10 | [binaryage/devtools "0.9.8"] 11 | [proto-repl "0.3.1"] 12 | [re-frisk "0.5.3"]] 13 | 14 | :jvm-opts ["--add-modules" "java.xml.bind"] 15 | 16 | :plugins [[lein-cljsbuild "1.1.7"] 17 | [lein-doo "0.1.8"] 18 | [lein-figwheel "0.5.14"]] 19 | 20 | :hooks [leiningen.cljsbuild] 21 | 22 | :aliases {"dev" ["do" "clean" ["figwheel"]] 23 | "prod" ["do" "clean" ["with-profile" "prod" "compile"]]} 24 | 25 | :profiles {:dev {:cljsbuild 26 | {:builds {:client {:compiler {:asset-path "js" 27 | :optimizations :none 28 | :source-map true 29 | :source-map-timestamp true 30 | :preloads [re-frisk.preload devtools.preload] 31 | :main "conduit.core"} 32 | :figwheel {:on-jsload "conduit.core/main"}}}}} 33 | 34 | :prod {:cljsbuild 35 | {:builds {:client {:compiler {:optimizations :advanced 36 | :elide-asserts true 37 | :pretty-print false}}}}} 38 | :test {:cljsbuild 39 | {:builds {:client {:source-paths ["src/conduit" "test"] 40 | :compiler {:optimizations :none 41 | :main test.runner 42 | :output-to "resources/public/test" 43 | :output-dir "resources/public/test/out"}}}}}} 44 | :figwheel {:server-port 3449 45 | :repl true} 46 | 47 | :clean-targets ^{:protect false} ["resources/public/js" "target"] 48 | 49 | :cljsbuild {:builds {:client {:source-paths ["src/conduit"] 50 | :compiler {:output-dir "resources/public/js" 51 | :output-to "resources/public/js/client.js" 52 | :closure-warnings {:global-this :off} 53 | :process-shim true 54 | :install-deps true 55 | :npm-deps {"react" "15.6.2" 56 | "react-dom" "15.6.2" 57 | "create-react-class" "15.6.2"}}}}}) 58 | -------------------------------------------------------------------------------- /resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/clojurescript-reframe-realworld-example-app/5ef88a86b78be5a01d44ecf755a8a150725712c3/resources/public/favicon.ico -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | Conduit 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 |
25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /resources/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Conduit", 3 | "name": "Conduit with ClojureScript and re-frame", 4 | "start_url": "/index.html", 5 | "icons": [ 6 | { 7 | "src": "favicon.ico", 8 | "sizes": "64x64 32x32 24x24 16x16", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "/img/icon@1.png", 13 | "sizes": "128x128", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/img/icon@1.5x.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/img/icon@2x.png", 23 | "sizes": "256x256", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/img/icon@3x.png", 28 | "sizes": "384x384", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/img/icon@4x.png", 33 | "sizes": "512x512", 34 | "type": "image/png" 35 | } 36 | ], 37 | "display": "standalone", 38 | "theme_color": "#000000", 39 | "background_color": "#ffffff" 40 | } 41 | -------------------------------------------------------------------------------- /resources/public/test.js: -------------------------------------------------------------------------------- 1 | var CLOSURE_UNCOMPILED_DEFINES = {}; 2 | var CLOSURE_NO_DEPS = true; 3 | if(typeof goog == "undefined") document.write(''); 4 | document.write(''); 5 | document.write(''); 6 | document.write(''); 7 | document.write(''); 8 | document.write(''); 9 | document.write(''); 10 | -------------------------------------------------------------------------------- /src/conduit/core.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.core 2 | (:require-macros [secretary.core :refer [defroute]]) 3 | (:require [goog.events :as events] 4 | [reagent.core :as reagent] 5 | [re-frame.core :refer [dispatch dispatch-sync]] 6 | [secretary.core :as secretary] 7 | [conduit.events] ;; These three are only 8 | [conduit.subs] ;; required to make the compiler 9 | [conduit.views] ;; load them 10 | ) 11 | (:import [goog History] 12 | [goog.history EventType])) 13 | 14 | ;; -- Debugging aids ---------------------------------------------------------- 15 | ;; 16 | (enable-console-print!) ;; so that println writes to `console.log` 17 | 18 | ;; -- Service Worker ---------------------------------------------------------- 19 | ;; 20 | (defn is-service-worker-supported? 21 | [] 22 | (and 23 | (exists? js/navigator.serviceWorker) 24 | (= js/location.protocol "https:"))) 25 | 26 | (defn register-service-worker 27 | [path-to-sw] 28 | (when (is-service-worker-supported?) 29 | (-> js/navigator 30 | .-serviceWorker 31 | (.register path-to-sw)))) 32 | 33 | ;; -- Routes and History ------------------------------------------------------ 34 | ;; 35 | (defn routes 36 | [] 37 | (set! (.-hash js/location) "/") ;; on app startup set location to "/" 38 | (secretary/set-config! :prefix "#") ;; and don't forget about "#" prefix 39 | (defroute "/" [] (dispatch [:set-active-page {:page :home}])) 40 | (defroute "/login" [] (dispatch [:set-active-page {:page :login}])) 41 | (defroute "/register" [] (dispatch [:set-active-page {:page :register}])) 42 | (defroute "/settings" [] (dispatch [:set-active-page {:page :settings}])) 43 | (defroute "/editor" [] (dispatch [:set-active-page {:page :editor}])) 44 | (defroute "/editor/:slug" [slug] (dispatch [:set-active-page {:page :editor :slug slug}])) 45 | (defroute "/logout" [] (dispatch [:logout])) 46 | (defroute "/article/:slug" [slug] (dispatch [:set-active-page {:page :article :slug slug}])) 47 | (defroute "/:profile/favorites" [profile] (dispatch [:set-active-page {:page :favorited :favorited (subs profile 1)}])) 48 | (defroute "/:profile" [profile] (dispatch [:set-active-page {:page :profile :profile (subs profile 1)}]))) 49 | 50 | (def history 51 | (doto (History.) 52 | (events/listen EventType.NAVIGATE 53 | (fn [event] (secretary/dispatch! (.-token event)))) 54 | (.setEnabled true))) 55 | 56 | ;; -- Entry Point ------------------------------------------------------------- 57 | ;; Within ../../resources/public/index.html you'll see this code: 58 | ;; window.onload = function() { conduit.core.main() } 59 | ;; So this is the entry function that kicks off the app once the HTML is loaded. 60 | ;; 61 | (defn ^:export main 62 | [] 63 | ;; Put an initial value into app-db. 64 | ;; The event handler for `:initialise-db` can be found in `events.cljs` 65 | ;; Using the sync version of dispatch means that value is in 66 | ;; place before we go onto the next step. 67 | (dispatch-sync [:initialise-db]) 68 | 69 | ;; Hookup the router and history that we configured above. 70 | (routes) 71 | 72 | ;; Render the UI into the HTML's
element 73 | ;; The view function `conduit.views/conduit-app` is the 74 | ;; root view for the entire UI. 75 | (reagent/render [conduit.views/conduit-app] 76 | (.getElementById js/document "app")) 77 | 78 | ;; Register Service Worker defined at the top 79 | (register-service-worker "js/service-worker.js")) 80 | -------------------------------------------------------------------------------- /src/conduit/db.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.db 2 | (:require [cljs.reader] 3 | [re-frame.core :refer [reg-cofx]])) 4 | 5 | ;; -- Default app-db Value --------------------------------------------------- 6 | ;; 7 | ;; When the application first starts, this will be the value put in app-db 8 | ;; Look in: 9 | ;; 1. `core.cljs` for "(dispatch-sync [:initialise-db])" 10 | ;; 2. `events.cljs` for the registration of :initialise-db handler 11 | ;; 12 | (def default-db {:active-page :home}) ;; what gets put into app-db by default. 13 | 14 | ;; -- Local Storage ---------------------------------------------------------- 15 | ;; 16 | ;; Part of the conduit challenge is to store a user in localStorage, and 17 | ;; on app startup, reload the user from when the program was last run. 18 | ;; 19 | (def conduit-user-key "conduit-user") ;; localstore key 20 | 21 | (defn set-user-ls 22 | "Puts user into localStorage" 23 | [user] 24 | (.setItem js/localStorage conduit-user-key (str user))) ;; sorted-map written as an EDN map 25 | 26 | ;; Removes user information from localStorge when a user logs out. 27 | ;; 28 | (defn remove-user-ls 29 | "Removes user from localStorage" 30 | [] 31 | (.removeItem js/localStorage conduit-user-key)) 32 | 33 | ;; -- cofx Registrations ----------------------------------------------------- 34 | ;; 35 | ;; Use `reg-cofx` to register a "coeffect handler" which will inject the user 36 | ;; stored in localStorge. 37 | ;; 38 | ;; To see it used, look in `events.cljs` at the event handler for `:initialise-db`. 39 | ;; That event handler has the interceptor `(inject-cofx :local-store-user)` 40 | ;; The function registered below will be used to fulfill that request. 41 | ;; 42 | ;; We must supply a `sorted-map` but in localStorage it is stored as a `map`. 43 | ;; 44 | (reg-cofx 45 | :local-store-user 46 | (fn [cofx _] 47 | (assoc cofx :local-store-user ;; put the local-store user into the coeffect under :local-store-user 48 | (into (sorted-map) ;; read in user from localstore, and process into a sorted map 49 | (some->> (.getItem js/localStorage conduit-user-key) 50 | (cljs.reader/read-string)))))) ;; EDN map -> map 51 | -------------------------------------------------------------------------------- /src/conduit/events.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.events 2 | (:require 3 | [conduit.db :refer [default-db set-user-ls remove-user-ls]] 4 | [re-frame.core :refer [reg-event-db reg-event-fx reg-fx inject-cofx trim-v after path debug]] 5 | [day8.re-frame.http-fx] ;; even if we don't use this require its existence will cause the :http-xhrio effect handler to self-register with re-frame 6 | [ajax.core :refer [json-request-format json-response-format]] 7 | [clojure.string :as str] 8 | [cljs-time.coerce :refer [to-long]])) 9 | 10 | ;; -- Interceptors -------------------------------------------------------------- 11 | ;; Every event handler can be "wrapped" in a chain of interceptors. Each of these 12 | ;; interceptors can do things "before" and/or "after" the event handler is executed. 13 | ;; They are like the "middleware" of web servers, wrapping around the "handler". 14 | ;; Interceptors are a useful way of factoring out commonality (across event 15 | ;; handlers) and looking after cross-cutting concerns like logging or validation. 16 | ;; 17 | ;; They are also used to "inject" values into the `coeffects` parameter of 18 | ;; an event handler, when that handler needs access to certain resources. 19 | ;; 20 | ;; Each event handler can have its own chain of interceptors. Below we create 21 | ;; the interceptor chain shared by all event handlers which manipulate user. 22 | ;; A chain of interceptors is a vector. 23 | ;; Explanation of `trim-v` is given further below. 24 | ;; 25 | (def set-user-interceptor [(path :user) ;; `:user` path within `db`, rather than the full `db`. 26 | (after set-user-ls) ;; write user to localstore (after) 27 | trim-v]) ;; removes first (event id) element from the event vec 28 | 29 | ;; After logging out clean up local-storage so that when a users refreshes 30 | ;; the browser she/he is not automatically logged-in, and because it's a 31 | ;; good practice to clean-up after yourself. 32 | ;; 33 | (def remove-user-interceptor [(after remove-user-ls)]) 34 | 35 | ;; -- Helpers ----------------------------------------------------------------- 36 | ;; 37 | (def api-url "https://conduit.productionready.io/api") 38 | 39 | (defn endpoint [& params] 40 | "Concat any params to api-url separated by /" 41 | (str/join "/" (concat [api-url] params))) 42 | 43 | (defn auth-header [db] 44 | "Get user token and format for API authorization" 45 | (let [token (get-in db [:user :token])] 46 | (if token 47 | [:Authorization (str "Token " token)] 48 | nil))) 49 | 50 | (defn add-epoch [date coll] 51 | "Takes date identifier and adds :epoch (cljs-time.coerce/to-long) timestamp to coll" 52 | (map (fn [item] (assoc item :epoch (to-long (date item)))) coll)) 53 | 54 | (defn index-by [key coll] 55 | "Transform a coll to a map with a given key as a lookup value" 56 | (into {} (map (juxt key identity) (add-epoch :createdAt coll)))) 57 | 58 | (reg-fx ;; register a new event handler to use with our -fx events 59 | :set-hash ;; this will be provided in a map for -fx events and 60 | (fn [{:keys [hash]}] ;; accept :hash as parameter, something like this: {:hash path} 61 | (set! (.-hash js/location) hash))) ;; so that we can set window.location.hash to path 62 | 63 | ;; -- Event Handlers ---------------------------------------------------------- 64 | ;; 65 | (reg-event-fx ;; usage: (dispatch [:initialise-db]) 66 | :initialise-db ;; sets up initial application state 67 | 68 | ;; the interceptor chain (a vector of interceptors) 69 | [(inject-cofx :local-store-user)] ;; gets user from localstore, and puts into coeffects arg 70 | 71 | ;; the event handler (function) being registered 72 | (fn [{:keys [local-store-user]} _] ;; take 2 vals from coeffects. Ignore event vector itself. 73 | {:db (assoc default-db :user local-store-user)})) ;; what it returns becomes the new application state 74 | 75 | (reg-event-fx ;; usage: (dispatch [:set-active-page :home]) 76 | :set-active-page ;; triggered when the user clicks on a link that redirects to a another page 77 | (fn [{:keys [db]} [_ {:keys [page slug profile favorited]}]] ;; destructure 2nd parameter to obtain keys 78 | (let [set-page (assoc db :active-page page)] 79 | (case page 80 | ;; -- URL @ "/" -------------------------------------------------------- 81 | :home {:db set-page 82 | :dispatch-n (list (if (empty? (:user db)) ;; dispatch more than one event. When a user 83 | [:get-articles] ;; is NOT logged in we display all articles 84 | [:get-feed-articles]) ;; otherwiser we get her/his feed articles 85 | [:get-tags])} ;; we also can't forget to get tags 86 | 87 | ;; -- URL @ "/login" | "/register" | "/settings" ----------------------- 88 | (:login :register :settings) {:db set-page} ;; when using case with multiple test constants that 89 | ;; do the same thing we can group them together 90 | ;; (:login :register :settings) {:db set-page} is the same as: 91 | ;; :login {:db set-page} 92 | ;; :register {:db set-page} 93 | ;; :settings {:db set-page} 94 | ;; -- URL @ "/editor" -------------------------------------------------- 95 | :editor {:db set-page 96 | :dispatch (if slug ;; When we click article to edit we need 97 | [:set-active-article slug] ;; to set it active or if we want to write 98 | [:reset-active-article])} ;; a new article we reset 99 | 100 | ;; -- URL @ "/article/:slug" ------------------------------------------- 101 | :article {:db (assoc set-page 102 | :active-article slug) 103 | :dispatch-n (list [:get-article-comments {:slug slug}] 104 | [:get-user-profile {:profile (get-in db [:articles slug :author :username])}])} 105 | 106 | ;; -- URL @ "/:profile" ------------------------------------------------ 107 | :profile {:db (assoc set-page 108 | :active-article slug) 109 | :dispatch-n (list [:get-user-profile {:profile profile}] ;; again for dispatching multiple 110 | [:get-articles {:author profile}])} ;; events we can use :dispatch-n 111 | ;; -- URL @ "/:profile/favorites" -------------------------------------- 112 | :favorited {:db (assoc db :active-page :profile) ;; even though we are at :favorited we still 113 | :dispatch [:get-articles {:favorited favorited}]})))) ;; display :profile with :favorited articles 114 | 115 | (reg-event-db ;; usage: (dispatch [:reset-active-article]) 116 | :reset-active-article ;; triggered when the user enters new-article i.e. editor without slug 117 | (fn [db _] ;; 1st paramter in -db events is db, 2nd paramter not important therefore _ 118 | (dissoc db :active-article))) ;; compute and return the new state 119 | 120 | (reg-event-fx ;; usage: (dispatch [:set-active-article slug]) 121 | :set-active-article 122 | (fn [{:keys [db]} [_ slug]] ;; 1st parameter in -fx events is no longer just db. It is a map which contains a :db key. 123 | {:db (assoc db :active-article slug) ;; The handler is returning a map which describes two side-effects: 124 | :dispatch-n (list [:get-article-comments {:slug slug}] ;; changne to app-state :db and future event in this case :dispatch-n 125 | [:get-user-profile {:profile (get-in db [:articles slug :author :username])}])})) 126 | 127 | ;; -- GET Articles @ /api/articles -------------------------------------------- 128 | ;; 129 | (reg-event-fx ;; usage (dispatch [:get-articles {:limit 10 :tag "tag-name" ...}]) 130 | :get-articles ;; triggered every time user request articles with differetn params 131 | (fn [{:keys [db]} [_ params]] ;; params = {:limit 10 :tag "tag-name" ...} 132 | {:http-xhrio {:method :get 133 | :uri (endpoint "articles") ;; evaluates to "api/articles/" 134 | :params params ;; include params in the request 135 | :headers (auth-header db) ;; get and pass user token obtained during login 136 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 137 | :on-success [:get-articles-success] ;; trigger get-articles-success event 138 | :on-failure [:api-request-error :get-articles]} ;; trigger api-request-error with :get-articles 139 | :db (-> db 140 | (assoc-in [:loading :articles] true) 141 | (assoc-in [:filter :offset] (:offset params)) ;; base on paassed params set a filter 142 | (assoc-in [:filter :tag] (:tag params)) ;; so that we can easily show and hide 143 | (assoc-in [:filter :author] (:author params)) ;; appropriate ui components 144 | (assoc-in [:filter :favorites] (:favorited params)) 145 | (assoc-in [:filter :feed] false))})) ;; we need to disable filter by feed every time since it's not supported query param 146 | 147 | (reg-event-db 148 | :get-articles-success 149 | (fn [db [_ {articles :articles, articles-count :articlesCount}]] 150 | (-> db 151 | (assoc-in [:loading :articles] false) ;; turn off loading flag for this event 152 | (assoc :articles-count articles-count ;; change app-state by adding articles-count 153 | :articles (index-by :slug articles))))) ;; and articles, which we index-by slug 154 | 155 | ;; -- GET Article @ /api/articles/:slug --------------------------------------- 156 | ;; 157 | (reg-event-fx ;; usage (dispatch [:get-article {:slug "slug"}]) 158 | :get-article ;; triggered when a user upserts article i.e. is redirected to article page after saving an article 159 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "slug"} 160 | {:http-xhrio {:method :get 161 | :uri (endpoint "articles" (:slug params)) ;; evaluates to "api/articles/:slug" 162 | :headers (auth-header db) ;; get and pass user token obtained during login 163 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 164 | :on-success [:get-article-success] ;; trigger get-article-success event 165 | :on-failure [:api-request-error :get-article]} ;; trigger api-request-error with :get-articles 166 | :db (assoc-in db [:loading :article] true)})) 167 | 168 | (reg-event-db 169 | :get-article-success 170 | (fn [db [_ {article :article}]] 171 | (-> db 172 | (assoc-in [:loading :article] false) 173 | (assoc :articles (index-by :slug [article]))))) 174 | 175 | ;; -- POST/PUT Article @ /api/articles(/:slug) -------------------------------- 176 | ;; 177 | (reg-event-fx ;; usage (dispatch [:upsert-article article]) 178 | :upsert-article ;; when we update or insert (upsert) we are sending the same shape of information 179 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug" :article {:body "article body"} } 180 | {:db (assoc-in db [:loading :article] true) 181 | :http-xhrio {:method (if (:slug params) :put :post) ;; when we get a slug we'll update (:put) otherwise insert (:post) 182 | :uri (if (:slug params) ;; Same logic as above but we go with different 183 | (endpoint "articles" (:slug params)) ;; endpoint - one with :slug to update 184 | (endpoint "articles")) ;; and another to insert 185 | :headers (auth-header db) ;; get and pass user token obtained during login 186 | :params (:article params) 187 | :format (json-request-format) ;; make sure we are doing request format wiht json 188 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 189 | :on-success [:upsert-article-success] ;; trigger upsert-article-success event 190 | :on-failure [:api-request-error :upsert-article]}})) ;; trigger api-request-error with :upsert-article 191 | 192 | (reg-event-fx 193 | :upsert-article-success 194 | (fn [{:keys [db]} [_ {article :article}]] 195 | {:db (-> db 196 | (assoc-in [:loading :article] false) 197 | (dissoc :comments) ;; clean up any comments that we might have in db 198 | (dissoc :errors) ;; clean up any erros that we might have in db 199 | (assoc :active-page :article 200 | :active-article (:slug article))) 201 | :dispatch-n (list [:get-article {:slug (:slug article)}] ;; when the users clicks save we fetch the new version 202 | [:get-article-comments {:slug (:slug article)}]) ;; of the article and comments from the server 203 | :set-hash {:hash (str "/articles/" (:slug article))}})) ;; after successful upsert i.e. no errors from the server we set url to /articles/:slug 204 | 205 | ;; -- DELETE Article @ /api/articles/:slug ------------------------------------ 206 | ;; 207 | (reg-event-fx ;; usage (dispatch [:delete-article slug]) 208 | :delete-article ;; triggered when a user deletes an article 209 | (fn [{:keys [db]} [_ slug]] ;; slug = {:slug "article-slug"} 210 | {:db (assoc-in db [:loading :article] true) 211 | :http-xhrio {:method :delete 212 | :uri (endpoint "articles" slug) ;; evaluates to "api/articles/:slug" 213 | :headers (auth-header db) ;; get and pass user token obtained during login 214 | :params slug ;; pass the article slug to delete 215 | :format (json-request-format) ;; make sure we are doing request format wiht json 216 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 217 | :on-success [:delete-article-success] ;; trigger get-articles-success 218 | :on-failure [:api-request-error :delete-article]}})) ;; trigger api-request-error with :delete-article 219 | 220 | (reg-event-fx 221 | :delete-article-success 222 | (fn [{:keys [db]} _] 223 | {:db (-> db 224 | (update-in [:articles] dissoc (:active-article db)) 225 | (assoc-in [:loading :article] false)) 226 | :dispatch [:set-active-page :home] 227 | :set-hash {:hash "/"}})) 228 | 229 | ;; -- GET Feed Articles @ /api/articles/feed ---------------------------------- 230 | ;; 231 | (reg-event-fx ;; usage (dispatch [:get-feed-articles {:limit 10 :offset 0 ...}]) 232 | :get-feed-articles ;; triggered when Your Feed tab is loaded 233 | (fn [{:keys [db]} [_ params]] ;; params = {:offset 0 :limit 10} 234 | {:http-xhrio {:method :get 235 | :uri (endpoint "articles" "feed") ;; evaluates to "api/articles/feed" 236 | :params params ;; include params in the request 237 | :headers (auth-header db) ;; get and pass user token obtained during login 238 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 239 | :on-success [:get-feed-articles-success] ;; trigger get-articles-success event 240 | :on-failure [:api-request-error :get-feed-articles]} ;; trigger api-request-error with :get-feed-articles 241 | :db (-> db 242 | (assoc-in [:loading :articles] true) 243 | (assoc-in [:filter :offset] (:offset params)) 244 | (assoc-in [:filter :tag] nil) ;; with feed-articles we turn off almost all 245 | (assoc-in [:filter :author] nil) ;; filters to make sure everythinig on the 246 | (assoc-in [:filter :favorites] nil) ;; client is displayed correctly. 247 | (assoc-in [:filter :feed] true))})) ;; This is the only one we need 248 | 249 | (reg-event-db 250 | :get-feed-articles-success 251 | (fn [db [_ {articles :articles, articles-count :articlesCount}]] 252 | (-> db 253 | (assoc-in [:loading :articles] false) 254 | (assoc :articles-count articles-count 255 | :articles (index-by :slug articles))))) 256 | 257 | ;; -- GET Tags @ /api/tags ---------------------------------------------------- 258 | ;; 259 | (reg-event-fx ;; usage (dispatch [:get-tags]) 260 | :get-tags ;; triggered when the home page is loaded 261 | (fn [{:keys [db]} _] ;; second parameter is not important, therefore _ 262 | {:db (assoc-in db [:loading :tags] true) 263 | :http-xhrio {:method :get 264 | :uri (endpoint "tags") ;; evaluates to "api/tags" 265 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 266 | :on-success [:get-tags-success] ;; trigger get-tags-success event 267 | :on-failure [:api-request-error :get-tags]}})) ;; trigger api-request-error with :get-tags 268 | 269 | (reg-event-db 270 | :get-tags-success 271 | (fn [db [_ {tags :tags}]] 272 | (-> db 273 | (assoc-in [:loading :tags] false) 274 | (assoc :tags tags)))) 275 | 276 | ;; -- GET Comments @ /api/articles/:slug/comments ----------------------------- 277 | ;; 278 | (reg-event-fx ;; usage (dispatch [:get-article-comments {:slug "article-slug"}]) 279 | :get-article-comments ;; triggered when the article page is loaded 280 | (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug"} 281 | {:db (assoc-in db [:loading :comments] true) 282 | :http-xhrio {:method :get 283 | :uri (endpoint "articles" (:slug params) "comments") ;; evaluates to "api/articles/:slug/comments" 284 | :headers (auth-header db) ;; get and pass user token obtained during login 285 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 286 | :on-success [:get-article-comments-success] ;; trigger get-article-comments-success 287 | :on-failure [:api-request-error :get-article-comments]}})) ;; trigger api-request-error with :get-article-comments 288 | 289 | (reg-event-db 290 | :get-article-comments-success 291 | (fn [db [_ {comments :comments}]] 292 | (-> db 293 | (assoc-in [:loading :comments] false) 294 | (assoc :comments (index-by :id comments))))) ;; another index-by, this time by id 295 | 296 | ;; -- POST Comments @ /api/articles/:slug/comments ---------------------------- 297 | ;; 298 | (reg-event-fx ;; usage (dispatch [:post-comment comment]) 299 | :post-comment ;; triggered when a user submits a comment 300 | (fn [{:keys [db]} [_ body]] ;; body = {:body "body" } 301 | {:db (assoc-in db [:loading :comments] true) 302 | :http-xhrio {:method :post 303 | :uri (endpoint "articles" (:active-article db) "comments") ;; evaluates to "api/articles/:slug/comments" 304 | :headers (auth-header db) ;; get and pass user token obtained during login 305 | :params body 306 | :format (json-request-format) ;; make sure we are doing request format wiht json 307 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 308 | :on-success [:post-comment-success] ;; trigger get-articles-success 309 | :on-failure [:api-request-error :comments]}})) ;; trigger api-request-error with :comments 310 | 311 | (reg-event-fx 312 | :post-comment-success 313 | (fn [{:keys [db]} [_ comment]] 314 | {:db (-> db 315 | (assoc-in [:loading :comments] false) 316 | (assoc-in [:articles (:active-article db) :comments] comment) 317 | (update-in [:errors] dissoc :comments)) ;; clean up errors, if any 318 | :dispatch [:get-article-comments {:slug (:active-article db)}]})) 319 | 320 | ;; -- DELETE Comments @ /api/articles/:slug/comments/:comment-id ---------------------- 321 | ;; 322 | (reg-event-fx ;; usage (dispatch [:delete-comment comment-id]) 323 | :delete-comment ;; triggered when a user deletes an article 324 | (fn [{:keys [db]} [_ comment-id]] ;; comment-id = 1234 325 | {:db (do 326 | (assoc-in db [:loading :comments] true) 327 | (assoc db :active-comment comment-id)) 328 | :http-xhrio {:method :delete 329 | :uri (endpoint "articles" (:active-article db) "comments" comment-id) ;; evaluates to "api/articles/:slug/comments/:comment-id" 330 | :headers (auth-header db) ;; get and pass user token obtained during login 331 | :format (json-request-format) ;; make sure we are doing request format wiht json 332 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 333 | :on-success [:delete-comment-success] ;; trigger delete-comment-success 334 | :on-failure [:api-request-error :delete-comment]}})) ;; trigger api-request-error with :delete-comment 335 | 336 | (reg-event-db 337 | :delete-comment-success 338 | (fn [db _] 339 | (-> db 340 | (update-in [:comments] dissoc (:active-comment db)) ;; we could do another fetch of comments 341 | (dissoc :active-comment) ;; but instead we just remove it from app-db 342 | (assoc-in [:loading :comment] false)))) ;; which gives us much snappier ui 343 | 344 | ;; -- GET Profile @ /api/profiles/:username ----------------------------------- 345 | ;; 346 | (reg-event-fx ;; usage (dispatch [:get-user-profile {:profile "profile"}]) 347 | :get-user-profile ;; triggered when the profile page is loaded 348 | (fn [{:keys [db]} [_ params]] ;; params = {:profile "profile"} 349 | {:db (assoc-in db [:loading :profile] true) 350 | :http-xhrio {:method :get 351 | :uri (endpoint "profiles" (:profile params)) ;; evaluates to "api/profiles/:profile" 352 | :headers (auth-header db) ;; get and pass user token obtained during login 353 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 354 | :on-success [:get-user-profile-success] ;; trigger get-user-profile-success 355 | :on-failure [:api-request-error :get-user-profile]}})) ;; trigger api-request-error with :get-user-profile 356 | 357 | (reg-event-db 358 | :get-user-profile-success 359 | (fn [db [_ {profile :profile}]] 360 | (-> db 361 | (assoc-in [:loading :profile] false) 362 | (assoc :profile profile)))) 363 | 364 | ;; -- POST Login @ /api/users/login ------------------------------------------- 365 | ;; 366 | (reg-event-fx ;; usage (dispatch [:login user]) 367 | :login ;; triggered when a users submits login form 368 | (fn [{:keys [db]} [_ credentials]] ;; credentials = {:email ... :password ...} 369 | {:db (assoc-in db [:loading :login] true) 370 | :http-xhrio {:method :post 371 | :uri (endpoint "users" "login") ;; evaluates to "api/users/login" 372 | :params {:user credentials} ;; {:user {:email ... :password ...}} 373 | :format (json-request-format) ;; make sure it's json 374 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 375 | :on-success [:login-success] ;; trigger login-success 376 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :login 377 | 378 | (reg-event-fx 379 | :login-success 380 | ;; The standard set of interceptors, defined above, which we 381 | ;; use for all user-modifying event handlers. Looks after 382 | ;; writing user to localStorage. 383 | ;; NOTE: this chain includes `path` and `trim-v` 384 | set-user-interceptor 385 | 386 | ;; The event handler function. 387 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 388 | ;; value at `:user` path within `db`, rather than the full `db`. 389 | ;; And, further, it means the event handler returns just the value to be 390 | ;; put into `:user` path, and not the entire `db`. 391 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 392 | (fn [{user :db} [{props :user}]] 393 | {:db (merge user props) 394 | :dispatch-n (list [:complete-request :login] 395 | [:get-feed-articles {:tag nil :author nil :offset 0 :limit 10}]) 396 | :set-hash {:hash "/"}})) 397 | 398 | ;; -- POST Registration @ /api/users ------------------------------------------ 399 | ;; 400 | (reg-event-fx ;; usage (dispatch [:register-user registration]) 401 | :register-user ;; triggered when a users submits registration form 402 | (fn [{:keys [db]} [_ registration]] ;; registration = {:username ... :email ... :password ...} 403 | {:db (assoc-in db [:loading :register-user] true) 404 | :http-xhrio {:method :post 405 | :uri (endpoint "users") ;; evaluates to "api/users" 406 | :params {:user registration} ;; {:user {:username ... :email ... :password ...}} 407 | :format (json-request-format) ;; make sure it's json 408 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 409 | :on-success [:register-user-success] ;; trigger login-success 410 | :on-failure [:api-request-error :register-user]}})) ;; trigger api-request-error with :login-success 411 | 412 | (reg-event-fx 413 | :register-user-success 414 | ;; The standard set of interceptors, defined above, which we 415 | ;; use for all user-modifying event handlers. Looks after 416 | ;; writing user to LocalStore. 417 | ;; NOTE: this chain includes `path` and `trim-v` 418 | set-user-interceptor 419 | 420 | ;; The event handler function. 421 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 422 | ;; value at `:user` path within `db`, rather than the full `db`. 423 | ;; And, further, it means the event handler returns just the value to be 424 | ;; put into `:user` path, and not the entire `db`. 425 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 426 | (fn [{user :db} [{props :user}]] 427 | {:db (merge user props) 428 | :dispatch [:complete-request :register-user] 429 | :set-hash {:hash "/"}})) 430 | 431 | ;; -- PUT Update User @ /api/user --------------------------------------------- 432 | ;; 433 | (reg-event-fx ;; usage (dispatch [:update-user user]) 434 | :update-user ;; triggered when a users updates settgins 435 | (fn [{:keys [db]} [_ user]] ;; user = {:img ... :username ... :bio ... :email ... :password ...} 436 | {:db (assoc-in db [:loading :update-user] true) 437 | :http-xhrio {:method :put 438 | :uri (endpoint "user") ;; evaluates to "api/user" 439 | :params {:user user} ;; {:user {:img ... :username ... :bio ... :email ... :password ...}} 440 | :headers (auth-header db) ;; get and pass user token obtained during login 441 | :format (json-request-format) ;; make sure our request is json 442 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 443 | :on-success [:update-user-success] ;; trigger update-user-success 444 | :on-failure [:api-request-error :update-user]}})) ;; trigger api-request-error with :update-user 445 | 446 | (reg-event-fx 447 | :update-user-success 448 | ;; The standard set of interceptors, defined above, which we 449 | ;; use for all user-modifying event handlers. Looks after 450 | ;; writing user to LocalStore. 451 | ;; NOTE: this chain includes `path` and `trim-v` 452 | set-user-interceptor 453 | 454 | ;; The event handler function. 455 | ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the 456 | ;; value at `:user` path within `db`, rather than the full `db`. 457 | ;; And, further, it means the event handler returns just the value to be 458 | ;; put into `:user` path, and not the entire `db`. 459 | ;; So, a path interceptor makes the event handler act more like clojure's `update-in` 460 | (fn [{user :db} [{props :user}]] 461 | {:db (merge user props) 462 | :dispatch [:complete-request :update-user]})) 463 | 464 | ;; -- Toggle follow user @ /api/profiles/:username/follow --------------------- 465 | ;; 466 | (reg-event-fx ;; usage (dispatch [:toggle-follow-user username]) 467 | :toggle-follow-user ;; triggered when user clicks follow/unfollow button on profile page 468 | (fn [{:keys [db]} [_ username]] ;; username = :username 469 | {:db (assoc-in db [:loading :toggle-follow-user] true) 470 | :http-xhrio {:method (if (get-in db [:profile :following]) :delete :post) ;; check if we follow if yes DELETE, no POST 471 | :uri (endpoint "profiles" username "follow") ;; evaluates to "api/profiles/:username/follow" 472 | :headers (auth-header db) ;; get and pass user token obtained during login 473 | :format (json-request-format) ;; make sure it's json 474 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 475 | :on-success [:toggle-follow-user-success] ;; trigger toggle-follow-user-success 476 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :update-user-success 477 | 478 | (reg-event-db ;; usage: (dispatch [:toggle-follow-user-success]) 479 | :toggle-follow-user-success 480 | (fn [db [_ {profile :profile}]] 481 | (-> db 482 | (assoc-in [:loading :toggle-follow-user] false) 483 | (assoc-in [:profile :following] (:following profile))))) 484 | 485 | ;; -- Toggle favorite article @ /api/articles/:slug/favorite ------------------ 486 | ;; 487 | (reg-event-fx ;; usage (dispatch [:toggle-favorite-article slug]) 488 | :toggle-favorite-article ;; triggered when user clicks favorite/unfavorite button on profile page 489 | (fn [{:keys [db]} [_ slug]] ;; slug = :slug 490 | {:db (assoc-in db [:loading :toggle-favorite-article] true) 491 | :http-xhrio {:method (if (get-in db [:articles slug :favorited]) :delete :post) ;; check if article is already favorite: yes DELETE, no POST 492 | :uri (endpoint "articles" slug "favorite") ;; evaluates to "api/articles/:slug/favorite" 493 | :headers (auth-header db) ;; get and pass user token obtained during login 494 | :format (json-request-format) ;; make sure it's json 495 | :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords 496 | :on-success [:toggle-favorite-article-success] ;; trigger toggle-favorite-article-success 497 | :on-failure [:api-request-error :login]}})) ;; trigger api-request-error with :toggle-favorite-article 498 | 499 | (reg-event-db ;; usage: (dispatch [:toggle-favorite-article-success]) 500 | :toggle-favorite-article-success 501 | (fn [db [_ {article :article}]] 502 | (let [slug (:slug article) 503 | favorited (:favorited article)] 504 | (-> db 505 | (assoc-in [:loading :toggle-favorite-article] false) 506 | (assoc-in [:articles slug :favorited] favorited) 507 | (assoc-in [:articles slug :favoritesCount] (if favorited 508 | (:favoritesCount article inc) 509 | (:favoritesCount article dec))))))) 510 | 511 | ;; -- Logout ------------------------------------------------------------------ 512 | ;; 513 | (reg-event-fx ;; usage (dispatch [:logout]) 514 | :logout 515 | ;; This interceptor, defined above, makes sure 516 | ;; that we clean up localStorage after logging-out 517 | ;; the user. 518 | remove-user-interceptor 519 | ;; The event handler function removes the user from 520 | ;; app-state = :db and sets the url to "/". 521 | (fn [{:keys [db]} _] 522 | {:db (dissoc db :user) ;; remove user from db 523 | :set-hash {:hash "/"}})) ;; head back home after logout 524 | 525 | ;; -- Request Handlers ----------------------------------------------------------- 526 | ;; 527 | (reg-event-db 528 | :complete-request ;; when we complete a request we need to clean up 529 | (fn [db [_ request-type]] ;; few things so that our ui is nice and tidy 530 | (assoc-in db [:loading request-type] false))) 531 | 532 | (reg-event-fx 533 | :api-request-error ;; triggered when we get request-error from the server 534 | (fn [{:keys [db]} [_ request-type response]] ;; destructure to obtain request-type and response 535 | {:db (assoc-in db [:errors request-type] (get-in response [:response :errors])) ;; save in db so that we can 536 | :dispatch [:complete-request request-type]})) ;; display it to the user 537 | -------------------------------------------------------------------------------- /src/conduit/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.subs 2 | (:require [re-frame.core :refer [reg-sub subscribe]])) 3 | 4 | (defn reverse-cmp [a b] ;; https://clojure.org/guides/comparators 5 | "Sort numbers in decreasing order, i.e.: calls compare with the arguments in the opposite order" 6 | (compare b a)) 7 | 8 | (reg-sub 9 | :active-page ;; usage: (subscribe [:showing]) 10 | (fn [db _] ;; db is the (map) value stored in the app-db atom 11 | (:active-page db))) ;; extract a value from the application state 12 | 13 | (reg-sub 14 | :articles ;; usage: (subscribe [:articles]) 15 | (fn [db _] 16 | (let [articles (:articles db)] 17 | (->> articles 18 | (vals) ;; get values from (:articles db) 19 | (sort-by :epoch reverse-cmp))))) ;; sort-by epoch in reverse order 20 | 21 | (reg-sub 22 | :articles-count ;; usage: (subscribe [:articles]) 23 | (fn [db _] 24 | (:articles-count db))) 25 | 26 | (reg-sub 27 | :active-article ;; usage (subscribe [:active-article]) 28 | (fn [db _] 29 | (let [active-article (:active-article db)] 30 | (get-in db [:articles active-article])))) 31 | 32 | (reg-sub 33 | :tags ;; usage: (subscribe [:tags]) 34 | (fn [db _] 35 | (:tags db))) 36 | 37 | (reg-sub 38 | :comments ;; usage: (subscribe [:comments]) 39 | (fn [db _] 40 | (let [comments (:comments db)] 41 | (->> comments 42 | (vals) 43 | (sort-by :epoch reverse-cmp))))) 44 | 45 | (reg-sub 46 | :profile ;; usage: (subscribe [:profile]) 47 | (fn [db _] 48 | (:profile db))) 49 | 50 | (reg-sub 51 | :loading ;; usage: (subscribe [:loading]) 52 | (fn [db _] 53 | (:loading db))) 54 | 55 | (reg-sub 56 | :filter ;; usage: (subscribe [:filter]) 57 | (fn [db _] 58 | (:filter db))) 59 | 60 | (reg-sub 61 | :errors ;; usage: (subscribe [:errors]) 62 | (fn [db _] 63 | (:errors db))) 64 | 65 | (reg-sub 66 | :user ;; usage: (subscribe [:user]) 67 | (fn [db _] 68 | (:user db))) 69 | -------------------------------------------------------------------------------- /src/conduit/views.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.views 2 | (:require [reagent.core :as reagent] 3 | [re-frame.core :refer [subscribe dispatch]] 4 | [clojure.string :as str :refer [trim split]])) 5 | 6 | ;; -- Helpers ----------------------------------------------------------------- 7 | ;; 8 | (defn format-date 9 | [date] 10 | (.toDateString (js/Date. date))) 11 | 12 | (defn tags-list 13 | [tags-list] 14 | [:ul.tag-list 15 | (for [tag tags-list] 16 | ^{:key tag} [:li.tag-default.tag-pill.tag-outline tag])]) 17 | 18 | (defn article-meta 19 | [{author :author 20 | created-at :createdAt 21 | favorites-count :favoritesCount 22 | favorited :favorited 23 | slug :slug}] 24 | (let [loading @(subscribe [:loading]) 25 | user @(subscribe [:user]) 26 | profile @(subscribe [:profile])] 27 | [:div.article-meta 28 | [:a {:href (str "#/@" (:username author))} 29 | [:img {:src (:image author)}]] 30 | " " 31 | [:div.info 32 | [:a.author {:href (str "#/@" (:username author))} (:username author)] 33 | [:span.date (format-date created-at)]] 34 | (if (= (:username user) (:username author)) 35 | [:span 36 | [:a.btn.btn-sm.btn-outline-secondary {:href (str "#/editor/" slug)} 37 | [:i.ion-edit] 38 | [:span " Edit Article "]] 39 | " " 40 | [:a.btn.btn-outline-danger.btn-sm {:href "#/" 41 | :on-click #(dispatch [:delete-article slug])} 42 | [:i.ion-trash-a] 43 | [:span " Delete Article "]]] 44 | (when-not (empty? user) 45 | [:span 46 | [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user (:username profile)]) 47 | :class (when (:toggle-follow-user loading) "disabled")} 48 | [:i {:class (if (:following profile) "ion-minus-round" "ion-plus-round")}] 49 | [:span (if (:following profile) (str " Unfollow " (:username profile)) (str " Follow " (:username profile)))]] 50 | " " 51 | [:button.btn.btn-sm.btn-primary {:on-click #(dispatch [:toggle-favorite-article slug]) 52 | :class (cond 53 | (not favorited) "btn-outline-primary" 54 | (:toggle-favorite-article loading) "disabled")} 55 | [:i.ion-heart] 56 | [:span (if favorited " Unfavorite Post " " Favorite Post ")] 57 | [:span.counter "(" favorites-count ")"]]]))])) 58 | 59 | (defn articles-preview 60 | [{:keys [description slug createdAt title author favoritesCount favorited tagList]}] 61 | (let [loading @(subscribe [:loading])] 62 | [:div.article-preview 63 | [:div.article-meta 64 | [:a {:href (str "#/@" (:username author))} 65 | [:img {:src (:image author)}]] 66 | [:div.info 67 | [:a.author {:href (str "#/@" (:username author))} (:username author)] 68 | [:span.date (format-date createdAt)]] 69 | [:button.btn.btn-primary.btn-sm.pull-xs-right {:on-click #(dispatch [:toggle-favorite-article slug]) 70 | :class (cond 71 | (not favorited) "btn-outline-primary" 72 | (:toggle-favorite-article loading) "disabled")} 73 | [:i.ion-heart " "] 74 | [:span favoritesCount]]] 75 | [:a.preview-link {:href (str "#/article/" slug)} 76 | [:h1 title] 77 | [:p description] 78 | [:span "Read more ..."] 79 | [tags-list tagList]]])) ;; defined in Helpers section 80 | 81 | (defn articles-list 82 | [articles loading-articles] 83 | [:div 84 | (if loading-articles 85 | [:div.article-preview 86 | [:p "Loading articles ..."]] 87 | (if (empty? articles) 88 | [:div.article-preview 89 | [:p "No articles are here... yet."]] 90 | (for [article articles] 91 | ^{:key (:slug article)} [articles-preview article])))]) 92 | 93 | (defn errors-list 94 | [errors] 95 | [:ul.error-messages 96 | (for [[key [val]] errors] 97 | ^{:key key} [:li (str (name key) " " val)])]) 98 | 99 | ;; -- Header ------------------------------------------------------------------ 100 | ;; 101 | (defn header 102 | [] 103 | (let [user @(subscribe [:user]) 104 | active-page @(subscribe [:active-page])] 105 | [:nav.navbar.navbar-light 106 | [:div.container 107 | [:a.navbar-brand {:href "#/"} "conduit"] 108 | (if (empty? user) 109 | [:ul.nav.navbar-nav.pull-xs-right 110 | [:li.nav-item 111 | [:a.nav-link {:href "#/" :class (when (= active-page :home) "active")} "Home"]] 112 | [:li.nav-item 113 | [:a.nav-link {:href "#/login" :class (when (= active-page :login) "active")} "Sign in"]] 114 | [:li.nav-item 115 | [:a.nav-link {:href "#/register" :class (when (= active-page :register) "active")} "Sign up"]]] 116 | [:ul.nav.navbar-nav.pull-xs-right 117 | [:li.nav-item 118 | [:a.nav-link {:href "#/" :class (when (= active-page :home) "active")} "Home"]] 119 | [:li.nav-item 120 | [:a.nav-link {:href "#/editor" :class (when (= active-page :editor) "active")} 121 | [:i.ion-compose "New Article"]]] 122 | [:li.nav-item 123 | [:a.nav-link {:href "#/settings" :class (when (= active-page :settings) "active")} 124 | [:i.ion-gear-a "Settings"]]] 125 | [:li.nav-item 126 | [:a.nav-link {:href (str "#/@" (:username user)) :class (when (= active-page :profile) "active")} (:username user) 127 | [:img.user-pic {:src (:image user)}]]]])]])) 128 | 129 | ;; -- Footer ------------------------------------------------------------------ 130 | ;; 131 | (defn footer 132 | [] 133 | [:footer 134 | [:div.container 135 | [:a.logo-font {:href "#/"} "conduit"] 136 | [:span.attribution 137 | "An interactive learning project from " 138 | [:a {:href "https://thinkster.io"} "Thinkster"] 139 | ". Code & design licensed under MIT."]]]) 140 | 141 | ;; -- Home -------------------------------------------------------------------- 142 | ;; 143 | (defn get-articles [event params] 144 | (.preventDefault event) 145 | (dispatch [:get-articles params])) 146 | 147 | (defn get-feed-articles [event params] 148 | (.preventDefault event) 149 | (dispatch [:get-feed-articles params])) 150 | 151 | (defn home 152 | [] 153 | (let [filter @(subscribe [:filter]) 154 | tags @(subscribe [:tags]) 155 | loading @(subscribe [:loading]) 156 | articles @(subscribe [:articles]) 157 | articles-count @(subscribe [:articles-count]) 158 | user @(subscribe [:user])] 159 | [:div.home-page 160 | (when (empty? user) 161 | [:div.banner 162 | [:div.container 163 | [:h1.logo-font "conduit"] 164 | [:p "A place to share your knowledge."]]]) 165 | [:div.container.page 166 | [:div.row 167 | [:div.col-md-9 168 | [:div.feed-toggle 169 | [:ul.nav.nav-pills.outline-active 170 | (when-not (empty? user) 171 | [:li.nav-item 172 | [:a.nav-link {:href "" 173 | :class (when (:feed filter) "active") 174 | :on-click #(get-feed-articles % {:offset 0 :limit 10})} "Your Feed"]]) 175 | [:li.nav-item 176 | [:a.nav-link {:href "" 177 | :class (when-not (or (:tag filter) (:feed filter)) "active") 178 | :on-click #(get-articles % {:offset 0 :limit 10})} "Global Feed"]] ;; first argument: % is browser event, second: map of filter params 179 | (when (:tag filter) 180 | [:li.nav-item 181 | [:a.nav-link.active 182 | [:i.ion-pound] (str " " (:tag filter))]])]] 183 | [articles-list articles (:articles loading)] 184 | (when-not (or (:articles loading) (< articles-count 10)) 185 | [:ul.pagination 186 | (for [offset (range (/ articles-count 10))] 187 | ^{:key offset} [:li.page-item {:class (when (= (* offset 10) (:offset filter)) "active") 188 | :on-click #(get-articles % {:offset (* offset 10) :tag (:tag filter) :limit 10})} 189 | [:a.page-link {:href ""} (+ 1 offset)]])])] 190 | 191 | [:div.col-md-3 192 | [:div.sidebar 193 | [:p "Popular Tags"] 194 | (if (:tags loading) 195 | [:p "Loading tags ..."] 196 | [:div.tag-list 197 | (for [tag tags] 198 | ^{:key tag} [:a.tag-pill.tag-default {:href "" 199 | :on-click #(get-articles % {:tag tag :limit 10 :offset 0})} tag])])]]]]])) 200 | 201 | ;; -- Login ------------------------------------------------------------------- 202 | ;; 203 | (defn login-user [event credentials] 204 | (.preventDefault event) 205 | (dispatch [:login credentials])) 206 | 207 | (defn login 208 | [] 209 | (let [default {:email "" :password ""} 210 | credentials (reagent/atom default)] 211 | (fn [] 212 | (let [email (get @credentials :email) 213 | password (get @credentials :password) 214 | errors @(subscribe [:errors]) 215 | loading @(subscribe [:loading])] 216 | [:div.auth-page 217 | [:div.container.page 218 | [:div.row 219 | [:div.col-md-6.offset-md-3.col-xs-12 220 | [:h1.text-xs-center "Sign in"] 221 | [:p.text-xs-center 222 | [:a {:href "#/register"} "Need an account?"]] 223 | (when (:login errors) 224 | [errors-list (:login errors)]) 225 | [:form {:on-submit #(login-user % @credentials)} 226 | [:fieldset.form-group 227 | [:input.form-control.form-control-lg {:type "text" 228 | :placeholder "Email" 229 | :value email 230 | :on-change #(swap! credentials assoc :email (-> % .-target .-value)) 231 | :disabled (when (:login loading))}]] 232 | 233 | [:fieldset.form-group 234 | [:input.form-control.form-control-lg {:type "password" 235 | :placeholder "Password" 236 | :value password 237 | :on-change #(swap! credentials assoc :password (-> % .-target .-value)) 238 | :disabled (when (:login loading))}]] 239 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:login loading) "disabled")} "Sign in"]]]]]])))) 240 | 241 | ;; -- Register ---------------------------------------------------------------- 242 | ;; 243 | (defn register-user [event registration] 244 | (.preventDefault event) 245 | (dispatch [:register-user registration])) 246 | 247 | (defn register 248 | [] 249 | (let [default {:username "" :email "" :password ""} 250 | registration (reagent/atom default)] 251 | (fn [] 252 | (let [username (get @registration :username) 253 | email (get @registration :email) 254 | password (get @registration :password) 255 | loading @(subscribe [:loading]) 256 | errors @(subscribe [:errors])] 257 | [:div.auth-page 258 | [:div.container.page 259 | [:div.row 260 | [:div.col-md-6.offset-md-3.col-xs-12 261 | [:h1.text-xs-center "Sign up"] 262 | [:p.text-xs-center 263 | [:a {:href "#/login"} "Have an account?"]] 264 | (when (:register-user errors) 265 | [errors-list (:register-user errors)]) 266 | [:form {:on-submit #(register-user % @registration)} 267 | [:fieldset.form-group 268 | [:input.form-control.form-control-lg {:type "text" 269 | :placeholder "Your Name" 270 | :value username 271 | :on-change #(swap! registration assoc :username (-> % .-target .-value)) 272 | :disabled (when (:register-user loading))}]] 273 | [:fieldset.form-group 274 | [:input.form-control.form-control-lg {:type "text" 275 | :placeholder "Email" 276 | :value email 277 | :on-change #(swap! registration assoc :email (-> % .-target .-value)) 278 | :disabled (when (:register-user loading))}]] 279 | [:fieldset.form-group 280 | [:input.form-control.form-control-lg {:type "password" 281 | :placeholder "Password" 282 | :value password 283 | :on-change #(swap! registration assoc :password (-> % .-target .-value)) 284 | :disabled (when (:register-user loading))}]] 285 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:register-user loading) "disabled")} "Sign up"]]]]]])))) 286 | 287 | ;; -- Profile ----------------------------------------------------------------- 288 | ;; 289 | (defn profile 290 | [] 291 | (let [profile @(subscribe [:profile]) 292 | filter @(subscribe [:filter]) 293 | loading @(subscribe [:loading]) 294 | articles @(subscribe [:articles]) 295 | user @(subscribe [:user])] 296 | [:div.profile-page 297 | [:div.user-info 298 | [:div.container 299 | [:div.row 300 | [:div.col-xs-12.col-md-10.offset-md-1 301 | [:img.user-img {:src (:image profile)}] 302 | [:h4 (:username profile)] 303 | [:p (:bio profile)] 304 | (if (= (:username user) (:username profile)) 305 | [:a.btn.btn-sm.btn-outline-secondary.action-btn {:href "#/settings"} 306 | [:i.ion-gear-a] " Edit Profile Settings"] 307 | [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user (:username profile)]) 308 | :class (when (:toggle-follow-user loading) "disabled")} 309 | [:i {:class (if (:following profile) "ion-minus-round" "ion-plus-round")}] 310 | [:span (if (:following profile) (str " Unfollow " (:username profile)) (str " Follow " (:username profile)))]])]]]] 311 | [:div.container 312 | [:row 313 | [:div.col-xs-12.col-md-10.offset-md-1 314 | [:div.articles-toggle 315 | [:ul.nav.nav-pills.outline-active 316 | [:li.nav-item 317 | [:a.nav-link {:href (str "#/@" (:username profile)) :class (when (:author filter) " active")} "My Articles"]] 318 | [:li.nav-item 319 | [:a.nav-link {:href (str "#/@" (:username profile) "/favorites") :class (when (:favorites filter) "nav-link active")} "Favorited Articles"]]]] 320 | [articles-list articles (:articles loading)]]]]])) 321 | 322 | ;; -- Settings ---------------------------------------------------------------- 323 | ;; 324 | (defn logout-user [event] 325 | (.preventDefault event) 326 | (dispatch [:logout])) 327 | 328 | (defn update-user [event update] 329 | (.preventDefault event) 330 | (dispatch [:update-user update])) 331 | 332 | (defn settings 333 | [] 334 | (let [{:keys [bio email image username] :as user} @(subscribe [:user]) 335 | default {:bio bio :email email :image image :username username} 336 | loading @(subscribe [:loading]) 337 | user-update (reagent/atom default)] 338 | [:div.settings-page 339 | [:div.container.page 340 | [:div.row 341 | [:div.col-md-6.offset-md-3.col-xs-12 342 | [:h1.text-xs-center "Your Settings"] 343 | [:form 344 | [:fieldset 345 | [:fieldset.form-group 346 | [:input.form-control {:type "text" 347 | :placeholder "URL of profile picture" 348 | :default-value (:image user) 349 | :on-change #(swap! user-update assoc :image (-> % .-target .-value))}]] 350 | [:fieldset.form-group 351 | [:input.form-control.form-control-lg {:type "text" 352 | :placeholder "Your Name" 353 | :default-value (:username user) 354 | :on-change #(swap! user-update assoc :username (-> % .-target .-value)) 355 | :disabled (when (:update-user loading))}]] 356 | [:fieldset.form-group 357 | [:textarea.form-control.form-control-lg {:rows "8" 358 | :placeholder "Short bio about you" 359 | :default-value (:bio user) 360 | :on-change #(swap! user-update assoc :bio (-> % .-target .-value)) 361 | :disabled (when (:update-user loading))}]] 362 | [:fieldset.form-group 363 | [:input.form-control.form-control-lg {:type "text" 364 | :placeholder "Email" 365 | :default-value (:email user) 366 | :on-change #(swap! user-update assoc :email (-> % .-target .-value)) 367 | :disabled (when (:update-user loading))}]] 368 | [:fieldset.form-group 369 | [:input.form-control.form-control-lg {:type "password" 370 | :placeholder "Password" 371 | :default-value "" 372 | :on-change #(swap! user-update assoc :password (-> % .-target .-value)) 373 | :disabled (when (:update-user loading))}]] 374 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(update-user % @user-update) 375 | :class (when (:update-user loading) "disabled")} "Update Settings"]]] 376 | [:hr] 377 | [:button.btn.btn-outline-danger {:on-click #(logout-user %)} "Or click here to logout."]]]]])) 378 | 379 | ;; -- Editor ------------------------------------------------------------------ 380 | ;; 381 | (defn upsert-article [event content slug] 382 | (.preventDefault event) 383 | (let [title (trim (or (:title content) "")) 384 | description (trim (or (:description content) "")) 385 | body (trim (or (:body content) "")) 386 | tagList (split (:tagList content) #" ")] 387 | (dispatch [:upsert-article {:slug slug :article {:title title 388 | :description description 389 | :body body 390 | :tagList tagList}}]))) 391 | 392 | (defn editor 393 | [] 394 | (let [{:keys [title description body tagList slug] :as active-article} @(subscribe [:active-article]) 395 | default {:title title :description description :body body :tagList tagList} 396 | content (reagent/atom default)] 397 | (fn [] 398 | (let [errors @(subscribe [:errors])] 399 | [:div.editor-page 400 | [:div.container.page 401 | [:div.row 402 | [:div.col-md-10.offset-md-1.col-xs-12 403 | (when (:upsert-article errors) 404 | [errors-list (:upsert-article errors)]) 405 | [:form 406 | [:fieldset 407 | [:fieldset.form-group 408 | [:input.form-control.form-control-lg {:type "text" 409 | :placeholder "Article Title" 410 | :default-value title 411 | :on-change #(swap! content assoc :title (-> % .-target .-value))}]] 412 | [:fieldset.form-group 413 | [:input.form-control {:type "text" 414 | :placeholder "What's this article about?" 415 | :default-value description 416 | :on-change #(swap! content assoc :description (-> % .-target .-value))}]] 417 | [:fieldset.form-group 418 | [:textarea.form-control {:rows "8" 419 | :placeholder "Write your article (in markdown)" 420 | :default-value body 421 | :on-change #(swap! content assoc :body (-> % .-target .-value))}]] 422 | [:fieldset.form-group 423 | [:input.form-control {:type "text" 424 | :placeholder "Enter tags" 425 | :default-value tagList 426 | :on-change #(swap! content assoc :tagList (-> % .-target .-value))}] 427 | [:div.tag-list]] 428 | [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(upsert-article % @content slug)} 429 | (if active-article 430 | "Update Article" 431 | "Publish Article")]]]]]]])))) 432 | 433 | ;; -- Article ----------------------------------------------------------------- 434 | ;; 435 | (defn post-comment [event comment default] 436 | (.preventDefault event) 437 | (let [body (get @comment :body)] 438 | (dispatch [:post-comment {:body body}]) 439 | (reset! comment default))) 440 | 441 | (defn article 442 | [] 443 | (let [default {:body ""} 444 | comment (reagent/atom default)] 445 | (fn [] 446 | (let [active-article @(subscribe [:active-article]) 447 | user @(subscribe [:user]) 448 | comments @(subscribe [:comments]) 449 | errors @(subscribe [:errors]) 450 | loading @(subscribe [:loading])] 451 | [:div.article-page 452 | [:div.banner 453 | [:div.container 454 | [:h1 (:title active-article)] 455 | [article-meta active-article]]] ;; defined in Helpers section 456 | [:div.container.page 457 | [:div.row.article-content 458 | [:div.col-md-12 459 | [:p (:body active-article)]]] 460 | [tags-list (:tagList active-article)] ;; defined in Helpers section 461 | [:hr] 462 | [:div.article-actions 463 | [article-meta active-article]] ;; defined in Helpers section 464 | [:div.row 465 | [:div.col-xs-12.col-md-8.offset-md-2 466 | (when (:comments errors) 467 | [errors-list (:comments errors)]) ;; defined in Helpers section 468 | (if-not (empty? user) 469 | [:form.card.comment-form 470 | [:div.card-block 471 | [:textarea.form-control {:placeholder "Write a comment..." 472 | :rows "3" 473 | :value (:body @comment) 474 | :on-change #(swap! comment assoc :body (-> % .-target .-value))}]] 475 | [:div.card-footer 476 | [:img.comment-author-img {:src (:image user)}] 477 | [:button.btn.btn-sm.btn-primary {:class (when (:comments loading) "disabled") 478 | :on-click #(post-comment % comment default)} "Post Comment"]]] 479 | [:p 480 | [:a {:href "#/register"} "Sign up"] 481 | " or " 482 | [:a {:href "#/login"} "Sign in"] 483 | " to add comments on this article."]) 484 | (if (:comments loading) 485 | [:div 486 | [:p "Loading comments ..."]] 487 | (if (empty? comments) 488 | [:div] 489 | (for [{:keys [id createdAt body author]} comments] 490 | ^{:key id} [:div.card 491 | [:div.card-block 492 | [:p.card-text body]] 493 | [:div.card-footer 494 | [:a.comment-author {:href (str "#/@" (:username author))} 495 | [:img.comment-author-img {:src (:image author)}]] 496 | " " 497 | [:a.comment-author {:href (str "#/@" (:username author))} (:username author)] 498 | [:span.date-posted (format-date createdAt)] 499 | (when (= (:username user) (:username author)) 500 | [:span.mod-options {:on-click #(dispatch [:delete-comment id])} 501 | [:i.ion-trash-a]])]])))]]]])))) 502 | 503 | (defn pages [page-name] 504 | (case page-name 505 | :home [home] 506 | :login [login] 507 | :register [register] 508 | :profile [profile] 509 | :settings [settings] 510 | :editor [editor] 511 | :article [article] 512 | [home])) 513 | 514 | (defn conduit-app 515 | [] 516 | (let [active-page @(subscribe [:active-page])] 517 | [:div 518 | [header] 519 | [pages active-page] 520 | [footer]])) 521 | -------------------------------------------------------------------------------- /test/core_test.cljs: -------------------------------------------------------------------------------- 1 | (ns test.core-test 2 | (:require [cljs.test :refer-macros [deftest testing is]] 3 | [test.core :as core])) 4 | 5 | ;; Working on it ... 6 | ;; 7 | (deftest one-is-one 8 | (testing "if one equals one" 9 | (is (= 1 1)))) 10 | -------------------------------------------------------------------------------- /test/runner.cljs: -------------------------------------------------------------------------------- 1 | (ns test.runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | [test.core-test])) 4 | 5 | (doo-tests 'test.core-test) 6 | --------------------------------------------------------------------------------