├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── BACKEND_INSTRUCTIONS.md ├── FRONTEND_INSTRUCTIONS.md ├── LICENSE ├── MOBILE_INSTRUCTIONS.md ├── diagrams ├── Home.drawio.svg └── Index.drawio.svg ├── favicon.ico ├── images ├── angular.realworld.io.png ├── conduit-vanilla.herokuapp.png ├── event-driven-web-components-realworld-example-app.png ├── react-redux.realworld.png └── vue-vuex-realworld.netlify.png ├── index.html ├── logo.png ├── package-lock.json ├── package.json ├── readme.md ├── src ├── es │ ├── components │ │ ├── atoms │ │ │ └── ArticleMeta.js │ │ ├── controllers │ │ │ ├── Article.js │ │ │ ├── Comments.js │ │ │ ├── GetTags.js │ │ │ ├── MetaActions.js │ │ │ ├── Router.js │ │ │ └── User.js │ │ ├── molecules │ │ │ ├── ArticleFeedToggle.js │ │ │ ├── ArticlePreview.js │ │ │ ├── Comments.js │ │ │ ├── Pagination.js │ │ │ └── TagList.js │ │ ├── organisms │ │ │ ├── Footer.js │ │ │ ├── Header.js │ │ │ └── ListArticlePreviews.js │ │ └── pages │ │ │ ├── Article.js │ │ │ ├── Editor.js │ │ │ ├── Home.js │ │ │ ├── Login.js │ │ │ ├── Profile.js │ │ │ ├── Register.js │ │ │ └── Settings.js │ └── helpers │ │ ├── Debugging.js │ │ ├── Environment.js │ │ ├── Interfaces.js │ │ └── Utils.js └── index.html └── test ├── es ├── Test.js └── tests │ └── components │ ├── controllers │ ├── Article.js │ ├── GetTags.js │ └── Router.js │ ├── molecules │ ├── ArticleFeedToggle.js │ ├── ArticlePreview.js │ ├── Pagination.js │ └── TagList.js │ ├── organisms │ ├── Footer.js │ ├── Header.js │ └── ListArticlePreviews.js │ └── pages │ └── Home.js └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /bower_components 6 | 7 | # IDEs and editors 8 | /.idea 9 | .project 10 | .classpath 11 | *.launch 12 | .settings/ 13 | 14 | 15 | #System Files 16 | .DS_Store 17 | Thumbs.db 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.0.1-beta", 6 | "configurations": [ 7 | { 8 | "name": "Launch Source", 9 | "type": "firefox", 10 | "request": "launch", 11 | "reAttach": false, 12 | "reloadOnChange": { 13 | "watch": ["${workspaceFolder}/src/**/*.js", "${workspaceFolder}/src/**/*.html"], 14 | "ignore": "**/node_modules/**" 15 | }, 16 | "internalConsoleOptions": "openOnSessionStart", 17 | "firefoxArgs": ["-devtools"], 18 | "url": "${workspaceFolder}/src/index.html", 19 | "webRoot": "${workspaceFolder}/src" 20 | }, 21 | { 22 | "name": "Launch Source Windows", 23 | "type": "firefox", 24 | "request": "launch", 25 | "reAttach": false, 26 | "reloadOnChange": { 27 | "watch": ["${workspaceFolder}\\src\\**\\*.js", "${workspaceFolder}\\src\\**\\*.html"], 28 | "ignore": "**\\node_modules\\**" 29 | }, 30 | "internalConsoleOptions": "openOnSessionStart", 31 | "firefoxArgs": ["-devtools"], 32 | "url": "${workspaceFolder}\\src\\index.html", 33 | "webRoot": "${workspaceFolder}\\src" 34 | }, 35 | { 36 | "name": "Launch Test", 37 | "type": "firefox", 38 | "request": "launch", 39 | "reAttach": false, 40 | "reloadOnChange": { 41 | "watch": ["${workspaceFolder}/test/**/*.js", "${workspaceFolder}/test/**/*.html"], 42 | "ignore": "**/node_modules/**" 43 | }, 44 | "internalConsoleOptions": "openOnSessionStart", 45 | "url": "${workspaceFolder}/test/index.html", 46 | "webRoot": "${workspaceFolder}/test" 47 | }, 48 | { 49 | "name": "Launch TestChrome", 50 | "type": "chrome", 51 | "request": "launch", 52 | "internalConsoleOptions": "openOnSessionStart", 53 | "url": "http://localhost/${workspaceFolder}/test/index.html", 54 | "webRoot": "${workspaceFolder}/test" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "favorited", 4 | "markdownit" 5 | ], 6 | "editor.tabSize": 2, 7 | "editor.detectIndentation": false 8 | } -------------------------------------------------------------------------------- /BACKEND_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | # [Backend API spec](https://github.com/gothinkster/realworld/tree/master/api) 4 | 5 | For your convenience, we have a [Postman collection](https://github.com/gothinkster/realworld/blob/master/api/Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app. 6 | -------------------------------------------------------------------------------- /FRONTEND_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | ### Using the hosted API 4 | 5 | Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go! 6 | 7 | ### Routing Guidelines 8 | 9 | - Home page (URL: /#/ ) 10 | - List of tags 11 | - List of articles pulled from either Feed, Global, or by Tag 12 | - Pagination for list of articles 13 | - Sign in/Sign up pages (URL: /#/login, /#/register ) 14 | - Uses JWT (store the token in localStorage) 15 | - Authentication can be easily switched to session/cookie based 16 | - Settings page (URL: /#/settings ) 17 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here ) 18 | - Article page (URL: /#/article/article-slug-here ) 19 | - Delete article button (only shown to article's author) 20 | - Render markdown from server client side 21 | - Comments section at bottom of page 22 | - Delete comment button (only shown to comment's author) 23 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites ) 24 | - Show basic user info 25 | - List of articles populated from author's created articles or author's favorited articles 26 | 27 | # Styles 28 | 29 | Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](#header) does this by default): 30 | 31 | ```html 32 | 33 | ``` 34 | 35 | Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template). 36 | 37 | 38 | # Templates 39 | 40 | - [Layout](#layout) 41 | - [Header](#header) 42 | - [Footer](#footer) 43 | - [Pages](#pages) 44 | - [Home](#home) 45 | - [Login/Register](#loginregister) 46 | - [Profile](#profile) 47 | - [Settings](#settings) 48 | - [Create/Edit Article](#createedit-article) 49 | - [Article](#article) 50 | 51 | 52 | ## Layout 53 | 54 | 55 | ### Header 56 | 57 | ```html 58 | 59 | 60 | 61 | 62 | Conduit 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 95 | 96 | 97 | ``` 98 | 99 | ### Footer 100 | ```html 101 | 109 | 110 | 111 | 112 | ``` 113 | 114 | ## Pages 115 | 116 | ### Home 117 | ```html 118 |
119 | 120 | 126 | 127 |
128 |
129 | 130 |
131 |
132 | 140 |
141 | 142 |
143 | 153 | 154 |

How to build webapps that scale

155 |

This is the description for the post.

156 | Read more... 157 |
158 |
159 | 160 |
161 | 171 | 172 |

The song you won't ever stop singing. No matter how hard you try.

173 |

This is the description for the post.

174 | Read more... 175 |
176 |
177 | 178 |
179 | 180 |
181 | 195 |
196 | 197 |
198 |
199 | 200 |
201 | ``` 202 | 203 | ### Login/Register 204 | 205 | ```html 206 |
207 |
208 |
209 | 210 |
211 |

Sign up

212 |

213 | Have an account? 214 |

215 | 216 |
    217 |
  • That email is already taken
  • 218 |
219 | 220 |
221 |
222 | 223 |
224 |
225 | 226 |
227 |
228 | 229 |
230 | 233 |
234 |
235 | 236 |
237 |
238 |
239 | ``` 240 | 241 | ### Profile 242 | 243 | ```html 244 |
245 | 246 |
247 |
248 |
249 | 250 |
251 | 252 |

Eric Simons

253 |

254 | Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games 255 |

256 | 261 |
262 | 263 |
264 |
265 |
266 | 267 |
268 |
269 | 270 |
271 |
272 | 280 |
281 | 282 |
283 | 293 | 294 |

How to build webapps that scale

295 |

This is the description for the post.

296 | Read more... 297 |
298 |
299 | 300 |
301 | 311 | 312 |

The song you won't ever stop singing. No matter how hard you try.

313 |

This is the description for the post.

314 | Read more... 315 |
    316 |
  • Music
  • 317 |
  • Song
  • 318 |
319 |
320 |
321 | 322 | 323 |
324 | 325 |
326 |
327 | 328 |
329 | ``` 330 | 331 | ### Settings 332 | 333 | ```html 334 |
335 |
336 |
337 | 338 |
339 |

Your Settings

340 | 341 |
342 |
343 |
344 | 345 |
346 |
347 | 348 |
349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 |
358 | 361 |
362 |
363 |
364 | 365 |
366 |
367 |
368 | ``` 369 | 370 | ### Create/Edit Article 371 | 372 | ```html 373 |
374 |
375 |
376 | 377 |
378 |
379 |
380 |
381 | 382 |
383 |
384 | 385 |
386 |
387 | 388 |
389 |
390 |
391 |
392 | 395 |
396 |
397 |
398 | 399 |
400 |
401 |
402 | 403 | 404 | ``` 405 | 406 | ### Article 407 | 408 | ```html 409 |
410 | 411 | 437 | 438 |
439 | 440 |
441 |
442 |

443 | Web development technologies have evolved at an incredible clip over the past few years. 444 |

445 |

Introducing RealWorld.

446 |

It's a great solution for learning how other frameworks work.

447 |
448 |
449 | 450 |
451 | 452 |
453 | 472 |
473 | 474 |
475 | 476 |
477 | 478 |
479 |
480 | 481 |
482 | 488 |
489 | 490 |
491 |
492 |

With supporting text below as a natural lead-in to additional content.

493 |
494 | 502 |
503 | 504 |
505 |
506 |

With supporting text below as a natural lead-in to additional content.

507 |
508 | 520 |
521 | 522 |
523 | 524 |
525 | 526 |
527 | 528 |
529 | ``` 530 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Silvan Strübi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MOBILE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | > *Note: Delete this file before publishing your app!* 2 | 3 | # [Mobile Icons (iOS/Android)](https://github.com/gothinkster/realworld/tree/master/spec/mobile_icons) 4 | 5 | ### Using the hosted API 6 | 7 | Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go! 8 | 9 | ### Styles/Templates 10 | 11 | Unfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps. 12 | 13 | Instead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a "north star" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :) 14 | -------------------------------------------------------------------------------- /diagrams/Home.drawio.svg: -------------------------------------------------------------------------------- 1 |
CustomEvent:
article
holds article & render
Page Home
<p-home>
Pagination
<m-pagination>

On listArticles event
renders pages list

On list entries click event
emmits requestListArticles 
CustomEvent:
requestListArticles
holds query
connectedCallback
CustomEvent:
requestListArticles
ArticlePreview
<m-article-preview>

renders <m-article-meta>
ArticleFeedToggle
<m-article-feed-toggle>

On listArticles 
renders new menu point

On menu points click 
emmits requestListArticles 
CustomEvent:
listArticles
holds queried articles
Articles Controller
<c-article>

On requestListArticles event
fetches articles and
emmits listArticles
CustomEvent:
requestListArticles
holds query
ListArticlePreviews
<o-list-article-previews>

On listArticles 
event renders
<m-article-preview>
TagList
<m-tag-list>

On tags event
renders list of tags

On tag click event
emmits requestListArticles 
connectedCallback
CustomEvent:
getTags
CustomEvent:
tags
holds tags
GetTags
<c-get-tags>

On getTags event
fetches tags and
emmits tags
CustomEvent:
requestListArticles
holds query
Meta Actions
Controller
<c-meta-actions>

On setFavorite event
delete or post article
to accounts favorites
and emmits article 
ArticleMeta
<m-article-meta>

On favorite Heart click 
emmits setFavorite
CustomEvent:
setFavorite
holds article
2 | -------------------------------------------------------------------------------- /diagrams/Index.drawio.svg: -------------------------------------------------------------------------------- 1 |
Header
<o-header>
Footer
<o-footer>
Router
<c-router>

On hashchange event:
-loads and mounts different pages
depending the matching regex
in its routes
Pages
eg.: <p-home>
window
hashchange
2 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/favicon.ico -------------------------------------------------------------------------------- /images/angular.realworld.io.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/images/angular.realworld.io.png -------------------------------------------------------------------------------- /images/conduit-vanilla.herokuapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/images/conduit-vanilla.herokuapp.png -------------------------------------------------------------------------------- /images/event-driven-web-components-realworld-example-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/images/event-driven-web-components-realworld-example-app.png -------------------------------------------------------------------------------- /images/react-redux.realworld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/images/react-redux.realworld.png -------------------------------------------------------------------------------- /images/vue-vuex-realworld.netlify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/images/vue-vuex-realworld.netlify.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mits-gossau/event-driven-web-components-realworld-example-app/f3adc9857bdef6575ba2b8d2bde019378cff5b70/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "event-driven-web-components-realworld-example-app", 3 | "version": "1.0.1", 4 | "description": "Exemplary real world application built with Vanilla JS Web Components in an Event Driven Architecture", 5 | "main": "./src/index.html", 6 | "scripts": { 7 | "fix": "standard --fix" 8 | }, 9 | "author": "weedshaker@gmail.com, https://github.com/tailormadecode, https://github.com/V4L3", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "standard": "*" 13 | }, 14 | "standard": { 15 | "ignore": [ 16 | "/test/" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ## Event Driven Architecture Vanilla JS Web Components codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | ### [Demo](https://mits-gossau.github.io/event-driven-web-components-realworld-example-app)    [Test](https://mits-gossau.github.io/event-driven-web-components-realworld-example-app/test)    [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | This codebase was created to demonstrate a fully fledged fullstack application built with Event Driven Vanilla JS Web Components including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | We've gone to great lengths to adhere to the **Document Object Model (DOM)** community styleguides & best practices. 10 | 11 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 12 | 13 | ## How it works 14 | 15 | > Frontend Event Driven Architecture works basically like the DOM itself. There are loosely coupled components (nodes), which emmit events and those get captured by controllers also called stores, routers, etc. Those controllers emmit events on their behalf, which the components can consume. 16 | 17 | ## Getting started 18 | 19 | > Simply open the src/index.html on a local or remote web server like, node [live-server](https://www.npmjs.com/package/live-server), apache, nginx, xampp, etc. 20 | > Tests: Open the test/index.html 21 | 22 | ## Diagrams 23 | 24 | ### Index.html 25 | 26 | ![Index](./diagrams/Index.drawio.svg) 27 | 28 | ### pages/Home.js 29 | 30 | ![Home](./diagrams/Home.drawio.svg) 31 | 32 | ## Explanations 33 | 34 | Here you can find the 👉 [event driven web components tutorial](https://github.com/Weedshaker/event-driven-web-components-tutorial) 35 | 36 | * **ShadowDOM**'s mostly shine when encapsulating CSS. But the Conduit example has one global CSS Stylesheet and for that reason, it is more efficient not to have shadowDOM's, which all would have to import that global CSS separately. Note: The biggest strength of Web Components is their shadowDOM, means in a real life examples you would share general CSS styles through CSS variables and have specific styles on each component in their respective shadowDOM. This will improve performance, since the DOM renderer only needs to respect certain CSS for certain nodes/shadowDOM's. There is a good helper Class, which you can use to simply add CSS with the lines: ```this.css = '...' ``` and for avoiding to reset nodes with innerHTML, it includes functions like: ```this.html = '' ```. Overall, this prototype Class helps you to easily and comfortably deal with the ShadowDOM. Have a look at: [Shadow.js](https://github.com/Weedshaker/event-driven-web-components-prototypes/blob/master/src/Shadow.js) 37 | 38 | * **Dependencie**'s: This application uses ZERO production dependencies. One devDependency is used for linting, see the package.json for further details. 39 | 40 | * **Size**: 35 items, totalling 143.1 kB uncompressed 41 | 42 | ## Lighthouse Audits 43 | 44 | ### [React / Redux (81)](https://github.com/gothinkster/react-redux-realworld-example-app) 45 | 46 | ![react-redux.realworld](./images/react-redux.realworld.png) 47 | 48 | ### [Angular (75)](https://github.com/gothinkster/angular-realworld-example-app) 49 | 50 | ![angular.realworld.io](./images/angular.realworld.io.png) 51 | 52 | ### [Vue (82)](https://github.com/gothinkster/vue-realworld-example-app) 53 | 54 | ![vue-vuex-realworld.netlify](./images/vue-vuex-realworld.netlify.png) 55 | 56 | ### [Vanilla JS Web Components (92)](https://github.com/gothinkster/web-components-realworld-example-app) 57 | 58 | ![conduit-vanilla.herokuapp](./images/conduit-vanilla.herokuapp.png) 59 | 60 | ### Event Driven Vanilla JS Web Components (95) 61 | 62 | ![event-driven-web-components-realworld-example-app](./images/event-driven-web-components-realworld-example-app.png) 63 | 64 | ## Contributions 65 | 66 | * [TailorMadeCode](https://github.com/tailormadecode) Components Development 67 | * [V4L3](https://github.com/V4L3) Components Development 68 | * [Weedshaker](https://github.com/Weedshaker) Architecture, Tests & Components Development 69 | 70 | ## Special Thanks to 71 | 72 | * [rehrbar](https://github.com/orgs/mits-gossau/people/rehrbar) who was an essential part of developing the first PoC of an Event Driven Architecture, helping me to challenge ideas and concepts to its perfection with his profound software engineering background. 73 | -------------------------------------------------------------------------------- /src/es/components/atoms/ArticleMeta.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | import { secureImageSrc } from '../../helpers/Utils.js' 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 10 | * As an atom, this component can not hold further children (those would be quantum) 11 | * 12 | * @export 13 | * @class ArticleMeta 14 | */ 15 | export default class ArticleMeta extends HTMLElement { 16 | /** 17 | * customDefine 18 | * 19 | * @param {import("../../helpers/Interfaces.js").SingleArticle | null} [article = null] 20 | */ 21 | constructor (article = null, hasActions = false) { 22 | super() 23 | 24 | // allow innerHTML ArticleMeta with article as a string attribute 25 | this.article = article || JSON.parse((this.getAttribute('article') || '').replace(/'/g, '"') || '{}') 26 | this.hasActions = hasActions 27 | 28 | /** 29 | * Listens to the event name/typeArg: 'getArticle' 30 | * 31 | * @param {CustomEvent & {detail: import("../controllers/Article.js").ArticleEventDetail}} event 32 | */ 33 | this.articleListener = event => event.detail.fetch.then(({ article }) => { 34 | if (article.slug === this.article.slug) this.render(article) 35 | }) 36 | 37 | this.favoriteBtnListener = event => { 38 | if (!event.target) return false 39 | event.preventDefault() 40 | this.dispatchEvent(new CustomEvent('setFavorite', { 41 | /** @type {import("../controllers/MetaActions.js").SetFavoriteEventDetail} */ 42 | detail: { 43 | article: this.article 44 | }, 45 | bubbles: true, 46 | cancelable: true, 47 | composed: true 48 | })) 49 | } 50 | 51 | this.followBtnListener = event => { 52 | if (!event.target) return false 53 | event.preventDefault() 54 | this.dispatchEvent(new CustomEvent('followUser', { 55 | /** @type {import("../controllers/MetaActions.js").SetFavoriteEventDetail} */ 56 | detail: { 57 | article: this.article 58 | }, 59 | bubbles: true, 60 | cancelable: true, 61 | composed: true 62 | })) 63 | } 64 | 65 | this.deleteBtnListener = event => { 66 | if (!event.target) return false 67 | event.preventDefault() 68 | this.dispatchEvent(new CustomEvent('deleteArticle', { 69 | /** @type {import("../controllers/Article.js").DeleteArticleEventDetail} */ 70 | detail: { 71 | slug: this.article.slug 72 | }, 73 | bubbles: true, 74 | cancelable: true, 75 | composed: true 76 | })) 77 | } 78 | } 79 | 80 | connectedCallback () { 81 | document.body.addEventListener('article', this.articleListener) 82 | if (this.shouldComponentRender()) this.render(this.article) 83 | } 84 | 85 | disconnectedCallback () { 86 | document.body.removeEventListener('article', this.articleListener) 87 | if (this.btnFavorite) this.btnFavorite.removeEventListener('click', this.favoriteBtnListener) 88 | if (this.btnFollow) this.btnFollow.removeEventListener('click', this.followBtnListener) 89 | if (this.btnDelete) this.btnDelete.removeEventListener('click', this.deleteBtnListener) 90 | } 91 | 92 | /** 93 | * evaluates if a render is necessary 94 | * 95 | * @return {boolean} 96 | */ 97 | shouldComponentRender () { 98 | return !this.innerHTML 99 | } 100 | 101 | /** 102 | * renders the article 103 | * 104 | * @param {import("../../helpers/Interfaces.js").SingleArticle & {author: {self: boolean}}} [article = this.article] 105 | * @return {article | string} 106 | */ 107 | render (article = this.article) { 108 | if (!article.author || !article.tagList) return (this.innerHTML = '
An error occurred rendering the article-meta!
') 109 | this.innerHTML = ` 110 |
111 | 112 |
113 | ${article.author.username} 114 | ${new Date(article.createdAt).toDateString()} 115 |
116 | 117 | ${this.hasActions 118 | ? article.author.self 119 | ? ` 120 | Edit Article 121 | ` 122 | : ` 127 |   128 | ` 133 | : ``} 136 |
137 | ` 138 | if (this.btnFavorite) this.btnFavorite.addEventListener('click', this.favoriteBtnListener) 139 | if (this.btnFollow) this.btnFollow.addEventListener('click', this.followBtnListener) 140 | if (this.btnDelete) this.btnDelete.addEventListener('click', this.deleteBtnListener) 141 | return (this.article = article) 142 | } 143 | 144 | get btnFavorite () { 145 | return this.querySelector('button[name=favorite]') 146 | } 147 | 148 | get btnFollow () { 149 | return this.querySelector('button[name=follow]') 150 | } 151 | 152 | get btnDelete () { 153 | return this.querySelector('button[name=delete]') 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/es/components/controllers/Article.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global AbortController */ 5 | /* global CustomEvent */ 6 | /* global fetch */ 7 | /* global self */ 8 | 9 | /** 10 | * https://github.com/gothinkster/realworld/tree/master/api#get-article 11 | * 12 | * @typedef {{ slug?: string }} RequestArticleEventDetail 13 | */ 14 | 15 | /** 16 | * https://github.com/gothinkster/realworld/tree/master/api#delete-article 17 | * 18 | * @typedef {{ slug: string }} DeleteArticleEventDetail 19 | */ 20 | 21 | /** 22 | * https://github.com/gothinkster/realworld/tree/master/api#single-article 23 | * 24 | * @typedef {{ 25 | slug: RequestArticleEventDetail, 26 | fetch: Promise 27 | }} ArticleEventDetail 28 | */ 29 | 30 | /** 31 | * https://github.com/gothinkster/realworld/tree/master/api#list-articles 32 | * 33 | * @typedef {{ tag?: string, author?: string, favorited?: string, limit?: number, offset?: number, showYourFeed?: boolean }} RequestListArticlesEventDetail 34 | */ 35 | 36 | /** 37 | * https://github.com/gothinkster/realworld/tree/master/api#multiple-articles 38 | * 39 | * @typedef {{ 40 | query: RequestListArticlesEventDetail, 41 | queryString: string, 42 | fetch: Promise 43 | }} ListArticlesEventDetail 44 | */ 45 | 46 | import { Environment } from '../../helpers/Environment.js' 47 | 48 | /** 49 | * https://github.com/gothinkster/realworld/tree/master/api#get-article 50 | * As a controller, this component becomes a store and organizes events 51 | * dispatches: 'article' on 'requestArticle' 52 | * dispatches: 'article' on 'postArticle' 53 | * reroutes to home on 'deleteArticle' 54 | * dispatches: 'listArticles' on 'requestListArticles' 55 | * 56 | * @export 57 | * @class Article 58 | */ 59 | export default class Article extends HTMLElement { 60 | constructor () { 61 | super() 62 | 63 | /** 64 | * Used to cancel ongoing, older fetches 65 | * this makes sense, if you only expect one and most recent true result and not multiple 66 | * 67 | * @type {AbortController | null} 68 | */ 69 | this.abortController = null 70 | 71 | /** 72 | * Listens to the event name/typeArg: 'requestArticle' 73 | * 74 | * @param {CustomEvent & {detail: RequestArticleEventDetail}} event 75 | */ 76 | this.requestArticleListener = event => { 77 | // if no slug is sent, we grab it here from the location, this logic could also be handle through an event at the router 78 | const slug = event.detail.slug || Environment.slug || '' 79 | const url = `${Environment.fetchBaseUrl}articles/${slug}` 80 | // reset old AbortController and assign new one 81 | if (this.abortController) this.abortController.abort() 82 | this.abortController = new AbortController() 83 | // answer with event 84 | this.dispatchEvent(new CustomEvent('article', { 85 | /** @type {ArticleEventDetail} */ 86 | detail: { 87 | slug, 88 | fetch: fetch(url, { 89 | signal: this.abortController.signal, 90 | ...Environment.fetchHeaders 91 | }).then(response => { 92 | if (response.status >= 200 && response.status <= 299) return response.json() 93 | throw new Error(response.statusText) 94 | // @ts-ignore 95 | }) 96 | }, 97 | bubbles: true, 98 | cancelable: true, 99 | composed: true 100 | })) 101 | } 102 | 103 | this.postArticleListener = event => { 104 | const url = `${Environment.fetchBaseUrl}articles${event.detail.slug ? `/${event.detail.slug}` : ''}` 105 | 106 | if (this.abortController) this.abortController.abort() 107 | this.abortController = new AbortController() 108 | // answer with event 109 | this.dispatchEvent(new CustomEvent('article', { 110 | detail: { 111 | fetch: fetch(url, 112 | { 113 | method: event.detail.slug ? 'PUT' : 'POST', 114 | ...Environment.fetchHeaders, 115 | body: JSON.stringify(event.detail.body), 116 | signal: this.abortController.signal 117 | }) 118 | .then(response => { 119 | if (response.status >= 200 && response.status <= 299) return response.json() 120 | throw new Error(response.statusText) 121 | }) 122 | .then(data => { 123 | if (data.errors) throw data.errors 124 | self.location.hash = `#/articles/${data.article.slug}` 125 | }) 126 | }, 127 | bubbles: true, 128 | cancelable: true, 129 | composed: true 130 | })) 131 | } 132 | 133 | /** 134 | * Listens to the event name/typeArg: 'deleteArticle' 135 | * 136 | * @param {CustomEvent & {detail: DeleteArticleEventDetail}} event 137 | */ 138 | this.deleteArticleListener = event => { 139 | // if no slug is sent, we grab it here from the location, this logic could also be handle through an event at the router 140 | const slug = event.detail.slug || Environment.slug || '' 141 | const url = `${Environment.fetchBaseUrl}articles/${slug}` 142 | // reset old AbortController and assign new one 143 | if (this.abortController) this.abortController.abort() 144 | this.abortController = new AbortController() 145 | fetch(url, { 146 | method: 'DELETE', 147 | signal: this.abortController.signal, 148 | ...Environment.fetchHeaders 149 | }).then(response => { 150 | if (response.status >= 200 && response.status <= 299) return (self.location.href = '#/') 151 | throw new Error(response.statusText) 152 | }) 153 | } 154 | 155 | /** 156 | * Listens to the event name/typeArg: 'requestListArticles' 157 | * 158 | * @param {CustomEvent & {detail: RequestListArticlesEventDetail}} event 159 | */ 160 | this.requestListArticlesListener = event => { 161 | // add default limit 162 | const detail = Object.assign({ limit: Environment.articlesPerPageLimit }, event.detail) 163 | // assemble query 164 | let query = '' 165 | for (const key in detail) { 166 | if (key !== 'showYourFeed' && detail[key] !== undefined && detail[key] !== '') query += `${query ? '&' : '?'}${key}=${detail[key]}` 167 | } 168 | const url = `${Environment.fetchBaseUrl}articles${detail.showYourFeed ? '/feed' : ''}${query}` 169 | // reset old AbortController and assign new one 170 | if (this.abortController) this.abortController.abort() 171 | this.abortController = new AbortController() 172 | // answer with event 173 | this.dispatchEvent(new CustomEvent('listArticles', { 174 | /** @type {ListArticlesEventDetail} */ 175 | detail: { 176 | query: detail, 177 | queryString: query, 178 | fetch: fetch(url, { 179 | signal: this.abortController.signal, 180 | ...Environment.fetchHeaders 181 | }).then(response => { 182 | if (response.status >= 200 && response.status <= 299) return response.json() 183 | throw new Error(response.statusText) 184 | // @ts-ignore 185 | }) 186 | }, 187 | bubbles: true, 188 | cancelable: true, 189 | composed: true 190 | })) 191 | } 192 | } 193 | 194 | connectedCallback () { 195 | this.addEventListener('requestArticle', this.requestArticleListener) 196 | this.addEventListener('postArticle', this.postArticleListener) 197 | this.addEventListener('deleteArticle', this.deleteArticleListener) 198 | this.addEventListener('requestListArticles', this.requestListArticlesListener) 199 | } 200 | 201 | disconnectedCallback () { 202 | this.removeEventListener('requestArticle', this.requestArticleListener) 203 | this.removeEventListener('postArticle', this.postArticleListener) 204 | this.removeEventListener('deleteArticle', this.deleteArticleListener) 205 | this.removeEventListener('requestListArticles', this.requestListArticlesListener) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/es/components/controllers/Comments.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global AbortController */ 5 | /* global CustomEvent */ 6 | /* global fetch */ 7 | 8 | /** 9 | * https://github.com/gothinkster/realworld/tree/master/api#add-comments-to-an-article 10 | * 11 | * @typedef {{ slug?: string, body: string }} AddCommentsEventDetail 12 | */ 13 | 14 | /** 15 | * https://github.com/gothinkster/realworld/tree/master/api#add-comments-to-an-article 16 | * 17 | * @typedef {{ 18 | fetch: Promise 19 | }} CommentEventDetail 20 | */ 21 | 22 | /** 23 | * https://github.com/gothinkster/realworld/tree/master/api#get-comments-from-an-article 24 | * 25 | * @typedef {{ slug?: string }} GetCommentsEventDetail 26 | */ 27 | 28 | /** 29 | * https://github.com/gothinkster/realworld/tree/master/api#get-comments-from-an-article 30 | * 31 | * @typedef {{ 32 | fetch: Promise 33 | }} CommentsEventDetail 34 | */ 35 | 36 | /** 37 | * https://github.com/gothinkster/realworld/tree/master/api#delete-comment 38 | * 39 | * @typedef {{ slug?: string, id: string }} DeleteCommentEventDetail 40 | */ 41 | 42 | import { Environment } from '../../helpers/Environment.js' 43 | 44 | /** 45 | * https://github.com/gothinkster/realworld/tree/master/api#add-comments-to-an-article 46 | * As a controller, this component becomes a store and organizes events 47 | * dispatches: 'comment' on 'addComment' 48 | * dispatches: 'comments' on 'getComments' 49 | * does nothing on 'deleteComment' 50 | * 51 | * @export 52 | * @class Comments 53 | */ 54 | export default class Comments extends HTMLElement { 55 | constructor () { 56 | super() 57 | 58 | /** 59 | * Used to cancel ongoing, older fetches 60 | * this makes sense, if you only expect one and most recent true result and not multiple 61 | * 62 | * @type {AbortController | null} 63 | */ 64 | this.abortController = null 65 | 66 | /** 67 | * Listens to the event name/typeArg: 'addComment' 68 | * 69 | * @param {CustomEvent & {detail: AddCommentsEventDetail}} event 70 | */ 71 | this.addCommentListener = event => { 72 | // if no slug is sent, we grab it here from the location, this logic could also be handle through an event at the router 73 | const slug = (event.detail && event.detail.slug) || Environment.slug || '' 74 | const url = `${Environment.fetchBaseUrl}articles/${slug}/comments` 75 | // reset old AbortController and assign new one 76 | if (this.abortController) this.abortController.abort() 77 | this.abortController = new AbortController() 78 | // answer with event 79 | this.dispatchEvent(new CustomEvent('comment', { 80 | /** @type {CommentEventDetail} */ 81 | detail: { 82 | fetch: fetch(url, { 83 | method: 'POST', 84 | body: JSON.stringify({ comment: { body: event.detail.body } }), 85 | signal: this.abortController.signal, 86 | ...Environment.fetchHeaders 87 | }).then(response => { 88 | if (response.status >= 200 && response.status <= 299) return response.json() 89 | throw new Error(response.statusText) 90 | // @ts-ignore 91 | }) 92 | }, 93 | bubbles: true, 94 | cancelable: true, 95 | composed: true 96 | })) 97 | } 98 | 99 | /** 100 | * Listens to the event name/typeArg: 'getComments' 101 | * 102 | * @param {CustomEvent & {detail: GetCommentsEventDetail}} event 103 | */ 104 | this.getCommentsListener = event => { 105 | // if no slug is sent, we grab it here from the location, this logic could also be handle through an event at the router 106 | const slug = (event.detail && event.detail.slug) || Environment.slug || '' 107 | const url = `${Environment.fetchBaseUrl}articles/${slug}/comments` 108 | // reset old AbortController and assign new one 109 | if (this.abortController) this.abortController.abort() 110 | this.abortController = new AbortController() 111 | // answer with event 112 | this.dispatchEvent(new CustomEvent('comments', { 113 | /** @type {CommentsEventDetail} */ 114 | detail: { 115 | fetch: fetch(url, { 116 | signal: this.abortController.signal, 117 | ...Environment.fetchHeaders 118 | }).then(response => { 119 | if (response.status >= 200 && response.status <= 299) return response.json() 120 | throw new Error(response.statusText) 121 | // @ts-ignore 122 | }) 123 | }, 124 | bubbles: true, 125 | cancelable: true, 126 | composed: true 127 | })) 128 | } 129 | 130 | /** 131 | * Listens to the event name/typeArg: 'deleteComment' 132 | * 133 | * @param {CustomEvent & {detail: DeleteCommentEventDetail}} event 134 | */ 135 | this.deleteCommentListener = event => { 136 | // if no slug is sent, we grab it here from the location, this logic could also be handle through an event at the router 137 | const slug = (event.detail && event.detail.slug) || Environment.slug || '' 138 | const url = `${Environment.fetchBaseUrl}articles/${slug}/comments/${event.detail.id}` 139 | fetch(url, { 140 | method: 'DELETE', 141 | ...Environment.fetchHeaders 142 | }).then(response => { 143 | if (response.status >= 200 && response.status <= 299) return 144 | throw new Error(response.statusText) 145 | // @ts-ignore 146 | }) 147 | } 148 | } 149 | 150 | connectedCallback () { 151 | this.addEventListener('addComment', this.addCommentListener) 152 | this.addEventListener('getComments', this.getCommentsListener) 153 | this.addEventListener('deleteComment', this.deleteCommentListener) 154 | } 155 | 156 | disconnectedCallback () { 157 | this.removeEventListener('addComment', this.addCommentListener) 158 | this.removeEventListener('getComments', this.getCommentsListener) 159 | this.removeEventListener('deleteComment', this.deleteCommentListener) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/es/components/controllers/GetTags.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global AbortController */ 5 | /* global CustomEvent */ 6 | /* global fetch */ 7 | 8 | /** 9 | * https://github.com/gothinkster/realworld/tree/master/api#get-tags 10 | * 11 | * @typedef {{ 12 | fetch: Promise 13 | }} TagsEventDetail 14 | */ 15 | 16 | import { Environment } from '../../helpers/Environment.js' 17 | 18 | /** 19 | * https://github.com/gothinkster/realworld/tree/master/api#get-tags 20 | * As a controller, this component becomes a store and organizes events 21 | * dispatches: 'tags' on 'getTags' 22 | * 23 | * @export 24 | * @class GetTags 25 | */ 26 | export default class GetTags extends HTMLElement { 27 | constructor () { 28 | super() 29 | 30 | /** 31 | * Used to cancel ongoing, older fetches 32 | * this makes sense, if you only expect one and most recent true result and not multiple 33 | * 34 | * @type {AbortController | null} 35 | */ 36 | this.abortController = null 37 | 38 | /** 39 | * Listens to the event name/typeArg: 'getTags' 40 | * 41 | * @param {CustomEvent} event 42 | */ 43 | this.getTagsListener = event => { 44 | const url = `${Environment.fetchBaseUrl}tags` 45 | // reset old AbortController and assign new one 46 | if (this.abortController) this.abortController.abort() 47 | this.abortController = new AbortController() 48 | // answer with event 49 | this.dispatchEvent(new CustomEvent('tags', { 50 | /** @type {TagsEventDetail} */ 51 | detail: { 52 | fetch: fetch(url, { 53 | signal: this.abortController.signal, 54 | ...Environment.fetchHeaders 55 | }).then(response => { 56 | if (response.status >= 200 && response.status <= 299) return response.json() 57 | throw new Error(response.statusText) 58 | // @ts-ignore 59 | }) 60 | }, 61 | bubbles: true, 62 | cancelable: true, 63 | composed: true 64 | })) 65 | } 66 | } 67 | 68 | connectedCallback () { 69 | this.addEventListener('getTags', this.getTagsListener) 70 | } 71 | 72 | disconnectedCallback () { 73 | this.removeEventListener('getTags', this.getTagsListener) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/es/components/controllers/MetaActions.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global fetch */ 4 | /* global HTMLElement */ 5 | /* global location */ 6 | /* global self */ 7 | /* global AbortController */ 8 | /* global CustomEvent */ 9 | 10 | /** 11 | * https://github.com/gothinkster/realworld/tree/master/api#favorite-article 12 | * 13 | * @typedef {{ article?: import("../../helpers/Interfaces.js").SingleArticle, profile?: import("../../helpers/Interfaces.js").Profile}} SetFavoriteEventDetail 14 | */ 15 | 16 | import { Environment } from '../../helpers/Environment.js' 17 | 18 | /** 19 | * https://github.com/gothinkster/realworld/tree/master/api#favorite-article 20 | * As a controller, this component becomes a store and organizes events 21 | * dispatches: 'article' on 'setFavorite' 22 | * dispatches: 'article' or 'profile' on 'followUser' 23 | * 24 | * @export 25 | * @class Favorite 26 | */ 27 | export default class Favorite extends HTMLElement { 28 | constructor () { 29 | super() 30 | this.isAuthenticated = false 31 | this.abortController = null 32 | /** 33 | * Listens to the event name/typeArg: 'user' 34 | * 35 | * @param {CustomEvent & {detail: import("./User.js").UserEventDetail}} event 36 | */ 37 | this.userListener = event => { 38 | event.detail.fetch.then(user => { 39 | this.isAuthenticated = !!user 40 | }).catch(error => { 41 | this.isAuthenticated = false 42 | console.log(`Error@UserFetch: ${error}`) 43 | }) 44 | } 45 | 46 | /** 47 | * Listens to the event name/typeArg: 'setFavorite' 48 | * 49 | * @param {CustomEvent & {detail: SetFavoriteEventDetail}} event 50 | * @return {Promise | false} 51 | */ 52 | this.setFavoriteListener = event => { 53 | if (!this.isAuthenticated) self.location.href = '#/register' 54 | 55 | if (!event.detail.article || !this.isAuthenticated) return false 56 | 57 | if (this.abortController) this.abortController.abort() 58 | this.abortController = new AbortController() 59 | 60 | const url = `${Environment.fetchBaseUrl}articles/${event.detail.article.slug}/favorite` 61 | 62 | return fetch(url, { 63 | method: event.detail.article.favorited ? 'DELETE' : 'POST', 64 | ...Environment.fetchHeaders, 65 | signal: this.abortController.signal 66 | }).then(response => { 67 | if (response.status >= 200 && response.status <= 299) return response.json() 68 | throw new Error(response.statusText) 69 | }).then( 70 | /** 71 | * Answer the CustomEvent setFavorite 72 | * 73 | * @param {import("../../helpers/Interfaces.js").SingleArticle} article 74 | * @return {void | false} 75 | */ 76 | article => { 77 | this.dispatchEvent(new CustomEvent('article', { 78 | /** @type {GetArticleEventDetail} */ 79 | detail: { 80 | slug: article.slug, 81 | fetch: Promise.resolve(article) 82 | }, 83 | bubbles: true, 84 | cancelable: true, 85 | composed: true 86 | })) 87 | } 88 | // forward to login, if error means that the user is unauthorized 89 | // @ts-ignore 90 | ).catch(error => error.message === 'Unauthorized' ? (location.hash = console.warn(url, 'Unauthorized User:', error) || '#/login') : console.warn(url, error) || error) 91 | } 92 | 93 | /** 94 | * Listens to the event name/typeArg: 'setFavorite' 95 | * 96 | * @param {CustomEvent & {detail: SetFavoriteEventDetail}} event 97 | * @return {Promise | any} 98 | */ 99 | this.followUserListener = event => { 100 | if (!this.isAuthenticated) return (self.location.href = '#/register') 101 | 102 | if (!event.detail.article && !event.detail.profile.username) return false 103 | 104 | if (this.abortController) this.abortController.abort() 105 | this.abortController = new AbortController() 106 | 107 | const url = `${Environment.fetchBaseUrl}profiles/${(event.detail.article && event.detail.article.author.username) || event.detail.profile.username}/follow` 108 | 109 | return fetch(url, { 110 | method: event.detail.article && event.detail.article.author.following ? 'DELETE' : event.detail.profile && event.detail.profile.following ? 'DELETE' : 'POST', 111 | ...Environment.fetchHeaders, 112 | signal: this.abortController.signal 113 | }).then(response => { 114 | if (response.status >= 200 && response.status <= 299) return response.json() 115 | throw new Error(response.statusText) 116 | }).then( 117 | /** 118 | * Answer the CustomEvent setFavorite 119 | * 120 | * @param {import("../../helpers/Interfaces.js").Profile} profile 121 | * @return {void | false} 122 | */ 123 | ({ profile }) => { 124 | if (event.detail.article) { 125 | const article = Object.assign(event.detail.article, { author: profile }) 126 | this.dispatchEvent(new CustomEvent('article', { 127 | detail: { 128 | slug: article.slug, 129 | fetch: Promise.resolve({ article }) 130 | }, 131 | bubbles: true, 132 | cancelable: true, 133 | composed: true 134 | })) 135 | } else { 136 | this.dispatchEvent(new CustomEvent('profile', { 137 | detail: { 138 | fetch: Promise.resolve({ profile }) 139 | }, 140 | bubbles: true, 141 | cancelable: true, 142 | composed: true 143 | })) 144 | } 145 | } 146 | // forward to login, if error means that the user is unauthorized 147 | // @ts-ignore 148 | ).catch(error => error.message === 'Unauthorized' ? (location.hash = console.warn(url, 'Unauthorized User:', error) || '#/login') : console.warn(url, error) || error) 149 | } 150 | } 151 | 152 | connectedCallback () { 153 | document.body.addEventListener('user', this.userListener) 154 | this.addEventListener('setFavorite', this.setFavoriteListener) 155 | this.addEventListener('followUser', this.followUserListener) 156 | 157 | this.dispatchEvent(new CustomEvent('getUser', { 158 | bubbles: true, 159 | cancelable: true, 160 | composed: true 161 | })) 162 | } 163 | 164 | disconnectedCallback () { 165 | document.body.removeEventListener('user', this.userListener) 166 | this.removeEventListener('setFavorite', this.setFavoriteListener) 167 | this.removeEventListener('followUser', this.followUserListener) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/es/components/controllers/Router.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @typedef {{ 4 | name: string, 5 | path: string, 6 | regExp: RegExp, 7 | component?: HTMLElement 8 | }} Route 9 | */ 10 | 11 | /* global self */ 12 | /* global HTMLElement */ 13 | /* global location */ 14 | /* global customElements */ 15 | 16 | /** 17 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#routing-guidelines 18 | * As a controller, this component becomes a router 19 | * 20 | * @export 21 | * @class Router 22 | */ 23 | export default class Router extends HTMLElement { 24 | constructor () { 25 | super() 26 | 27 | /** @type {Route[]} */ 28 | this.routes = [ 29 | // Home page (URL: /#/ ) 30 | { 31 | name: 'p-home', 32 | path: '../pages/Home.js', 33 | regExp: new RegExp(/^#\/$/) 34 | }, 35 | // Sign in/Sign up pages (URL: /#/login, /#/register ) 36 | { 37 | name: 'p-login', 38 | path: '../pages/Login.js', 39 | regExp: new RegExp(/^#\/login/) 40 | }, 41 | { 42 | name: 'p-register', 43 | path: '../pages/Register.js', 44 | regExp: new RegExp(/^#\/register/) 45 | }, 46 | // Settings page (URL: /#/settings ) 47 | { 48 | name: 'p-settings', 49 | path: '../pages/Settings.js', 50 | regExp: new RegExp(/^#\/settings/) 51 | }, 52 | // Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here ) 53 | { 54 | name: 'p-editor', 55 | path: '../pages/Editor.js', 56 | regExp: new RegExp(/^#\/editor/) 57 | }, 58 | // Article page (URL: /#/article/article-slug-here ) 59 | { 60 | name: 'p-article', 61 | path: '../pages/Article.js', 62 | regExp: new RegExp(/^#\/article/) 63 | }, 64 | // Profile page (URL: /#/profile/:username, /#/profile/:username/favorites ) 65 | { 66 | name: 'p-profile', 67 | path: '../pages/Profile.js', 68 | regExp: new RegExp(/^#\/profile/) 69 | } 70 | ] 71 | 72 | /** 73 | * Listens to hash changes and forwards the new hash to route 74 | */ 75 | this.hashChangeListener = event => this.route(location.hash, false, event.newURL === event.oldURL) 76 | } 77 | 78 | connectedCallback () { 79 | self.addEventListener('hashchange', this.hashChangeListener) 80 | this.route(this.routes.some(route => route.regExp.test(location.hash)) ? location.hash : '#/', true) 81 | } 82 | 83 | disconnectedCallback () { 84 | self.removeEventListener('hashchange', this.hashChangeListener) 85 | } 86 | 87 | /** 88 | * route to the desired hash/domain 89 | * 90 | * @param {string} hash 91 | * @param {boolean} [replace = false] 92 | * @param {boolean} [isUrlEqual = true] 93 | * @return {void | string} 94 | */ 95 | route (hash, replace = false, isUrlEqual = true) { 96 | // escape on route call which is not set by hashchange event and trigger it here, if needed 97 | if (location.hash !== hash) { 98 | if (replace) return location.replace(hash) 99 | return (location.hash = hash) 100 | } 101 | let route 102 | // find the correct route or do nothing 103 | if ((route = this.routes.find(route => route.regExp.test(hash)))) { 104 | // reuse route.component, if already set, otherwise import and define custom element 105 | // @ts-ignore 106 | (route.component ? Promise.resolve(route.component) : import(route.path).then(module => { 107 | // don't define already existing customElements 108 | if (!customElements.get(route.name)) customElements.define(route.name, module.default) 109 | // save it to route object for reuse. grab child if it already exists. 110 | return (route.component = this.children && this.children[0] && this.children[0].tagName === route.name.toUpperCase() ? this.children[0] : document.createElement(route.name)) 111 | })).then(component => { 112 | if (this.shouldComponentRender(route.name, isUrlEqual)) this.render(component) 113 | // @ts-ignore 114 | }).catch(error => console.warn('Router did not find:', route) || error) 115 | } 116 | } 117 | 118 | /** 119 | * evaluates if a render is necessary 120 | * 121 | * @param {string} name 122 | * @param {boolean} [isUrlEqual = true] 123 | * @return {boolean} 124 | */ 125 | shouldComponentRender (name, isUrlEqual = true) { 126 | if (!this.children || !this.children.length) return true 127 | return !isUrlEqual || this.children[0].tagName !== name.toUpperCase() 128 | } 129 | 130 | /** 131 | * renders the page 132 | * 133 | * @param {HTMLElement} component 134 | * @return {void} 135 | */ 136 | render (component) { 137 | // clear previous content 138 | this.innerHTML = '' 139 | this.appendChild(component) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/es/components/controllers/User.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global AbortController */ 5 | /* global CustomEvent */ 6 | /* global fetch */ 7 | 8 | /** 9 | * https://github.com/gothinkster/realworld/tree/master/api#authentication 10 | * 11 | * @typedef {{ email: string, password: string }} loginUserEventDetail 12 | */ 13 | 14 | /** 15 | * https://github.com/gothinkster/realworld/tree/master/api#authentication 16 | * 17 | * @typedef {{ 18 | fetch: Promise 19 | updated?: Boolean 20 | }} UserEventDetail 21 | */ 22 | 23 | /** 24 | * https://github.com/gothinkster/realworld/tree/master/api#authentication 25 | * 26 | * @typedef {{ 27 | fetch: Promise 28 | }} ProfileEventDetail 29 | */ 30 | 31 | import { Environment } from '../../helpers/Environment.js' 32 | 33 | /** 34 | * https://github.com/gothinkster/realworld/tree/master/api#get-article 35 | * As a controller, this component becomes a store and organizes events 36 | * dispatches: 'user' on 'loginUser' 37 | * dispatches: 'user' on 'registerUser' 38 | * dispatches: 'user' on 'updateUser' 39 | * dispatches: 'user' on 'getUser' 40 | * dispatches: 'user' (reject) on 'logoutUser' 41 | * dispatches: 'profile' on 'getProfile' 42 | * 43 | * @export 44 | * @class User 45 | */ 46 | export default class User extends HTMLElement { 47 | constructor () { 48 | super() 49 | 50 | /** 51 | * Used to cancel ongoing, older fetches 52 | * this makes sense, if you only expect one and most recent true result and not multiple 53 | * 54 | * @type {AbortController | null} 55 | */ 56 | this.abortController = this.abortControllerProfile = null 57 | 58 | /** 59 | * Listens to the event name/typeArg: 'loginUser' 60 | * 61 | * @param {CustomEvent & {detail: loginUserEventDetail}} event 62 | */ 63 | this.loginUserListener = event => { 64 | const url = `${Environment.fetchBaseUrl}users/login` 65 | 66 | // reset old AbortController and assign new one 67 | if (this.abortController) this.abortController.abort() 68 | this.abortController = new AbortController() 69 | // answer with event 70 | this.dispatchEvent(new CustomEvent('user', { 71 | /** @type {UserEventDetail} */ 72 | detail: { 73 | fetch: fetch(url, 74 | { 75 | method: 'POST', 76 | ...Environment.fetchHeaders, 77 | body: JSON.stringify(event.detail), 78 | signal: this.abortController.signal 79 | }) 80 | .then(response => { 81 | if (response.status >= 200 && response.status <= 299) return response.json() 82 | throw new Error(response.statusText) 83 | }) 84 | .then(data => { 85 | if (data.errors) throw data.errors 86 | if (data.user) { 87 | this.user = data.user 88 | Environment.token = data.user.token 89 | } 90 | return data.user 91 | }) 92 | }, 93 | bubbles: true, 94 | cancelable: true, 95 | composed: true 96 | })) 97 | } 98 | 99 | this.registerUserListener = event => { 100 | if (!event.detail.user) return 101 | 102 | if (this.abortController) this.abortController.abort() 103 | this.abortController = new AbortController() 104 | 105 | const url = `${Environment.fetchBaseUrl}users` 106 | // answer with event 107 | this.dispatchEvent(new CustomEvent('user', { 108 | /** @type {UserEventDetail} */ 109 | detail: { 110 | fetch: fetch(url, 111 | { 112 | method: 'POST', 113 | ...Environment.fetchHeaders, 114 | body: JSON.stringify(event.detail), 115 | signal: this.abortController.signal 116 | }) 117 | .then(response => { 118 | if (response.status >= 200 && response.status <= 299) return response.json() 119 | throw new Error(response.statusText) 120 | }) 121 | .then(data => { 122 | if (data.errors) throw data.errors 123 | if (data.user) { 124 | this.user = data.user 125 | Environment.token = data.user.token 126 | } 127 | return data.user 128 | }) 129 | }, 130 | bubbles: true, 131 | cancelable: true, 132 | composed: true 133 | })) 134 | } 135 | 136 | this.updateUserListener = event => { 137 | if (!event.detail.user) return 138 | 139 | if (this.abortController) this.abortController.abort() 140 | this.abortController = new AbortController() 141 | 142 | const url = `${Environment.fetchBaseUrl}user` 143 | // answer with event 144 | this.dispatchEvent(new CustomEvent('user', { 145 | /** @type {UserEventDetail} */ 146 | detail: { 147 | fetch: fetch(url, 148 | { 149 | method: 'PUT', 150 | ...Environment.fetchHeaders, 151 | body: JSON.stringify(event.detail), 152 | signal: this.abortController.signal 153 | }) 154 | .then(response => { 155 | if (response.status >= 200 && response.status <= 299) return response.json() 156 | throw new Error(response.statusText) 157 | }) 158 | .then(data => { 159 | if (data.errors) throw data.errors 160 | if (data.user) { 161 | this.user = data.user 162 | } 163 | return data.user 164 | }), 165 | updated: true 166 | }, 167 | bubbles: true, 168 | cancelable: true, 169 | composed: true 170 | })) 171 | } 172 | 173 | this.getUserListener = event => { 174 | if (this.abortController) this.abortController.abort() 175 | this.abortController = new AbortController() 176 | 177 | const url = `${Environment.fetchBaseUrl}user` 178 | // answer with event 179 | this.dispatchEvent(new CustomEvent('user', { 180 | /** @type {UserEventDetail} */ 181 | detail: { 182 | fetch: this.user ? Promise.resolve(this.user) : Environment.token ? fetch(url, 183 | { 184 | method: 'GET', 185 | ...Environment.fetchHeaders, 186 | signal: this.abortController.signal 187 | }) 188 | .then(response => { 189 | if (response.status >= 200 && response.status <= 299) return response.json() 190 | throw new Error(response.statusText) 191 | }) 192 | .then(data => { 193 | if (data.user) { 194 | this.user = data.user 195 | Environment.token = data.user.token 196 | } 197 | return data.user 198 | }) 199 | .catch(error => { 200 | if (error && typeof error.toString === 'function' && !error.toString().includes('aborted')) Environment.token = '' 201 | console.log(`Error@UserFetch: ${error}`) 202 | }) : Promise.reject(new Error('No token found')) 203 | }, 204 | bubbles: true, 205 | cancelable: true, 206 | composed: true 207 | })) 208 | } 209 | 210 | this.logoutUserListener = event => { 211 | Environment.token = '' 212 | this.user = null 213 | this.dispatchEvent(new CustomEvent('user', { 214 | detail: { 215 | fetch: Promise.reject(new Error('User logged out')) 216 | }, 217 | bubbles: true, 218 | cancelable: true, 219 | composed: true 220 | })) 221 | } 222 | 223 | this.getProfileListener = event => { 224 | if (this.abortControllerProfile) this.abortController.abort() 225 | this.abortControllerProfile = new AbortController() 226 | 227 | const url = `${Environment.fetchBaseUrl}profiles/${event.detail.username}` 228 | this.dispatchEvent(new CustomEvent('profile', { 229 | /** @type {ProfileEventDetail} */ 230 | detail: { 231 | fetch: fetch(url, 232 | { 233 | method: 'GET', 234 | ...Environment.fetchHeaders, 235 | signal: this.abortControllerProfile.signal 236 | }) 237 | .then(response => { 238 | if (response.status >= 200 && response.status <= 299) return response.json() 239 | throw new Error(response.statusText) 240 | }) 241 | .then(data => { 242 | return data 243 | }) 244 | .catch(error => { 245 | console.log(`Error@ProfileFetch: ${error}`) 246 | }) 247 | }, 248 | bubbles: true, 249 | cancelable: true, 250 | composed: true 251 | })) 252 | } 253 | } 254 | 255 | connectedCallback () { 256 | this.addEventListener('loginUser', this.loginUserListener) 257 | this.addEventListener('registerUser', this.registerUserListener) 258 | this.addEventListener('updateUser', this.updateUserListener) 259 | this.addEventListener('getUser', this.getUserListener) 260 | this.addEventListener('logoutUser', this.logoutUserListener) 261 | this.addEventListener('getProfile', this.getProfileListener) 262 | } 263 | 264 | disconnectedCallback () { 265 | this.removeEventListener('loginUser', this.loginUserListener) 266 | this.removeEventListener('registerUser', this.registerUserListener) 267 | this.removeEventListener('updateUser', this.updateUserListener) 268 | this.removeEventListener('getUser', this.getUserListener) 269 | this.removeEventListener('logoutUser', this.logoutUserListener) 270 | this.removeEventListener('getProfile', this.getProfileListener) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/es/components/molecules/ArticleFeedToggle.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | /** 7 | * can be used with the two attributes and then ignores user login status by listenToUser and changes labels for profile sites 8 | * else it will behave normal as a sub-navigation on home with Your feed which only works when logged in 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 10 | * As a molecule, this component shall hold Atoms 11 | * 12 | * @export 13 | * @attribute { 14 | * favorited?: string, 15 | * author?: string, 16 | * itsMe?: boolean 17 | * } 18 | * @class ArticleFeedToggle 19 | */ 20 | export default class ArticleFeedToggle extends HTMLElement { 21 | constructor () { 22 | super() 23 | 24 | /** @type {boolean} */ 25 | this.isLoggedIn = false 26 | /** @type {import("../controllers/Article").RequestListArticlesEventDetail} */ 27 | this.query = {} 28 | 29 | /** 30 | * Listens to the event name/typeArg: 'listArticles' 31 | * 32 | * @param {CustomEvent & {detail: import("../controllers/Article").ListArticlesEventDetail}} event 33 | */ 34 | this.listArticlesListener = event => { 35 | this.query = event.detail.query 36 | this.render() 37 | } 38 | 39 | /** 40 | * Listens to the event name/typeArg: 'user' 41 | * 42 | * @param {CustomEvent & {detail: import("../controllers/User.js").UserEventDetail}} event 43 | */ 44 | this.userListener = event => { 45 | event.detail.fetch.then(user => { 46 | if (!this.isLoggedIn) { 47 | this.isLoggedIn = true 48 | this.render() 49 | } 50 | }).catch(error => { 51 | console.log(`Error@UserFetch: ${error}`) 52 | if (this.isLoggedIn) { 53 | this.isLoggedIn = false 54 | this.render() 55 | } 56 | }) 57 | } 58 | 59 | /** 60 | * target href to dispatch a CustomEvent requestListArticles, which will trigger ListArticlePreviews to render with new query 61 | * 62 | * @param {event & {target: HTMLElement}} event 63 | * @return {void | false} 64 | */ 65 | this.clickListener = event => { 66 | if (!event.target) return false 67 | event.preventDefault() 68 | if (event.target.id === 'your-feed' && !event.target.classList.contains('disabled')) { 69 | // get logged in users feed 70 | this.dispatchEvent(new CustomEvent('requestListArticles', { 71 | /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 72 | detail: { 73 | showYourFeed: this.isLoggedIn, 74 | author: this.getAttribute('author') || '' 75 | }, 76 | bubbles: true, 77 | cancelable: true, 78 | composed: true 79 | })) 80 | } else { 81 | // on every link click it will attempt to get articles by tags 82 | this.dispatchEvent(new CustomEvent('requestListArticles', { 83 | /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 84 | detail: { 85 | favorited: this.getAttribute('favorited') || '' 86 | }, 87 | bubbles: true, 88 | cancelable: true, 89 | composed: true 90 | })) 91 | } 92 | } 93 | } 94 | 95 | connectedCallback () { 96 | document.body.addEventListener('listArticles', this.listArticlesListener) 97 | this.addEventListener('click', this.clickListener) 98 | if (this.shouldComponentRender()) this.render() 99 | if (this.listenToUser) { 100 | document.body.addEventListener('user', this.userListener) 101 | this.dispatchEvent(new CustomEvent('getUser', { 102 | bubbles: true, 103 | cancelable: true, 104 | composed: true 105 | })) 106 | } 107 | } 108 | 109 | disconnectedCallback () { 110 | document.body.removeEventListener('listArticles', this.listArticlesListener) 111 | this.removeEventListener('click', this.clickListener) 112 | if (this.listenToUser) document.body.removeEventListener('user', this.userListener) 113 | } 114 | 115 | /** 116 | * evaluates if a render is necessary 117 | * 118 | * @return {boolean} 119 | */ 120 | shouldComponentRender () { 121 | return !this.innerHTML 122 | } 123 | 124 | /** 125 | * renders the header within the body, which is in this case the navbar 126 | * 127 | * @return {void} 128 | */ 129 | render () { 130 | /** 131 | * three list elements 0: Your Feed or Users Posts, 1: Global Feed or Users Favorited Posts, 2: Tags 132 | * @type {0 | 1 | 2} 133 | */ 134 | const active = this.query.tag ? 2 : this.query.showYourFeed || this.query.author ? 0 : !this.query.showYourFeed || this.query.favorited ? 1 : 0 135 | /** 136 | * 0: Your Feed or Users Post disabled? 137 | * @type {boolean} 138 | */ 139 | const disabled = this.listenToUser && !this.isLoggedIn 140 | this.innerHTML = ` 141 | 158 | ` 159 | } 160 | 161 | get listenToUser () { 162 | return !this.getAttribute('author') && !this.getAttribute('favorited') 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/es/components/molecules/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global customElements */ 4 | /* global HTMLElement */ 5 | 6 | /** 7 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 8 | * As a molecule, this component shall hold Atoms 9 | * 10 | * @export 11 | * @class ArticlePreview 12 | */ 13 | export default class ArticlePreview extends HTMLElement { 14 | /** 15 | * customDefine 16 | * 17 | * @param {import("../../helpers/Interfaces.js").SingleArticle | null} [article = null] 18 | */ 19 | constructor (article = null) { 20 | super() 21 | 22 | // allow innerHTML ArticlePreview with article as a string attribute 23 | this.article = article || JSON.parse((this.getAttribute('article') || '').replace(/'/g, '"') || '{}') 24 | } 25 | 26 | connectedCallback () { 27 | this.loadChildComponents() 28 | if (this.shouldComponentRender()) this.render(this.article) 29 | } 30 | 31 | /** 32 | * evaluates if a render is necessary 33 | * 34 | * @return {boolean} 35 | */ 36 | shouldComponentRender () { 37 | return !this.innerHTML 38 | } 39 | 40 | /** 41 | * renders the article 42 | * 43 | * @param {import("../../helpers/Interfaces.js").SingleArticle} [article = this.article] 44 | * @return {void | string} 45 | */ 46 | render (article = this.article) { 47 | if (!article.author || !article.tagList) return (this.innerHTML = '
An error occurred rendering the article-preview!
') 48 | this.innerHTML = ` 49 |
50 | 51 | 52 |

${article.title}

53 |

${article.description}

54 | Read more... 55 |
    56 | ${article.tagList.reduce((tagListStr, tag) => (tagListStr += ` 57 |
  • ${tag}
  • 58 | `), '')} 59 |
60 |
61 |
62 | ` 63 | this.loadChildComponents().then(children => { 64 | /** @type {import("../atoms/ArticleMeta.js").default} */ 65 | // @ts-ignore 66 | const articleMeta = new children[0][1](article) 67 | this.querySelector('.article-meta').replaceWith(articleMeta) 68 | }) 69 | } 70 | 71 | /** 72 | * fetch children when first needed 73 | * 74 | * @returns {Promise<[string, CustomElementConstructor][]>} 75 | */ 76 | loadChildComponents () { 77 | return this.childComponentsPromise || (this.childComponentsPromise = Promise.all([ 78 | import('../atoms/ArticleMeta.js').then( 79 | /** @returns {[string, CustomElementConstructor]} */ 80 | module => ['a-article-meta', module.default] 81 | ) 82 | ]).then(elements => { 83 | elements.forEach(element => { 84 | // don't define already existing customElements 85 | // @ts-ignore 86 | if (!customElements.get(element[0])) customElements.define(...element) 87 | }) 88 | return elements 89 | })) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/es/components/molecules/Comments.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | import { secureImageSrc } from '../../helpers/Utils.js' 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 10 | * As a molecule, this component shall hold Atoms 11 | * 12 | * @export 13 | * @class Comments 14 | */ 15 | export default class Comments extends HTMLElement { 16 | constructor () { 17 | super() 18 | 19 | /** 20 | * Listens to the event name/typeArg: 'comment' 21 | * 22 | * @param {CustomEvent & {detail: import("../controllers/Comments.js").CommentsEventDetail}} event 23 | */ 24 | this.commentListener = event => event.detail.fetch.then(({ comment }) => { 25 | this.insertBefore(this.createComment(comment, false), this.firstCard) 26 | this.formControl.value = '' 27 | }) 28 | 29 | /** 30 | * Listens to the event name/typeArg: 'comments' 31 | * which is returned when adding a comment 32 | * 33 | * @param {CustomEvent & {detail: import("../controllers/Comments.js").CommentEventDetail}} event 34 | */ 35 | this.commentsListener = event => this.render(event.detail.fetch) 36 | 37 | /** 38 | * target href to dispatch a CustomEvent requestListArticles, which will trigger ListArticlePreviews to render with new query 39 | * 40 | * @param {event & {target: HTMLElement}} event 41 | * @return {void | false} 42 | */ 43 | this.clickListener = event => { 44 | let isDeleteIcon = false 45 | if (!event.target || (event.target.tagName !== 'BUTTON' && !(isDeleteIcon = event.target.classList.contains('ion-trash-a')))) return false 46 | event.preventDefault() 47 | if (isDeleteIcon) { 48 | const card = event.target.parentNode.parentNode.parentNode 49 | if (card && card.classList.contains('card')) { 50 | this.dispatchEvent(new CustomEvent('deleteComment', { 51 | /** @type {import("../controllers/Comments.js").DeleteCommentEventDetail} */ 52 | detail: { 53 | id: card.getAttribute('id') 54 | }, 55 | bubbles: true, 56 | cancelable: true, 57 | composed: true 58 | })) 59 | card.remove() 60 | } 61 | } else { 62 | if (this.formControl.value) { 63 | this.dispatchEvent(new CustomEvent('addComment', { 64 | /** @type {import("../controllers/Comments.js").AddCommentsEventDetail} */ 65 | detail: { 66 | body: this.formControl.value 67 | }, 68 | bubbles: true, 69 | cancelable: true, 70 | composed: true 71 | })) 72 | } 73 | } 74 | } 75 | } 76 | 77 | connectedCallback () { 78 | // listen for comments 79 | document.body.addEventListener('comments', this.commentsListener) 80 | document.body.addEventListener('comment', this.commentListener) 81 | this.addEventListener('click', this.clickListener) 82 | // on every connect it will attempt to get newest comments 83 | this.dispatchEvent(new CustomEvent('getComments', { 84 | bubbles: true, 85 | cancelable: true, 86 | composed: true 87 | })) 88 | if (this.shouldComponentRender()) this.render(null) 89 | } 90 | 91 | disconnectedCallback () { 92 | document.body.removeEventListener('comments', this.commentsListener) 93 | document.body.removeEventListener('comment', this.commentListener) 94 | this.removeEventListener('click', this.clickListener) 95 | } 96 | 97 | /** 98 | * evaluates if a render is necessary 99 | * 100 | * @return {boolean} 101 | */ 102 | shouldComponentRender () { 103 | return !this.innerHTML 104 | } 105 | 106 | /** 107 | * renders each received comment 108 | * 109 | * @param {Promise | null} fetchComments 110 | * @return {void} 111 | */ 112 | render (fetchComments) { 113 | this.innerHTML = /* html */ ` 114 |
115 |
116 | 117 |
118 | 124 |
125 | ` 126 | fetchComments && fetchComments.then(({ comments }) => { 127 | this.innerHTML += comments.reduce((commentsStr, comment) => (commentsStr += this.createComment(comment)), '') 128 | }) 129 | } 130 | 131 | /** 132 | * html snipper for comment to be filled 133 | * 134 | * @param {import("../../helpers/Interfaces.js").SingleComment} comment 135 | * @param {boolean} [returnString = true] 136 | * @return {string | Node} 137 | */ 138 | createComment (comment, returnString = true) { 139 | const card = /* html */` 140 |
141 |
142 |

${comment.body}

143 |
144 | 153 |
154 | ` 155 | if (returnString) return card 156 | const div = document.createElement('div') 157 | div.innerHTML = card 158 | return div.children[0] 159 | } 160 | 161 | get formControl () { 162 | return this.querySelector('.form-control') 163 | } 164 | 165 | /** 166 | * returns the first card element 167 | * 168 | * @readonly 169 | * @return {HTMLElement} 170 | */ 171 | get firstCard () { 172 | return this.querySelector('.card:not(.comment-form)') 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/es/components/molecules/Pagination.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | import { Environment } from '../../helpers/Environment.js' 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 10 | * As a molecule, this component shall hold Atoms 11 | * 12 | * @export 13 | * @attribute { 14 | * favorited?: string, 15 | * author?: string 16 | * } 17 | * @class Pagination 18 | */ 19 | export default class Pagination extends HTMLElement { 20 | constructor () { 21 | super() 22 | 23 | // keep a reference with the last received listArticles tag used for new offset requests to avoid loosing tag focus 24 | this.tag = '' 25 | this.author = '' 26 | this.favorited = '' 27 | // avoid loosing feed focus 28 | this.showYourFeed = false 29 | 30 | /** 31 | * Listens to the event name/typeArg: 'listArticles' 32 | * 33 | * @param {CustomEvent & {detail: import("../controllers/Article").ListArticlesEventDetail}} event 34 | */ 35 | this.listArticlesListener = event => this.render(event.detail.fetch, event.detail.query) 36 | 37 | /** 38 | * target href to dispatch a CustomEvent requestListArticles, which will trigger ListArticlePreviews to render with new query 39 | * 40 | * @param {event & {target: HTMLElement}} event 41 | * @return {void | false} 42 | */ 43 | this.clickListener = event => { 44 | if (!event.target || event.target.tagName !== 'A') return false 45 | event.preventDefault() 46 | // on every link click it will attempt to get articles by pagination 47 | this.dispatchEvent(new CustomEvent('requestListArticles', { 48 | /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 49 | detail: { 50 | offset: (Number(event.target.textContent) - 1) * Environment.articlesPerPageLimit, 51 | showYourFeed: this.showYourFeed, 52 | tag: this.tag, 53 | author: this.author, 54 | favorited: this.favorited 55 | }, 56 | bubbles: true, 57 | cancelable: true, 58 | composed: true 59 | })) 60 | } 61 | } 62 | 63 | connectedCallback () { 64 | document.body.addEventListener('listArticles', this.listArticlesListener) 65 | this.addEventListener('click', this.clickListener) 66 | // on every connect it will attempt to get newest articles 67 | this.dispatchEvent(new CustomEvent('requestListArticles', { 68 | /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 69 | detail: { 70 | author: this.getAttribute('author') || '', 71 | favorited: this.getAttribute('favorited') || '' 72 | }, 73 | bubbles: true, 74 | cancelable: true, 75 | composed: true 76 | })) 77 | } 78 | 79 | disconnectedCallback () { 80 | document.body.removeEventListener('listArticles', this.listArticlesListener) 81 | this.removeEventListener('click', this.clickListener) 82 | } 83 | 84 | /** 85 | * renders the articles pagination 86 | * 87 | * @param {Promise} fetchMultipleArticles 88 | * @param {import("../controllers/Article").RequestListArticlesEventDetail} query 89 | * @return {void} 90 | */ 91 | render (fetchMultipleArticles, query) { 92 | fetchMultipleArticles.then(multipleArticles => { 93 | if (!multipleArticles || !multipleArticles.articlesCount || !multipleArticles.articles) { 94 | this.innerHTML = '' 95 | } else { 96 | // save the tag for further pagination requests 97 | this.tag = query.tag || '' 98 | this.author = query.author || '' 99 | this.favorited = query.favorited || '' 100 | this.showYourFeed = query.showYourFeed || false 101 | const offset = query.offset || 0 102 | let pageItems = '' 103 | for (let i = 0; i < Math.ceil(multipleArticles.articlesCount / Environment.articlesPerPageLimit); ++i) { 104 | pageItems += `
  • ${i + 1}
  • ` 105 | } 106 | if (pageItems) { 107 | this.innerHTML = ` 108 | 113 | ` 114 | } 115 | } 116 | // @ts-ignore 117 | }).catch(error => console.warn(error)) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/es/components/molecules/TagList.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | /** 7 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 8 | * As a molecule, this component shall hold Atoms 9 | * 10 | * @export 11 | * @class TagList 12 | */ 13 | export default class TagList extends HTMLElement { 14 | constructor () { 15 | super() 16 | 17 | /** 18 | * Listens to the event name/typeArg: 'tags' 19 | * 20 | * @param {CustomEvent & {detail: import("../controllers/GetTags.js").TagsEventDetail}} event 21 | */ 22 | this.tagsListener = event => this.render(event.detail.fetch) 23 | 24 | /** 25 | * target href to dispatch a CustomEvent requestListArticles, which will trigger ListArticlePreviews to render with new query 26 | * 27 | * @param {event & {target: HTMLElement}} event 28 | * @return {void | false} 29 | */ 30 | this.clickListener = event => { 31 | if (!event.target || event.target.tagName !== 'A') return false 32 | event.preventDefault() 33 | // on every link click it will attempt to get articles by tags 34 | this.dispatchEvent(new CustomEvent('requestListArticles', { 35 | /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 36 | detail: { tag: event.target.textContent }, 37 | bubbles: true, 38 | cancelable: true, 39 | composed: true 40 | })) 41 | } 42 | } 43 | 44 | connectedCallback () { 45 | // listen for tags 46 | document.body.addEventListener('tags', this.tagsListener) 47 | this.addEventListener('click', this.clickListener) 48 | // on every connect it will attempt to get newest tags 49 | this.dispatchEvent(new CustomEvent('getTags', { 50 | bubbles: true, 51 | cancelable: true, 52 | composed: true 53 | })) 54 | } 55 | 56 | disconnectedCallback () { 57 | document.body.removeEventListener('tags', this.tagsListener) 58 | this.removeEventListener('click', this.clickListener) 59 | } 60 | 61 | /** 62 | * renders each received tag 63 | * 64 | * @param {Promise} fetchTags 65 | * @return {void} 66 | */ 67 | render (fetchTags) { 68 | fetchTags.then(tag => { 69 | if (!tag || !tag.tags || !tag.tags.length) tag = { tags: ['No tags are here... yet.'] } 70 | this.innerHTML = `
    ${tag.tags.map(tag => `${tag}`).join('')}
    ` 71 | // @ts-ignore 72 | }).catch(error => (this.innerHTML = console.warn(error) || (error && typeof error.toString === 'function' && error.toString().includes('aborted') ? '
    Loading...
    ' : '
    An error occurred fetching the tags!
    '))) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/es/components/organisms/Footer.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | 5 | /** 6 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#footer 7 | * As an organism, this component shall hold molecules and/or atoms 8 | * 9 | * @export 10 | * @class Footer 11 | */ 12 | export default class Footer extends HTMLElement { 13 | connectedCallback () { 14 | if (this.shouldComponentRender()) this.render() 15 | } 16 | 17 | /** 18 | * evaluates if a render is necessary 19 | * 20 | * @return {boolean} 21 | */ 22 | shouldComponentRender () { 23 | return !this.innerHTML 24 | } 25 | 26 | /** 27 | * renders the footer 28 | * 29 | * @return {void} 30 | */ 31 | render () { 32 | this.innerHTML = ` 33 |
    34 |
    35 | conduit 36 | 37 | An interactive learning project from Thinkster. Code & design licensed under MIT. 38 | 39 |
    40 |
    41 | ` 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/es/components/organisms/Header.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | 6 | import { secureImageSrc } from '../../helpers/Utils.js' 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#header 10 | * As an organism, this component shall hold molecules and/or atoms 11 | * 12 | * @export 13 | * @class Header 14 | */ 15 | export default class Header extends HTMLElement { 16 | constructor () { 17 | super() 18 | 19 | /** 20 | * Listens to the event name/typeArg: 'user' 21 | * 22 | * @param {CustomEvent & {detail: import("../controllers/User.js").UserEventDetail}} event 23 | */ 24 | this.userListener = event => { 25 | event.detail.fetch.then(user => { 26 | if (this.shouldComponentRender(user)) this.render(user) 27 | this.user = user 28 | }).catch(error => { 29 | console.log(`Error@UserFetch: ${error}`) 30 | if (this.shouldComponentRender(null)) this.render(null) 31 | this.user = null 32 | }) 33 | } 34 | } 35 | 36 | connectedCallback () { 37 | this.user = undefined 38 | 39 | this.render() 40 | document.body.addEventListener('user', this.userListener) 41 | this.dispatchEvent(new CustomEvent('getUser', { 42 | bubbles: true, 43 | cancelable: true, 44 | composed: true 45 | })) 46 | } 47 | 48 | disconnectedCallback () { 49 | document.body.removeEventListener('user', this.userListener) 50 | } 51 | 52 | /** 53 | * evaluates if a render is necessary 54 | * 55 | * @param {import("../../helpers/Interfaces.js").User} user 56 | * @return {boolean} 57 | */ 58 | shouldComponentRender (user) { 59 | return this.user !== user 60 | } 61 | 62 | /** 63 | * renders the header within the body, which is in this case the navbar 64 | * 65 | * @param {import("../../helpers/Interfaces.js").User} [user = undefined] 66 | * @return {void} 67 | */ 68 | render (user) { 69 | this.innerHTML = /* html */ ` 70 | 106 | ` 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/es/components/organisms/ListArticlePreviews.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global customElements */ 4 | /* global HTMLElement */ 5 | 6 | /** 7 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#ListArticles 8 | * As an organism, this component shall hold molecules and/or atoms 9 | * this organism always renders new when connected to keep most recent and does not need shouldComponentRender 10 | * 11 | * @export 12 | * @class ListArticlePreviews 13 | */ 14 | export default class ListArticlePreviews extends HTMLElement { 15 | constructor () { 16 | super() 17 | 18 | /** 19 | * Listens to the event name/typeArg: 'listArticles' 20 | * 21 | * @param {CustomEvent & {detail: import("../controllers/Article.js").ListArticlesEventDetail}} event 22 | */ 23 | this.listArticlesListener = event => this.render(event.detail.fetch) 24 | } 25 | 26 | connectedCallback () { 27 | // listen for articles 28 | document.body.addEventListener('listArticles', this.listArticlesListener) 29 | // is not needed since the molecules/Pagination.js, which is located after this, does the same request 30 | // it is possible to have multiple components request the same data on connectCallback but then it ether should expect a private response by a promise with caching or tolerate abort 31 | // on every connect it will attempt to get newest articles 32 | // this.dispatchEvent(new CustomEvent('requestListArticles', { 33 | // /** @type {import("../controllers/Article.js").RequestListArticlesEventDetail} */ 34 | // detail: {}, 35 | // bubbles: true, 36 | // cancelable: true, 37 | // composed: true 38 | // })) 39 | } 40 | 41 | disconnectedCallback () { 42 | document.body.removeEventListener('listArticles', this.listArticlesListener) 43 | } 44 | 45 | /** 46 | * renders each received article 47 | * 48 | * @param {Promise} fetchMultipleArticles 49 | * @return {void} 50 | */ 51 | render (fetchMultipleArticles) { 52 | Promise.all([fetchMultipleArticles, this.loadChildComponents()]).then(result => { 53 | const [multipleArticles, children] = result 54 | if (!multipleArticles || !multipleArticles.articles || !multipleArticles.articles.length) { 55 | this.innerHTML = '
    No articles are here... yet.
    ' 56 | } else { 57 | this.innerHTML = '' 58 | multipleArticles.articles.forEach(article => { 59 | /** @type {import("../molecules/ArticlePreview.js").default & any} */ 60 | // @ts-ignore 61 | const articlePreview = new children[0][1](article) 62 | this.appendChild(articlePreview) 63 | }) 64 | if (!this.getAttribute('no-scroll')) this.scrollToEl(this) 65 | } 66 | // @ts-ignore 67 | }).catch(error => (this.innerHTML = console.warn(error) || (error && typeof error.toString === 'function' && error.toString().includes('aborted') ? '
    Loading...
    ' : '
    An error occurred fetching the articles!
    '))) 68 | } 69 | 70 | /** 71 | * fetch children when first needed 72 | * 73 | * @returns {Promise<[string, CustomElementConstructor][]>} 74 | */ 75 | loadChildComponents () { 76 | return this.childComponentsPromise || (this.childComponentsPromise = Promise.all([ 77 | import('../molecules/ArticlePreview.js').then( 78 | /** @returns {[string, CustomElementConstructor]} */ 79 | module => ['m-article-preview', module.default] 80 | ) 81 | ]).then(elements => { 82 | elements.forEach(element => { 83 | // don't define already existing customElements 84 | // @ts-ignore 85 | if (!customElements.get(element[0])) customElements.define(...element) 86 | }) 87 | return elements 88 | })) 89 | } 90 | 91 | // inspired from: https://github.com/Weedshaker/PeerWebSite/blob/master/JavaScript/js/Player/Player.js 92 | scrollToEl (el) { 93 | const rect = el.getBoundingClientRect() 94 | // check if the element is outside the viewport, otherwise don't scroll 95 | if (rect && rect.top < 0) el.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth' }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/es/components/pages/Article.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global customElements */ 5 | /* global CustomEvent */ 6 | /* global self */ 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#article 10 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 11 | * 12 | * @export 13 | * @class Article 14 | */ 15 | export default class Article extends HTMLElement { 16 | constructor () { 17 | super() 18 | 19 | /** 20 | * Listens to the event name/typeArg: 'article' 21 | * 22 | * @param {CustomEvent & {detail: import("../controllers/Article.js").ArticleEventDetail}} event 23 | */ 24 | this.articleListener = event => this.render(event.detail.fetch) 25 | 26 | /** 27 | * Listens to the event name/typeArg: 'user' 28 | * 29 | * @param {CustomEvent & {detail: import("../controllers/User.js").UserEventDetail}} event 30 | */ 31 | this.userListener = event => { 32 | event.detail.fetch.then(user => { 33 | if (this.shouldComponentRender(user)) this.render(undefined, user) 34 | }).catch(error => { 35 | if (this.shouldComponentRender(null)) this.render(undefined, null) 36 | console.log(`Error@UserFetch: ${error}`) 37 | }) 38 | } 39 | } 40 | 41 | connectedCallback () { 42 | this.user = undefined 43 | this.fetchSingleArticle = undefined 44 | 45 | // listen for articles 46 | document.body.addEventListener('article', this.articleListener) 47 | // on every connect it will attempt to get newest articles 48 | this.dispatchEvent(new CustomEvent('requestArticle', { 49 | /** @type {import("../controllers/Article.js").RequestArticleEventDetail} */ 50 | detail: {}, // slug gets decided at Article.js controller, could also be done by request event to router 51 | bubbles: true, 52 | cancelable: true, 53 | composed: true 54 | })) 55 | document.body.addEventListener('user', this.userListener) 56 | this.dispatchEvent(new CustomEvent('getUser', { 57 | bubbles: true, 58 | cancelable: true, 59 | composed: true 60 | })) 61 | // show initial loading because there is no connectCallback render execution 62 | if (!this.innerHTML) this.innerHTML = /* html */'
    ' 63 | } 64 | 65 | disconnectedCallback () { 66 | document.body.removeEventListener('article', this.articleListener) 67 | document.body.removeEventListener('user', this.userListener) 68 | // looks nicer when cleared 69 | this.innerHTML = '' 70 | } 71 | 72 | /** 73 | * evaluates if a render is necessary 74 | * 75 | * @param {import("../../helpers/Interfaces.js").User} [user = this.user] 76 | * @return {boolean} 77 | */ 78 | shouldComponentRender (user = this.user) { 79 | return user !== this.user 80 | } 81 | 82 | /** 83 | * renders the article 84 | * 85 | * @param {Promise<{article: import("../../helpers/Interfaces.js").SingleArticle}>} [fetchSingleArticle = this.fetchSingleArticle] 86 | * @param {import("../../helpers/Interfaces.js").User} [user = this.user] 87 | * @return {void} 88 | */ 89 | render (fetchSingleArticle = this.fetchSingleArticle, user = this.user) { 90 | if (user !== undefined) this.user = user 91 | if (fetchSingleArticle) { 92 | this.fetchSingleArticle = fetchSingleArticle 93 | Promise.all([fetchSingleArticle, this.loadDependency(), this.loadChildComponents()]).then(result => { 94 | const [singleArticle, markdownit, children] = result 95 | const article = singleArticle.article 96 | if (!article || !article.author || !article.tagList) return (this.innerHTML = '
    ') 97 | article.author = Object.assign(article.author, { self: user && user.username === article.author.username }) 98 | this.innerHTML = ` 99 |
    100 | 101 | 110 | 111 |
    112 | 113 |
    114 |
    115 |
    ${markdownit.render(article.body)}
    116 |
      117 | ${article.tagList.reduce((tagListStr, tag) => (tagListStr += ` 118 |
    • ${tag}
    • 119 | `), '')} 120 |
    121 |
    122 |
    123 | 124 |
    125 | 126 |
    127 | 128 |
    129 | 130 |
    131 | 132 |
    133 | 134 | ${user 135 | ? ` 136 | 137 | ` 138 | : '
    Sign in or sign up to add comments on this article.
    '} 139 |
    140 |
    141 |
    142 |
    143 | ` 144 | /** @type {import("../atoms/ArticleMeta.js").default} */ 145 | // @ts-ignore 146 | this.querySelectorAll('.article-meta').forEach(node => { 147 | const articleMeta = new children[0][1](article, true) 148 | node.replaceWith(articleMeta) 149 | }) 150 | // @ts-ignore 151 | }).catch(error => (this.innerHTML = console.warn(error) || (error && typeof error.toString === 'function' && error.toString().includes('aborted') ? '
    ' : '
    '))) 152 | } 153 | } 154 | 155 | /** 156 | * fetch children when first needed 157 | * 158 | * @returns {Promise<[string, CustomElementConstructor][]>} 159 | */ 160 | loadChildComponents () { 161 | return this.childComponentsPromise || (this.childComponentsPromise = Promise.all([ 162 | import('../atoms/ArticleMeta.js').then( 163 | /** @returns {[string, CustomElementConstructor]} */ 164 | module => ['a-article-meta', module.default] 165 | ), 166 | import('../controllers/Comments.js').then( 167 | /** @returns {[string, CustomElementConstructor]} */ 168 | module => ['c-comments', module.default] 169 | ), 170 | import('../molecules/Comments.js').then( 171 | /** @returns {[string, CustomElementConstructor]} */ 172 | module => ['m-comments', module.default] 173 | ) 174 | ]).then(elements => { 175 | elements.forEach(element => { 176 | // don't define already existing customElements 177 | // @ts-ignore 178 | if (!customElements.get(element[0])) customElements.define(...element) 179 | }) 180 | return elements 181 | })) 182 | } 183 | 184 | /** 185 | * fetch dependency 186 | * 187 | * @returns {Promise<{render:(string)=>string}>} 188 | */ 189 | loadDependency () { 190 | return this.dependencyPromise || (this.dependencyPromise = new Promise(resolve => { 191 | // needs markdown 192 | if ('markdownit' in self === false) { 193 | const script = document.createElement('script') 194 | // https://github.com/markdown-it/markdown-it 195 | script.src = 'https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js' 196 | // @ts-ignore 197 | script.onload = () => resolve(new self.markdownit()) // eslint-disable-line 198 | document.head.appendChild(script) 199 | } else { 200 | // @ts-ignore 201 | resolve(new self.markdownit()) // eslint-disable-line 202 | } 203 | })) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/es/components/pages/Editor.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global CustomEvent */ 5 | 6 | import { Environment } from '../../helpers/Environment.js' 7 | 8 | /** 9 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#createedit-article 10 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 11 | * 12 | * @export 13 | * @class Editor 14 | */ 15 | export default class Editor extends HTMLElement { 16 | constructor () { 17 | super() 18 | 19 | this.publishListener = event => { 20 | if (!event.target || event.target.tagName !== 'BUTTON') return false 21 | event.preventDefault() 22 | 23 | const body = { 24 | article: { 25 | title: this.titleField.value, 26 | description: this.descriptionField.value, 27 | body: this.bodyField.value, 28 | tagList: this.tagField.value.split(/(?:,| )+/) 29 | } 30 | } 31 | 32 | this.dispatchEvent(new CustomEvent('postArticle', { 33 | detail: { 34 | body: body, 35 | slug: Environment.slug 36 | }, 37 | bubbles: true, 38 | cancelable: true, 39 | composed: true 40 | })) 41 | } 42 | 43 | this.articleListener = event => event.detail.fetch.then(response => { 44 | if (response) this.render(response.article) 45 | }).catch(error => (this.errorMessages = error)) 46 | } 47 | 48 | connectedCallback () { 49 | document.body.addEventListener('article', this.articleListener) 50 | 51 | if (Environment.slug) { 52 | this.dispatchEvent(new CustomEvent('requestArticle', { 53 | /** @type {import("../controllers/Article.js").RequestArticleEventDetail} */ 54 | detail: { 55 | slug: Environment.slug 56 | }, // slug gets decided at Article.js controller, could also be done by request event to router 57 | bubbles: true, 58 | cancelable: true, 59 | composed: true 60 | })) 61 | } 62 | this.render() 63 | this.addEventListener('click', this.publishListener) 64 | } 65 | 66 | disconnectedCallback () { 67 | this.removeEventListener('click', this.publishListener) 68 | document.body.removeEventListener('article', this.articleListener) 69 | } 70 | 71 | /** 72 | * renders the Editor 73 | * 74 | * @param {import("../../helpers/Interfaces.js").SingleArticle} [article=null] 75 | * @return {void} 76 | */ 77 | render (article) { 78 | this.innerHTML = /* html */` 79 |
    80 |
    81 |
    82 | 83 | 84 |
    85 |
      86 |
      87 |
      88 |
      89 | 90 |
      91 |
      92 | 93 |
      94 |
      95 | 96 |
      97 |
      98 |
      99 |
      100 | 103 |
      104 |
      105 |
      106 | 107 |
      108 |
      109 |
      110 | ` 111 | } 112 | 113 | /** 114 | * @return {HTMLInputElement} 115 | */ 116 | get titleField () { 117 | return this.querySelector('input[name=title]') 118 | } 119 | 120 | /** 121 | * @return {HTMLInputElement} 122 | */ 123 | get descriptionField () { 124 | return this.querySelector('input[name=description]') 125 | } 126 | 127 | /** 128 | * @return {HTMLTextAreaElement} 129 | * 130 | */ 131 | get bodyField () { 132 | return this.querySelector('textarea[name=body]') 133 | } 134 | 135 | /** 136 | * @return {HTMLInputElement} 137 | */ 138 | get tagField () { 139 | return this.querySelector('input[name=tagList]') 140 | } 141 | 142 | set errorMessages (errors) { 143 | const ul = this.querySelector('.error-messages') 144 | if (ul && typeof errors === 'object') { 145 | ul.innerHTML = '' 146 | for (const key in errors) { 147 | const li = document.createElement('li') 148 | li.textContent = `${key}: ${errors[key].reduce((acc, curr) => `${acc}${acc ? ' | ' : ''}${curr}`, '')}` 149 | ul.appendChild(li) 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/es/components/pages/Home.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global customElements */ 5 | 6 | /** 7 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 8 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 9 | * 10 | * @export 11 | * @class Home 12 | */ 13 | export default class Home extends HTMLElement { 14 | connectedCallback () { 15 | this.loadChildComponents() 16 | if (this.shouldComponentRender()) this.render() 17 | } 18 | 19 | /** 20 | * evaluates if a render is necessary 21 | * 22 | * @return {boolean} 23 | */ 24 | shouldComponentRender () { 25 | return !this.innerHTML 26 | } 27 | 28 | /** 29 | * renders the footer 30 | * 31 | * @return {void} 32 | */ 33 | render () { 34 | this.innerHTML = ` 35 |
      36 | 42 | 43 |
      44 |
      45 | 46 |
      47 | 48 | 49 |
      Loading...
      50 | 51 | 52 | 53 |
      54 | 55 |
      56 | 63 |
      64 | 65 |
      66 |
      67 | 68 |
      69 | ` 70 | } 71 | 72 | /** 73 | * fetch children when first needed 74 | * 75 | * @returns {Promise<[string, CustomElementConstructor][]>} 76 | */ 77 | loadChildComponents () { 78 | return this.childComponentsPromise || (this.childComponentsPromise = Promise.all([ 79 | import('../controllers/GetTags.js').then( 80 | /** @returns {[string, CustomElementConstructor]} */ 81 | module => ['c-get-tags', module.default] 82 | ), 83 | import('../molecules/ArticleFeedToggle.js').then( 84 | /** @returns {[string, CustomElementConstructor]} */ 85 | module => ['m-article-feed-toggle', module.default] 86 | ), 87 | import('../organisms/ListArticlePreviews.js').then( 88 | /** @returns {[string, CustomElementConstructor]} */ 89 | module => ['o-list-article-previews', module.default] 90 | ), 91 | import('../molecules/TagList.js').then( 92 | /** @returns {[string, CustomElementConstructor]} */ 93 | module => ['m-tag-list', module.default] 94 | ), 95 | import('../molecules/Pagination.js').then( 96 | /** @returns {[string, CustomElementConstructor]} */ 97 | module => ['m-pagination', module.default] 98 | ) 99 | ]).then(elements => { 100 | elements.forEach(element => { 101 | // don't define already existing customElements 102 | // @ts-ignore 103 | if (!customElements.get(element[0])) customElements.define(...element) 104 | }) 105 | return elements 106 | })) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/es/components/pages/Login.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | /* global self */ 6 | 7 | /** 8 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 9 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 10 | * 11 | * @export 12 | * @class Login 13 | */ 14 | export default class Login extends HTMLElement { 15 | constructor () { 16 | super() 17 | 18 | this.submitListener = event => { 19 | event.preventDefault() 20 | 21 | this.dispatchEvent(new CustomEvent('loginUser', { 22 | detail: { 23 | /** @type {import("../../helpers/Interfaces.js").Authentication} */ 24 | user: { 25 | email: this.emailField.value, 26 | password: this.passwordField.value 27 | } 28 | }, 29 | bubbles: true, 30 | cancelable: true, 31 | composed: true 32 | })) 33 | } 34 | 35 | /** 36 | * Listens to the event name/typeArg: 'user' 37 | * 38 | * @param {CustomEvent & {detail: import("../controllers/User.js").UserEventDetail}} event 39 | */ 40 | this.userListener = event => { 41 | event.detail.fetch.then(user => (self.location.hash = '#/')).catch(error => (this.errorMessages = error)) 42 | } 43 | } 44 | 45 | connectedCallback () { 46 | if (this.shouldComponentRender()) this.render() 47 | this.querySelector('form').addEventListener('submit', this.submitListener) 48 | document.body.addEventListener('user', this.userListener) 49 | } 50 | 51 | disconnectedCallback () { 52 | this.querySelector('form').removeEventListener('submit', this.submitListener) 53 | document.body.removeEventListener('user', this.userListener) 54 | } 55 | 56 | /** 57 | * evaluates if a render is necessary 58 | * 59 | * @return {boolean} 60 | */ 61 | shouldComponentRender () { 62 | return !this.innerHTML 63 | } 64 | 65 | /** 66 | * renders the footer 67 | * 68 | * @return {void} 69 | */ 70 | render () { 71 | this.innerHTML = /* html */` 72 |
      73 |
      74 |
      75 | 76 |
      77 |

      Sign in

      78 |

      79 | Need an account? 80 |

      81 | 82 |
        83 | 84 |
        85 |
        86 | 87 |
        88 |
        89 | 90 |
        91 | 94 |
        95 | 96 |
        97 |
        98 |
        99 |
        100 | ` 101 | } 102 | 103 | /** 104 | * @return {HTMLInputElement} 105 | * 106 | */ 107 | get passwordField () { 108 | return document.querySelector('input[type=password]') 109 | } 110 | 111 | /** 112 | * @return {HTMLInputElement} 113 | * 114 | */ 115 | get emailField () { 116 | return document.querySelector('input[type=email]') 117 | } 118 | 119 | get errorMessages () { 120 | return this.querySelector('.error-messages') 121 | } 122 | 123 | set errorMessages (errors) { 124 | const ul = this.querySelector('.error-messages') 125 | if (ul && typeof errors === 'object') { 126 | ul.innerHTML = '' 127 | for (const key in errors) { 128 | const li = document.createElement('li') 129 | li.textContent = `${key}: ${errors[key].reduce((acc, curr) => `${acc}${acc ? ' | ' : ''}${curr}`, '')}` 130 | ul.appendChild(li) 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/es/components/pages/Profile.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { Environment } from '../../helpers/Environment.js' 4 | import { secureImageSrc } from '../../helpers/Utils.js' 5 | 6 | /* global HTMLElement */ 7 | /* global customElements */ 8 | /* global CustomEvent */ 9 | 10 | /** 11 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#article 12 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 13 | * 14 | * @export 15 | * @class Article 16 | */ 17 | export default class Article extends HTMLElement { 18 | constructor () { 19 | super() 20 | 21 | this.loading = /* html */'
        ' 22 | 23 | this.profileListener = event => { 24 | event.detail.fetch.then(({ profile }) => { if (this.shouldComponentRender(profile, undefined)) this.render(profile, undefined) }).catch(error => { 25 | if (this.shouldComponentRender(null, undefined)) this.render(null, undefined) 26 | console.log(`Error@ProfileFetch: ${error}`) 27 | }) 28 | } 29 | 30 | this.userListener = event => { 31 | event.detail.fetch.then(user => { if (this.shouldComponentRender(undefined, user)) this.render(undefined, user) }).catch(error => { 32 | if (this.shouldComponentRender(undefined, null)) this.render(undefined, null) 33 | console.log(`Error@UserFetch: ${error}`) 34 | }) 35 | } 36 | 37 | this.followBtnListener = event => { 38 | if (!event.target) return false 39 | event.preventDefault() 40 | this.dispatchEvent(new CustomEvent('followUser', { 41 | /** @type {import("../controllers/MetaActions.js").SetFavoriteEventDetail} */ 42 | detail: { 43 | profile: this.profile 44 | }, 45 | bubbles: true, 46 | cancelable: true, 47 | composed: true 48 | })) 49 | } 50 | } 51 | 52 | connectedCallback () { 53 | this.user = undefined 54 | this.profile = undefined 55 | 56 | this.loadChildComponents() 57 | document.body.addEventListener('profile', this.profileListener) 58 | document.body.addEventListener('user', this.userListener) 59 | 60 | this.dispatchEvent(new CustomEvent('getProfile', { 61 | detail: { 62 | username: Environment.urlEnding 63 | }, 64 | bubbles: true, 65 | cancelable: true, 66 | composed: true 67 | })) 68 | 69 | this.dispatchEvent(new CustomEvent('getUser', { 70 | bubbles: true, 71 | cancelable: true, 72 | composed: true 73 | })) 74 | // show initial loading because there is no connectCallback render execution 75 | if (!this.innerHTML) this.innerHTML = this.loading 76 | } 77 | 78 | disconnectedCallback () { 79 | document.body.removeEventListener('profile', this.profileListener) 80 | document.body.removeEventListener('user', this.userListener) 81 | if (this.btnFollow) this.btnFollow.removeEventListener('click', this.followBtnListener) 82 | // looks nicer when cleared 83 | this.innerHTML = '' 84 | } 85 | 86 | /** 87 | * evaluates if a render is necessary 88 | * 89 | * @param {import("../../helpers/Interfaces.js").Profile} [profile = this.profile] 90 | * @param {import("../../helpers/Interfaces.js").User} [user = this.user] 91 | * @return {boolean} 92 | */ 93 | shouldComponentRender (profile = this.profile, user = this.user) { 94 | return profile !== this.profile || user !== this.user 95 | } 96 | 97 | /** 98 | * renders the profile 99 | * 100 | * @param {import("../../helpers/Interfaces.js").Profile} [profile = this.profile] 101 | * @param {import("../../helpers/Interfaces.js").User} [user = this.user] 102 | * @return {any} 103 | */ 104 | render (profile = this.profile, user = this.user) { 105 | if (user !== undefined) this.user = user 106 | if (profile !== undefined) this.profile = profile 107 | if (!profile) return (this.innerHTML = profile === null ? '
        ' : this.loading) 108 | this.innerHTML = /* html */` 109 |
        110 | 111 | 137 | 138 |
        139 |
        140 | 141 |
        142 | 143 |
        144 | 145 |
        146 | 147 |
        Loading...
        148 | 149 | 150 | 151 |
        152 | 153 |
        154 |
        155 | 156 |
        157 | ` 158 | if (this.btnFollow) this.btnFollow.addEventListener('click', this.followBtnListener) 159 | } 160 | 161 | /** 162 | * fetch children when first needed 163 | * 164 | * @returns {Promise<[string, CustomElementConstructor][]>} 165 | */ 166 | loadChildComponents () { 167 | return this.childComponentsPromise || (this.childComponentsPromise = Promise.all([ 168 | import('../molecules/ArticleFeedToggle.js').then( 169 | /** @returns {[string, CustomElementConstructor]} */ 170 | module => ['m-article-feed-toggle', module.default] 171 | ), 172 | import('../organisms/ListArticlePreviews.js').then( 173 | /** @returns {[string, CustomElementConstructor]} */ 174 | module => ['o-list-article-previews', module.default] 175 | ), 176 | import('../molecules/Pagination.js').then( 177 | /** @returns {[string, CustomElementConstructor]} */ 178 | module => ['m-pagination', module.default] 179 | ) 180 | ]).then(elements => { 181 | elements.forEach(element => { 182 | // don't define already existing customElements 183 | // @ts-ignore 184 | if (!customElements.get(element[0])) customElements.define(...element) 185 | }) 186 | return elements 187 | })) 188 | } 189 | 190 | get btnFollow () { 191 | return this.querySelector('button[name=follow]') 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/es/components/pages/Register.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global CustomEvent */ 4 | /* global HTMLElement */ 5 | /* global self */ 6 | 7 | /** 8 | * https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#home 9 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 10 | * 11 | * @export 12 | * @class Register 13 | */ 14 | export default class Register extends HTMLElement { 15 | constructor () { 16 | super() 17 | 18 | this.submitListener = (e) => { 19 | if (this.registerForm.checkValidity()) { 20 | e.preventDefault() 21 | 22 | this.dispatchEvent(new CustomEvent('registerUser', { 23 | detail: { 24 | /** @type {import("../../helpers/Interfaces.js").Registration} */ 25 | user: { 26 | username: this.userField.value, 27 | email: this.emailField.value, 28 | password: this.passwordField.value 29 | } 30 | }, 31 | bubbles: true, 32 | cancelable: true, 33 | composed: true 34 | })) 35 | } 36 | } 37 | 38 | /** 39 | * Listens to the event name/typeArg: 'user' 40 | * 41 | * @param {CustomEvent & {detail: import("../controllers/User.js").UserEventDetail}} event 42 | */ 43 | this.userListener = event => { 44 | event.detail.fetch.then(user => (self.location.hash = '#/')).catch(error => (this.errorMessages = error)) 45 | } 46 | } 47 | 48 | connectedCallback () { 49 | if (this.shouldComponentRender()) this.render() 50 | this.registerForm.addEventListener('submit', this.submitListener) 51 | document.body.addEventListener('user', this.userListener) 52 | } 53 | 54 | disconnectedCallback () { 55 | this.registerForm.removeEventListener('submit', this.submitListener) 56 | document.body.removeEventListener('user', this.userListener) 57 | } 58 | 59 | /** 60 | * evaluates if a render is necessary 61 | * 62 | * @return {boolean} 63 | */ 64 | shouldComponentRender () { 65 | return !this.innerHTML 66 | } 67 | 68 | /** 69 | * renders the footer 70 | * 71 | * @return {void} 72 | */ 73 | render () { 74 | this.innerHTML = /* html */` 75 |
        76 |
        77 |
        78 | 79 |
        80 |

        Sign up

        81 |

        82 | Have an account? 83 |

        84 | 85 |
          86 | 87 |
          88 |
          89 | 90 |
          91 |
          92 | 93 |
          94 |
          95 | 96 |
          97 | 100 |
          101 | 102 |
          103 |
          104 |
          105 |
          106 | ` 107 | } 108 | 109 | get registerForm () { 110 | return this.querySelector('form') 111 | } 112 | 113 | /** 114 | * @return {HTMLInputElement} 115 | */ 116 | get userField () { 117 | return this.querySelector('input[name="username"]') 118 | } 119 | 120 | /** 121 | * @return {HTMLInputElement} 122 | */ 123 | get emailField () { 124 | return this.querySelector('input[name="email"]') 125 | } 126 | 127 | /** 128 | * @return {HTMLInputElement} 129 | */ 130 | get passwordField () { 131 | return document.querySelector('input[name="password"]') 132 | } 133 | 134 | get errorMessages () { 135 | return this.querySelector('.error-messages') 136 | } 137 | 138 | set errorMessages (errors) { 139 | const ul = this.querySelector('.error-messages') 140 | if (ul && typeof errors === 'object') { 141 | ul.innerHTML = '' 142 | for (const key in errors) { 143 | const li = document.createElement('li') 144 | li.textContent = `${key}: ${errors[key].reduce((acc, curr) => `${acc}${acc ? ' | ' : ''}${curr}`, '')}` 145 | ul.appendChild(li) 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/es/components/pages/Settings.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global HTMLElement */ 4 | /* global CustomEvent */ 5 | /* global self */ 6 | 7 | /** 8 | * https://github.com/mits-gossau/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#settings 9 | * As a page, this component becomes a domain dependent container and shall hold organisms, molecules and/or atoms 10 | * 11 | * @export 12 | * @class Settings 13 | */ 14 | export default class Settings extends HTMLElement { 15 | constructor () { 16 | super() 17 | 18 | this.updateListener = event => { 19 | event.preventDefault() 20 | 21 | const user = { 22 | username: this.userField.value, 23 | email: this.emailField.value, 24 | bio: this.bioField.value, 25 | image: this.imageField.value 26 | } 27 | 28 | if (this.passwordField.value) Object.assign(user, { password: this.passwordField.value }) 29 | 30 | this.dispatchEvent(new CustomEvent('updateUser', { 31 | detail: { 32 | /** @type {import("../../helpers/Interfaces.js").UpdateUser} */ 33 | user: user 34 | }, 35 | bubbles: true, 36 | cancelable: true, 37 | composed: true 38 | })) 39 | } 40 | 41 | this.userListener = event => { 42 | event.detail.fetch.then(user => { 43 | this.userField.value = user.username 44 | this.emailField.value = user.email 45 | this.imageField.value = user.image 46 | this.bioField.value = user.bio 47 | if (event.detail.updated) self.location.hash = '#/' 48 | }).catch((error) => { 49 | console.log(`Error@UserFetch: ${error}`) 50 | self.location.hash = '#/' 51 | }) 52 | } 53 | 54 | this.logoutListener = event => { 55 | this.dispatchEvent(new CustomEvent('logoutUser', { 56 | bubbles: true, 57 | cancelable: true, 58 | composed: true 59 | })) 60 | } 61 | } 62 | 63 | connectedCallback () { 64 | if (this.shouldComponentRender()) this.render() 65 | this.querySelector('button[name="update"]').addEventListener('click', this.updateListener) 66 | this.querySelector('button[name="logout"]').addEventListener('click', this.logoutListener) 67 | document.body.addEventListener('user', this.userListener) 68 | this.dispatchEvent(new CustomEvent('getUser', { 69 | bubbles: true, 70 | cancelable: true, 71 | composed: true 72 | })) 73 | } 74 | 75 | disconnectedCallback () { 76 | this.querySelector('button[name="update"]').removeEventListener('click', this.updateListener) 77 | this.querySelector('button[name="logout"]').removeEventListener('click', this.logoutListener) 78 | document.body.removeEventListener('user', this.userListener) 79 | } 80 | 81 | /** 82 | * evaluates if a render is necessary 83 | * 84 | * @return {boolean} 85 | */ 86 | shouldComponentRender () { 87 | return !this.innerHTML 88 | } 89 | 90 | /** 91 | * 92 | * @return {void} 93 | */ 94 | render () { 95 | this.innerHTML = /* html */` 96 |
          97 |
          98 |
          99 | 100 |
          101 |

          Your Settings

          102 | 103 |
          104 |
          105 |
          106 | 107 |
          108 |
          109 | 110 |
          111 |
          112 | 113 |
          114 |
          115 | 116 |
          117 |
          118 | 119 |
          120 | 123 |
          124 |
          125 | 126 |
          127 | 128 |
          129 | 130 |
          131 |
          132 |
          ` 133 | } 134 | 135 | /** 136 | * @return {HTMLInputElement} 137 | */ 138 | get userField () { 139 | return this.querySelector('input[name=username]') 140 | } 141 | 142 | /** 143 | * @return {HTMLInputElement} 144 | * 145 | */ 146 | get emailField () { 147 | return document.querySelector('input[name=email]') 148 | } 149 | 150 | /** 151 | * @return {HTMLTextAreaElement} 152 | * 153 | */ 154 | get bioField () { 155 | return document.querySelector('textarea[name=bio]') 156 | } 157 | 158 | /** 159 | * @return {HTMLInputElement} 160 | */ 161 | get passwordField () { 162 | return document.querySelector('input[name=password]') 163 | } 164 | 165 | /** 166 | * @return {HTMLInputElement} 167 | * 168 | */ 169 | get imageField () { 170 | return document.querySelector('input[name=image]') 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/es/helpers/Debugging.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global self */ 4 | 5 | /** 6 | * This global Helper-Class holds all Debugging relevant actions and is irrelevant in production 7 | * 8 | * @class DebuggingClass 9 | */ 10 | class DebuggingClass { 11 | constructor () { 12 | // when debugging locally navigate to localhost 13 | // NOTE: 1. Can not be set dynamically in .vscode/launch.json 2. Timeout is needed for the devtools to startup 14 | setTimeout(() => self.location.replace(self.location.href.replace(/.*?(www\/html|html|www|htdocs)(.*)index.*/, 'http://localhost$2')), self.location.href.includes('test') ? 0 : 2000) 15 | } 16 | } 17 | export const Debugging = self.location.href.includes('file://') ? new DebuggingClass() : null 18 | -------------------------------------------------------------------------------- /src/es/helpers/Environment.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global self */ 4 | /* global location */ 5 | 6 | /** 7 | * This global Helper-Class holds all Environment relevant data 8 | * 9 | * @class EnvironmentClass 10 | */ 11 | class EnvironmentClass { 12 | constructor () { 13 | // https://github.com/Weedshaker/event-driven-web-components-realworld-example-app/blob/master/FRONTEND_INSTRUCTIONS.md#using-the-hosted-api 14 | this._fetchBaseUrl = 'https://conduit.productionready.io/api/' 15 | 16 | /** 17 | * it seems as the conduit example always limits by 10 articles per page 18 | * 19 | * @type {number} 20 | */ 21 | this.articlesPerPageLimit = 10 22 | } 23 | 24 | /** 25 | * get the fetchBaseUrl 26 | * 27 | * @return {string} 28 | */ 29 | get fetchBaseUrl () { 30 | return this._fetchBaseUrl 31 | } 32 | 33 | /** 34 | * set the fetchBaseUrl 35 | * 36 | * @param {string} url 37 | */ 38 | set fetchBaseUrl (url) { 39 | const link = document.createElement('link') 40 | link.setAttribute('rel', 'preconnect') 41 | link.setAttribute('href', this._fetchBaseUrl = url) 42 | document.head.appendChild(link) 43 | } 44 | 45 | /** 46 | * get fetch header 47 | * 48 | * @returns {{headers: {}}} 49 | */ 50 | get fetchHeaders () { 51 | const headers = { 52 | 'Content-Type': 'application/json;charset=utf-8' 53 | } 54 | return { 55 | headers: this.token ? { 56 | authorization: `Token ${this.token}`, 57 | ...headers 58 | } : headers 59 | } 60 | } 61 | 62 | /** 63 | * get JWT token 64 | * 65 | * @return {string} 66 | */ 67 | get token () { 68 | return self.localStorage.getItem('ID_TOKEN') 69 | } 70 | 71 | /** 72 | * set JWT token 73 | * 74 | * @param {string} token 75 | */ 76 | set token (token) { 77 | if (token && token !== '') { 78 | self.localStorage.setItem('ID_TOKEN', token) 79 | } else { 80 | self.localStorage.removeItem('ID_TOKEN') 81 | } 82 | } 83 | 84 | /** 85 | * get page slug 86 | */ 87 | get slug () { 88 | const urlEnding = this.urlEnding 89 | if (urlEnding && urlEnding[0].match(/.*-[a-z0-9]{1,100}$/)) return urlEnding[0] 90 | return null 91 | } 92 | 93 | /** 94 | * get url ending 95 | */ 96 | get urlEnding () { 97 | return location.hash.match(/[^/]+$/) 98 | } 99 | } 100 | // @ts-ignore 101 | export const Environment = self.Environment = new EnvironmentClass() 102 | -------------------------------------------------------------------------------- /src/es/helpers/Interfaces.js: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | 3 | /** 4 | * NOTE: This file does not actually export anything except of JSDoc Interfaces/typeDefs 5 | */ 6 | 7 | // JSON Objects returned by API: 8 | // https://github.com/gothinkster/realworld/tree/master/api#json-objects-returned-by-api 9 | /** 10 | * User 11 | * https://github.com/gothinkster/realworld/tree/master/api#users-for-authentication 12 | * 13 | * @typedef {{ 14 | email: string, 15 | token: string, 16 | username: string, 17 | bio: string, 18 | image: string | null 19 | }} User 20 | */ 21 | 22 | /** 23 | * Profile 24 | * https://github.com/gothinkster/realworld/tree/master/api#profile 25 | * 26 | * @typedef {{ 27 | username: string, 28 | bio: string, 29 | image: string, 30 | following: boolean 31 | }} Profile 32 | */ 33 | 34 | /** 35 | * SingleArticle 36 | * https://github.com/gothinkster/realworld/tree/master/api#single-article 37 | * 38 | * @typedef {{ 39 | slug: string, 40 | title: string, 41 | description: string, 42 | body: string, 43 | tagList: Tag[], 44 | createdAt: string, 45 | updatedAt: string, 46 | favorited: boolean, 47 | favoritesCount: 0, 48 | author: Profile 49 | }} SingleArticle 50 | */ 51 | 52 | /** 53 | * MultipleArticles 54 | * https://github.com/gothinkster/realworld/tree/master/api#multiple-articles 55 | * 56 | * @typedef {{ 57 | articles: SingleArticle[], 58 | articlesCount: number 59 | }} MultipleArticles 60 | */ 61 | 62 | /** 63 | * SingleComment 64 | * https://github.com/gothinkster/realworld/tree/master/api#single-comment 65 | * 66 | * @typedef {{ 67 | id: number, 68 | createdAt: string, 69 | updatedAt: string, 70 | body: string, 71 | author: Profile 72 | }} SingleComment 73 | */ 74 | 75 | /** 76 | * MultipleComments 77 | * https://github.com/gothinkster/realworld/tree/master/api#multiple-comments 78 | * 79 | * @typedef {{ 80 | comments: SingleComment[] 81 | }} MultipleComments 82 | */ 83 | 84 | /** 85 | * Tag 86 | * https://github.com/gothinkster/realworld/tree/master/api#list-of-tags 87 | * 88 | * @typedef {string} Tag 89 | */ 90 | 91 | /** 92 | * MultipleTags 93 | * https://github.com/gothinkster/realworld/tree/master/api#multiple-articles 94 | * 95 | * @typedef {{ 96 | tags: Tag[], 97 | }} MultipleTags 98 | */ 99 | 100 | /** 101 | * Error 102 | * https://github.com/gothinkster/realworld/tree/master/api#errors-and-status-codes 103 | * 104 | * @typedef {{ body: string[] }} Error 105 | */ 106 | 107 | // Endpoints: 108 | // https://github.com/gothinkster/realworld/tree/master/api#endpoints 109 | 110 | /** 111 | * Authentication 112 | * https://github.com/gothinkster/realworld/tree/master/api#authentication 113 | * 114 | * @typedef {{ 115 | email: string, 116 | password: string 117 | }} Authentication 118 | */ 119 | 120 | /** 121 | * Registration 122 | * https://github.com/gothinkster/realworld/tree/master/api#registration 123 | * 124 | * @typedef {{ 125 | username: string, 126 | email: string, 127 | password: string 128 | }} Registration 129 | */ 130 | 131 | /** 132 | * UpdateUser 133 | * https://github.com/gothinkster/realworld/tree/master/api#update-user 134 | * 135 | * @typedef {{ 136 | username?: string, 137 | password?: string, 138 | email: string, 139 | bio: string, 140 | image: string 141 | }} UpdateUser 142 | */ 143 | 144 | /** 145 | * CreateArticle 146 | * https://github.com/gothinkster/realworld/tree/master/api#create-article 147 | * 148 | * @typedef {{ 149 | title: string, 150 | description: string, 151 | body: string, 152 | tagList?: Tag[] 153 | }} CreateArticle 154 | */ 155 | 156 | /** 157 | * UpdateArticle 158 | * https://github.com/gothinkster/realworld/tree/master/api#update-article 159 | * 160 | * @typedef {{ 161 | title?: string, 162 | description?: string, 163 | body?: string, 164 | tagList?: Tag[] 165 | }} UpdateArticle 166 | */ 167 | 168 | /** 169 | * AddComment 170 | * https://github.com/gothinkster/realworld/tree/master/api#single-comment 171 | * 172 | * @typedef {{ 173 | body: string 174 | }} AddComment 175 | */ 176 | 177 | // the line below is a workaround to fix 'is not a module' import error, it seems as it is needed to be recognized by JSDoc types 178 | export class IgnoreMe {} 179 | -------------------------------------------------------------------------------- /src/es/helpers/Utils.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * avoids any escapes which then inject JavaScript 5 | * eg.: "https://www.gettyimagcadsaes.com/gi-resources/images/500px/983794168.jpg\"onerror=\"javascript:alert(document.cookie)" 6 | * see issue https://github.com/gothinkster/realworld/issues/525 for more details 7 | * 8 | * @param {string} src 9 | * @return {string} 10 | */ 11 | export const secureImageSrc = src => src ? src.replace(/".*/g, '') : src 12 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Conduit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /test/es/Test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global customElements */ 4 | /* global self */ 5 | 6 | export default class Test { 7 | /** 8 | * Creates an instance of Test 9 | * @param {string} name 10 | * @param {string} [namespace = ''] // is very important if the same test runs more than once in the same session 11 | * @memberof Test 12 | */ 13 | constructor (name, namespace = '') { 14 | this.namespace = namespace 15 | 16 | this.summaries = document.createElement('div') 17 | this.summaries.innerHTML = `${name} had test runs done from which passed and failed` 18 | document.getElementById('summary').appendChild(this.summaries) 19 | this.summarySpaceDone = this.summaries.getElementsByClassName('hasDone')[0] 20 | this.summarySpacePassed = this.summaries.getElementsByClassName('hasPassed')[0] 21 | this.summarySpaceFailed = this.summaries.getElementsByClassName('hasFailed')[0] 22 | this.counter = 0 23 | this.passedCounter = 0 24 | this.failedCounter = 0 25 | 26 | const results = document.createElement('div') 27 | results.innerHTML = ` 28 |
          29 | 30 |

          Results: ${name}

          31 |
          32 |
          33 |
          34 |

          Test Artifacts

          35 |
          36 |
          37 | ` 38 | document.getElementById('results').appendChild(results) 39 | document.getElementById('results').appendChild(document.createElement('hr')) 40 | this.resultSpace = results.getElementsByClassName('result')[0] 41 | this.testSpace = results.getElementsByClassName('test')[0] 42 | 43 | self.onerror = (message, source, lineno, colno, error) => { 44 | const errorEl = document.createElement('div') 45 | errorEl.classList.add(errorEl.textContent = 'failed') 46 | errorEl.textContent += `message: ${message}, source: ${source}, lineno: ${lineno}, colno: ${colno}, error: ${error}}` 47 | this.summaries.appendChild(errorEl) 48 | } 49 | } 50 | 51 | /** 52 | * runs a web-component test by first importing the needed module, define it and test it 53 | * 54 | * @param {string} testName 55 | * @param {string} [moduleName='default'] 56 | * @param {string} modulePath 57 | * @param {(HTMLElement)=>boolean} testFunction 58 | * @param {string} [attributes=''] 59 | * @param {(Function)=>Function} [extendsFunction=(Function)=>Function] 60 | * @memberof Test 61 | * @return {Promise} 62 | */ 63 | runTest (testName, moduleName = 'default', modulePath, testFunction, attributes = '', extendsFunction = func => func) { 64 | testName = this.namespace ? `${testName}-${this.namespace}` : testName 65 | return import(modulePath).then(module => { 66 | // test shadowRoot 67 | try { 68 | // @ts-ignore 69 | if (!customElements.get(testName)) customElements.define(testName, extendsFunction(!module[moduleName].toString().includes(') => class') ? class extends module[moduleName] {} : module[moduleName]())) 70 | } catch (error) { 71 | console.error(`Note! testName: ${testName} has an error defining the customElement!`, error) 72 | console.info(`Assure that moduleName: "${moduleName}" can be found as property with value = class, within imported module:`, module) 73 | console.info(`Note! testName: "${testName}" must be lower case with hyphen separated!`) 74 | } 75 | return this.test(testName, testFunction, attributes, null) 76 | }) 77 | } 78 | 79 | /** 80 | * test a web-component 81 | * 82 | * @param {string} testName 83 | * @param {(HTMLElement)=>boolean} testFunction 84 | * @param {string} [attributes=''] 85 | * @param {Element} [testEl = null] 86 | * @param {boolean} hidden 87 | * @return {Element | null} 88 | */ 89 | test (testName, testFunction, attributes = '', testEl = null, hidden = false) { 90 | if (!testEl) { 91 | const description = document.createElement('div') 92 | description.textContent = `<${testName}>` 93 | description.classList.add('placeHolder') 94 | this.testSpace.appendChild(description) 95 | const container = document.createElement('div') 96 | container.innerHTML = `<${testName} ${attributes}>` 97 | testEl = container.getElementsByTagName(testName)[0] 98 | this.testSpace.appendChild(testEl) 99 | // @ts-ignore 100 | testEl.hidden = hidden 101 | } else { 102 | const placeHolder = document.createElement('div') 103 | placeHolder.textContent = `reused: <${testEl.tagName.toLowerCase()}> for ${testName} test` 104 | placeHolder.classList.add('placeHolder') 105 | this.testSpace.appendChild(placeHolder) 106 | placeHolder.hidden = hidden 107 | } 108 | if (testEl) { 109 | const resultEl = document.createElement('div') 110 | if (testFunction(testEl)) { 111 | resultEl.classList.add(resultEl.textContent = 'passed') 112 | if (!hidden) this.passedCounter++ 113 | } else { 114 | resultEl.classList.add(resultEl.textContent = 'failed') 115 | if (!hidden) this.failedCounter++ 116 | } 117 | testEl.className = '' 118 | testEl.classList.add(resultEl.textContent) 119 | resultEl.textContent += `: ${testName.replace(`-${this.namespace}`, '')}` 120 | this.resultSpace.appendChild(resultEl) 121 | resultEl.hidden = hidden 122 | if (hidden) console.info(`${testFunction(testEl) ? 'passed' : 'failed'} hidden test: ${testName} on element: ${testEl.tagName.toLowerCase()}`) 123 | } 124 | if (!hidden) this.counter++ 125 | this.updateSummary() 126 | return testEl 127 | } 128 | 129 | updateSummary () { 130 | if (Number(this.summarySpaceFailed.textContent) > 0) this.summaries.classList.add('failed') 131 | if (Number(this.summarySpacePassed.textContent) === Number(this.summarySpaceDone.textContent)) { 132 | this.summaries.classList.add('passed') 133 | } else { 134 | this.summaries.classList.remove('passed') 135 | } 136 | } 137 | 138 | set counter (number) { 139 | this._counter = number 140 | this.summarySpaceDone.textContent = number 141 | } 142 | 143 | get counter () { 144 | return this._counter 145 | } 146 | 147 | set passedCounter (number) { 148 | this._passedCounter = number 149 | this.summarySpacePassed.textContent = number 150 | } 151 | 152 | get passedCounter () { 153 | return this._passedCounter 154 | } 155 | 156 | set failedCounter (number) { 157 | this._failedCounter = number 158 | this.summarySpaceFailed.textContent = number 159 | } 160 | 161 | get failedCounter () { 162 | return this._failedCounter 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /test/es/tests/components/controllers/Article.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * ListArticles Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'controllers/Article', moduleName = 'default', modulePath = '../../src/es/components/controllers/Article.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | test.runTest('list-articles-setup', moduleName, modulePath, 25 | el => !!el 26 | ).then(el => { 27 | const child = document.createElement('div') 28 | el.appendChild(child) 29 | let gotFetch = false 30 | let func 31 | document.body.addEventListener('listArticles', (func = event => { 32 | gotFetch = typeof event?.detail?.fetch?.then === 'function' 33 | event?.detail?.fetch?.then(multipleArticles => { 34 | test.test('list-articles-got-fetched-articles', () => multipleArticles.articles.length > 0, undefined, el) 35 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 36 | }) 37 | document.body.removeEventListener('listArticles', func) 38 | })) 39 | child.dispatchEvent(new CustomEvent('requestListArticles', { 40 | /** @type {import("../../../../../src/es/components/controllers/Article").RequestListArticlesEventDetail} */ 41 | detail: {}, 42 | bubbles: true, 43 | cancelable: true, 44 | composed: true 45 | })) 46 | test.test('list-articles-got-fetch', () => gotFetch, undefined, el) 47 | }) 48 | // ------------------------------------------------------------------------------------------------------------ 49 | counter++ 50 | return result 51 | } 52 | -------------------------------------------------------------------------------- /test/es/tests/components/controllers/GetTags.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * GetTags Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'controllers/GetTags', moduleName = 'default', modulePath = '../../src/es/components/controllers/GetTags.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | test.runTest('get-tags-setup', moduleName, modulePath, 25 | el => !!el 26 | ).then(el => { 27 | const child = document.createElement('div') 28 | el.appendChild(child) 29 | let gotFetch = false 30 | let func 31 | document.body.addEventListener('tags', (func = event => { 32 | gotFetch = typeof event?.detail?.fetch?.then === 'function' 33 | event?.detail?.fetch?.then(tag => { 34 | test.test('get-tags-got-fetched-tags', () => tag.tags.length > 0, undefined, el) 35 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 36 | }) 37 | document.body.removeEventListener('tags', func) 38 | })) 39 | child.dispatchEvent(new CustomEvent('getTags', { 40 | /** @type {import("../../../../../src/es/components/controllers/GetTags").RequestGetTagsEventDetail} */ 41 | detail: {}, 42 | bubbles: true, 43 | cancelable: true, 44 | composed: true 45 | })) 46 | test.test('get-tags-got-fetch', () => gotFetch, undefined, el) 47 | }) 48 | // ------------------------------------------------------------------------------------------------------------ 49 | counter++ 50 | return result 51 | } 52 | -------------------------------------------------------------------------------- /test/es/tests/components/controllers/Router.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * Router Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'controllers/Router', moduleName = 'default', modulePath = '../../src/es/components/controllers/Router.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | const oldHash = location.hash 25 | let shouldComponentRenderCounter = 0 26 | let renderCount = 0 27 | let routeCount = 0 28 | const oldHistoryLength = history.length 29 | test.runTest('router-setup', moduleName, modulePath, 30 | el => !!el, 31 | undefined, 32 | subclass => class extends subclass { 33 | shouldComponentRender(name) { 34 | shouldComponentRenderCounter++ 35 | return super.shouldComponentRender(name) 36 | } 37 | render(component) { 38 | renderCount++ 39 | super.render(component) 40 | } 41 | } 42 | ).then(el => { 43 | const parent = el.parentNode 44 | location.hash = '#/' 45 | // routeCount++ don't count here, since this is the connectedCallback default route and will be replaced 46 | setTimeout(() => { 47 | test.test('route-to-p-home', el => el?.children[0]?.tagName === 'P-HOME', undefined, el) 48 | location.hash = '#/article' 49 | routeCount++ 50 | setTimeout(() => { 51 | test.test('route-to-p-article', el => el?.children[0]?.tagName === 'P-ARTICLE', undefined, el) 52 | // do the below at the very end 53 | el.remove() 54 | parent.appendChild(el) 55 | setTimeout(() => { 56 | test.test('router-render-counts', () => renderCount === 2, undefined, el) 57 | test.test('router-should-component-render-counts', () => shouldComponentRenderCounter === 3, undefined, el) 58 | test.test('history-length', () => oldHistoryLength + routeCount === history.length, undefined, el) 59 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 60 | location.hash = oldHash 61 | }, 200); 62 | }, 200) 63 | }, 200) 64 | }) 65 | // ------------------------------------------------------------------------------------------------------------ 66 | counter++ 67 | return result 68 | } 69 | -------------------------------------------------------------------------------- /test/es/tests/components/molecules/ArticleFeedToggle.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * ArticleFeedToggle Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'molecules/ArticleFeedToggle', moduleName = 'default', modulePath = '../../src/es/components/molecules/ArticleFeedToggle.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let shouldComponentRenderCounter = 0 25 | let renderCount = 0 26 | test.runTest('article-feed-toggle-setup', moduleName, modulePath, 27 | el => !!el, 28 | undefined, 29 | subclass => class extends subclass { 30 | shouldComponentRender() { 31 | shouldComponentRenderCounter++ 32 | return super.shouldComponentRender() 33 | } 34 | render(tag) { 35 | super.render(tag) 36 | renderCount++ 37 | } 38 | } 39 | ).then(el => { 40 | const parent = el.parentNode 41 | test.test('article-feed-toggle-content', el => el.querySelector('ul')?.children?.length === 2, undefined, el) 42 | // feed article query tag 43 | parent.dispatchEvent(new CustomEvent('listArticles', { 44 | /** @type {import("../../../../../src/es/components/controllers/Article.js").ListArticlesEventDetail} */ 45 | detail: { 46 | query: { tag: 'Test' } 47 | }, 48 | bubbles: true, 49 | cancelable: true, 50 | composed: true 51 | })) 52 | test.test('article-feed-toggle-content-got-tag', el => el.querySelector('ul')?.children?.length === 3 && el.querySelector('ul')?.children[2]?.textContent?.includes('Test'), undefined, el) 53 | // test click setFavorite button 54 | let gotClicks = 0 55 | let func 56 | // click above favorite button 57 | document.body.addEventListener('requestListArticles', (func = event => { 58 | gotClicks++ 59 | })) 60 | el.querySelector('ul')?.click() 61 | document.body.removeEventListener('requestListArticles', func) 62 | test.test('article-feed-toggle-click-counts', () => gotClicks === 1, undefined, el) 63 | // remove and append to trigger connectedCallback 64 | el.remove() 65 | parent.appendChild(el) 66 | test.test('article-feed-toggle-render-counts', () => renderCount === 2, undefined, el) 67 | test.test('article-feed-toggle-should-component-render-counts', () => shouldComponentRenderCounter === 2, undefined, el) 68 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 69 | }) 70 | // ------------------------------------------------------------------------------------------------------------ 71 | counter++ 72 | return result 73 | } 74 | -------------------------------------------------------------------------------- /test/es/tests/components/molecules/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * ArticlePreview Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'molecules/ArticlePreview', moduleName = 'default', modulePath = '../../src/es/components/molecules/ArticlePreview.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let shouldComponentRenderCounter = 0 25 | let renderCount = 0 26 | test.runTest('article-preview-setup', moduleName, modulePath, 27 | el => !!el, 28 | undefined, 29 | subclass => class extends subclass { 30 | shouldComponentRender() { 31 | shouldComponentRenderCounter++ 32 | return super.shouldComponentRender() 33 | } 34 | render() { 35 | if (typeof super.render() !== 'string') renderCount++ 36 | } 37 | } 38 | ).then(el => { 39 | const parent = el.parentNode 40 | test.test('article-preview-empty', el => !el.innerHTML || el.innerHTML.includes('An error occurred'), undefined, el) 41 | el.article = { 42 | author: { 43 | username: 'test' 44 | }, 45 | tagList: ['testTag'] 46 | } 47 | el.render() 48 | // needs to wait for fetching its child components eg. ArticleMeta 49 | setTimeout(() => { 50 | test.test('article-preview-content', el => !!el.querySelector('.article-meta') && el.querySelector('.author')?.textContent === 'test' && el.querySelector('.tag-default')?.textContent === 'testTag', undefined, el) 51 | // test click setFavorite button 52 | let gotClicks = 0 53 | let func1 54 | document.body.addEventListener('setFavorite', (func1 = event => { 55 | gotClicks = !!event?.detail?.article && typeof event?.detail?.resolve === 'function' ? gotClicks + 1 : gotClicks 56 | })) 57 | // click above favorite button 58 | let func2 59 | document.body.addEventListener('click', (func2 = event => { 60 | gotClicks++ 61 | })) 62 | el.querySelector('button')?.click() 63 | el.querySelector('.ion-heart')?.click() 64 | el.querySelector('.info')?.click() 65 | document.body.removeEventListener('setFavorite', func1) 66 | document.body.removeEventListener('click', func2) 67 | test.test('article-preview-click-counts', () => gotClicks === 3, undefined, el) 68 | // remove and append to trigger connectedCallback 69 | el.remove() 70 | parent.appendChild(el) 71 | test.test('article-preview-render-counts', () => renderCount === 1, undefined, el) 72 | test.test('article-preview-should-component-render-counts', () => shouldComponentRenderCounter === 2, undefined, el) 73 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 74 | }, 600) 75 | }) 76 | // ------------------------------------------------------------------------------------------------------------ 77 | counter++ 78 | return result 79 | } 80 | -------------------------------------------------------------------------------- /test/es/tests/components/molecules/Pagination.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * Pagination Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'molecules/Pagination', moduleName = 'default', modulePath = '../../src/es/components/molecules/Pagination.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let renderCount = 0 25 | test.runTest('pagination-setup', moduleName, modulePath, 26 | el => !!el, 27 | undefined, 28 | subclass => class extends subclass { 29 | // avoid dispatching the getPagination event and listen to this particular test 30 | connectedCallback () { 31 | document.body.addEventListener('paginationTest', this.listArticlesListener) 32 | this.addEventListener('click', this.clickListener) 33 | } 34 | render(fetchMultipleArticles, query) { 35 | super.render(fetchMultipleArticles, query) 36 | renderCount++ 37 | } 38 | } 39 | ).then(el => { 40 | const parent = el.parentNode 41 | test.test('pagination-empty', el => !el.innerHTML, undefined, el) 42 | document.body.dispatchEvent(new CustomEvent('paginationTest', { 43 | /** @type {import("../../../../../src/es/components/controllers/Article.js").ListArticlesEventDetail} */ 44 | detail: { 45 | fetch: Promise.resolve({ 46 | articlesCount: 250, 47 | articles: {} 48 | }), 49 | query: {} 50 | }, 51 | bubbles: true, 52 | cancelable: true, 53 | composed: true 54 | })) 55 | // wait for the Promise to resolve 56 | setTimeout(() => { 57 | test.test('pagination-content', el => !!el.querySelector('.pagination') && el.querySelector('.pagination')?.children?.length === 25, undefined, el) 58 | // test click pagination link 59 | let gotClicks = 0 60 | let func1 61 | document.body.addEventListener('requestListArticles', (func1 = event => { 62 | gotClicks = event?.detail?.offset === 240 ? gotClicks + 1 : gotClicks 63 | })) 64 | el.querySelector('.pagination')?.children[24]?.querySelector('a')?.click() 65 | document.body.removeEventListener('requestListArticles', func1) 66 | test.test('pagination-click-counts', () => gotClicks === 1, undefined, el) 67 | // remove and append to trigger connectedCallback 68 | el.remove() 69 | parent.appendChild(el) 70 | test.test('pagination-render-counts', () => renderCount === 1, undefined, el) 71 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 72 | }, 50); 73 | }) 74 | // ------------------------------------------------------------------------------------------------------------ 75 | counter++ 76 | return result 77 | } 78 | -------------------------------------------------------------------------------- /test/es/tests/components/molecules/TagList.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * TagList Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'molecules/TagList', moduleName = 'default', modulePath = '../../src/es/components/molecules/TagList.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let renderCount = 0 25 | test.runTest('tag-list-setup', moduleName, modulePath, 26 | el => !!el, 27 | undefined, 28 | subclass => class extends subclass { 29 | // avoid dispatching the getTags event and listen to this particular test 30 | connectedCallback () { 31 | document.body.addEventListener('tagsTest', this.tagsListener) 32 | this.addEventListener('click', this.clickListener) 33 | } 34 | render(fetchTags) { 35 | super.render(fetchTags) 36 | renderCount++ 37 | } 38 | } 39 | ).then(el => { 40 | const parent = el.parentNode 41 | test.test('tag-list-empty', el => !el.innerHTML, undefined, el) 42 | document.body.dispatchEvent(new CustomEvent('tagsTest', { 43 | /** @type {import("../../../../../src/es/components/controllers/GetTags.js").TagsEventDetail} */ 44 | detail: { 45 | fetch: Promise.resolve({ 46 | tags: ['test', 'hello', 'test'] 47 | }) 48 | }, 49 | bubbles: true, 50 | cancelable: true, 51 | composed: true 52 | })) 53 | // wait for the Promise to resolve 54 | setTimeout(() => { 55 | test.test('tag-list-content', el => !!el.querySelector('.tag-list') && el.querySelector('.tag-list')?.children?.length === 3, undefined, el) 56 | // test click tag link 57 | let gotClicks = 0 58 | let func1 59 | document.body.addEventListener('requestListArticles', (func1 = event => { 60 | gotClicks = event?.detail?.tag === 'hello' ? gotClicks + 1 : gotClicks 61 | })) 62 | el.querySelector('.tag-list')?.children[1]?.click() 63 | document.body.removeEventListener('requestListArticles', func1) 64 | test.test('tag-list-click-counts', () => gotClicks === 1, undefined, el) 65 | // remove and append to trigger connectedCallback 66 | el.remove() 67 | parent.appendChild(el) 68 | test.test('tag-list-render-counts', () => renderCount === 1, undefined, el) 69 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 70 | }, 50); 71 | }) 72 | // ------------------------------------------------------------------------------------------------------------ 73 | counter++ 74 | return result 75 | } 76 | -------------------------------------------------------------------------------- /test/es/tests/components/organisms/Footer.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * Footer Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'organisms/Footer', moduleName = 'default', modulePath = '../../src/es/components/organisms/Footer.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let shouldComponentRenderCounter = 0 25 | let renderCount = 0 26 | test.runTest('footer-setup', moduleName, modulePath, 27 | el => !!el, 28 | undefined, 29 | subclass => class extends subclass { 30 | shouldComponentRender() { 31 | shouldComponentRenderCounter++ 32 | return super.shouldComponentRender() 33 | } 34 | render() { 35 | renderCount++ 36 | super.render() 37 | } 38 | } 39 | ).then(el => { 40 | const parent = el.parentNode 41 | test.test('footer-content', el => !!el.querySelector('.logo-font') && !!el.querySelector('.attribution')?.querySelector('a')?.href?.includes('thinkster'), undefined, el) 42 | // remove and append to trigger connectedCallback 43 | el.remove() 44 | parent.appendChild(el) 45 | test.test('footer-render-counts', () => renderCount === 1, undefined, el) 46 | test.test('footer-should-component-render-counts', () => shouldComponentRenderCounter === 2, undefined, el) 47 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 48 | }) 49 | // ------------------------------------------------------------------------------------------------------------ 50 | counter++ 51 | return result 52 | } 53 | -------------------------------------------------------------------------------- /test/es/tests/components/organisms/Header.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * Header Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'organisms/Header', moduleName = 'default', modulePath = '../../src/es/components/organisms/Header.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let shouldComponentRenderCounter = 0 25 | let renderCount = 0 26 | test.runTest('header-setup', moduleName, modulePath, 27 | el => !!el, 28 | undefined, 29 | subclass => class extends subclass { 30 | shouldComponentRender() { 31 | shouldComponentRenderCounter++ 32 | return super.shouldComponentRender() 33 | } 34 | render() { 35 | renderCount++ 36 | super.render() 37 | } 38 | } 39 | ).then(el => { 40 | const parent = el.parentNode 41 | test.test('header-content', el => !!el.querySelector('a.navbar-brand')?.href?.includes('#/') && !!el.querySelector('.navbar-nav'), undefined, el) 42 | // remove and append to trigger connectedCallback 43 | el.remove() 44 | parent.appendChild(el) 45 | test.test('header-render-counts', () => renderCount === 2, undefined, el) 46 | // shouldComponentRender is only triggered on userListener 47 | test.test('header-should-component-render-counts', () => shouldComponentRenderCounter === 0, undefined, el) 48 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 49 | }) 50 | // ------------------------------------------------------------------------------------------------------------ 51 | counter++ 52 | return result 53 | } 54 | -------------------------------------------------------------------------------- /test/es/tests/components/organisms/ListArticlePreviews.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * ListArticlePreviews Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'organisms/ListArticlePreviews', moduleName = 'default', modulePath = '../../src/es/components/organisms/ListArticlePreviews.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let renderCount = 0 25 | let loadChildComponents = 0 26 | test.runTest('list-article-previews-setup', moduleName, modulePath, 27 | el => !!el, 28 | undefined, 29 | subclass => class extends subclass { 30 | // avoid dispatching the listArticlePreviewsTest event and listen to this particular test 31 | connectedCallback () { 32 | document.body.addEventListener('listArticlePreviewsTest', this.listArticlesListener) 33 | } 34 | render(fetchMultipleArticles) { 35 | super.render(fetchMultipleArticles) 36 | renderCount++ 37 | } 38 | loadChildComponents () { 39 | loadChildComponents++ 40 | return super.loadChildComponents() 41 | } 42 | } 43 | ).then(el => { 44 | const parent = el.parentNode 45 | test.test('list-article-previews-empty', el => !el.innerHTML, undefined, el) 46 | document.body.dispatchEvent(new CustomEvent('listArticlePreviewsTest', { 47 | /** @type {import("../../../../../src/es/components/controllers/Article.js").ListArticlesEventDetail} */ 48 | detail: { 49 | fetch: Promise.resolve({ 50 | articles: [ 51 | { 52 | author: {}, 53 | tagList: [] 54 | }, 55 | { 56 | author: {}, 57 | tagList: [] 58 | }, 59 | { 60 | author: {}, 61 | tagList: [] 62 | } 63 | ] 64 | }), 65 | query: {} 66 | }, 67 | bubbles: true, 68 | cancelable: true, 69 | composed: true 70 | })) 71 | // wait for the Promise to resolve 72 | setTimeout(() => { 73 | test.test('list-article-previews-content', el => el?.children?.length === 3, undefined, el) 74 | // remove and append to trigger connectedCallback 75 | el.remove() 76 | parent.appendChild(el) 77 | test.test('list-article-previews-render-counts', () => renderCount === 1, undefined, el) 78 | test.test('list-article-previews-load-child-components-counts', () => loadChildComponents === 1, undefined, el) 79 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 80 | }, 200); 81 | }) 82 | // ------------------------------------------------------------------------------------------------------------ 83 | counter++ 84 | return result 85 | } 86 | -------------------------------------------------------------------------------- /test/es/tests/components/pages/Home.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import Test from '../../../Test.js' 4 | 5 | let counter = 0 6 | 7 | /** 8 | * Home Tests 9 | * 10 | * @param {string} testTitle 11 | * @param {string} moduleName 12 | * @param {string} modulePath 13 | * @param {string} [namespace = ''] 14 | * @return {Promise<[number, number, number]>} 15 | */ 16 | export const test = (testTitle = 'organisms/Home', moduleName = 'default', modulePath = '../../src/es/components/pages/Home.js', namespace = counter) => { 17 | let resolveTest 18 | const result = new Promise(resolve => resolveTest = resolve) 19 | // test modulePath must be from Test.js perspective 20 | const test = new Test(testTitle, namespace) 21 | 22 | // ------------------------------------------------------------------------------------------------------------ 23 | // HTML ------------------------------------------------------------------------------------------------------- 24 | let shouldComponentRenderCounter = 0 25 | let renderCount = 0 26 | let loadChildComponentsCount = 0 27 | test.runTest('home-setup', moduleName, modulePath, 28 | el => !!el, 29 | undefined, 30 | subclass => class extends subclass { 31 | shouldComponentRender() { 32 | shouldComponentRenderCounter++ 33 | return super.shouldComponentRender() 34 | } 35 | render() { 36 | renderCount++ 37 | super.render() 38 | } 39 | loadChildComponents() { 40 | loadChildComponentsCount++ 41 | return super.loadChildComponents() 42 | } 43 | } 44 | ).then(el => { 45 | const parent = el.parentNode 46 | // set timeout due to render is async 47 | setTimeout(() => { 48 | test.test('home-content', el => !!el.querySelector('o-list-article-previews'), undefined, el) 49 | test.test('home-defined-child-components', () => customElements.get('o-list-article-previews'), undefined, el) 50 | // remove and append to trigger connectedCallback 51 | el.remove() 52 | parent.appendChild(el) 53 | test.test('home-should-component-render-counts', () => shouldComponentRenderCounter === 2, undefined, el) 54 | test.test('home-render-counts', () => renderCount === 1, undefined, el) 55 | test.test('home-load-child-components-counts', () => loadChildComponentsCount === 2, undefined, el) 56 | resolveTest([test.counter, test.passedCounter, test.failedCounter]) 57 | }, 200); 58 | }) 59 | // ------------------------------------------------------------------------------------------------------------ 60 | counter++ 61 | return result 62 | } 63 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Conduit Tests 6 | 7 | 8 | 9 | 10 | 11 | 67 | 112 | 113 | 114 |

          Summary:

          115 |
          116 |
          117 |
          118 | 119 | --------------------------------------------------------------------------------