├── .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 | # 
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 |
--------------------------------------------------------------------------------