├── .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 |
72 |
94 |
95 |
96 |
97 | ```
98 |
99 | ### Footer
100 | ```html
101 |
102 |
103 |
conduit
104 |
105 | An interactive learning project from Thinkster . Code & design licensed under MIT.
106 |
107 |
108 |
109 |
110 |
111 |
112 | ```
113 |
114 | ## Pages
115 |
116 | ### Home
117 | ```html
118 |
119 |
120 |
121 |
122 |
conduit
123 |
A place to share your knowledge.
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
141 |
142 |
159 |
160 |
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 |
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 |
257 |
258 |
259 | Follow Eric Simons
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
281 |
282 |
299 |
300 |
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 |
363 |
364 |
365 |
366 |
367 |
368 | ```
369 |
370 | ### Create/Edit Article
371 |
372 | ```html
373 |
402 |
403 |
404 | ```
405 |
406 | ### Article
407 |
408 | ```html
409 |
410 |
411 |
412 |
413 |
414 |
How to build webapps that scale
415 |
416 |
417 |
418 |
422 |
423 |
424 |
425 | Follow Eric Simons (10)
426 |
427 |
428 |
429 |
430 |
431 | Favorite Post (29)
432 |
433 |
434 |
435 |
436 |
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 |
454 |
455 |
459 |
460 |
461 |
462 |
463 | Follow Eric Simons (10)
464 |
465 |
466 |
467 |
468 |
469 | Favorite Post (29)
470 |
471 |
472 |
473 |
474 |
475 |
476 |
477 |
478 |
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
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 | Router <c-router> On hashchange event: -loads and mounts different pages depending the matching regex in its routes
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 | # 
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 | 
27 |
28 | ### pages/Home.js
29 |
30 | 
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 | 
47 |
48 | ### [Angular (75)](https://github.com/gothinkster/angular-realworld-example-app)
49 |
50 | 
51 |
52 | ### [Vue (82)](https://github.com/gothinkster/vue-realworld-example-app)
53 |
54 | 
55 |
56 | ### [Vanilla JS Web Components (92)](https://github.com/gothinkster/web-components-realworld-example-app)
57 |
58 | 
59 |
60 | ### Event Driven Vanilla JS Web Components (95)
61 |
62 | 
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 |
116 |
117 | ${this.hasActions
118 | ? article.author.self
119 | ? `
120 | Edit Article
121 |
Delete Article`
122 | : `
123 |
124 |
125 | ${article.author.following ? 'Unfollow' : 'Follow'} ${article.author.username}
126 |
127 |
128 |
129 |
130 |
131 | Favorite Post (${article.favoritesCount})
132 | `
133 | : `
134 | ${article.favoritesCount}
135 | `}
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 |
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 |
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 |
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 |
109 |
112 |
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 |
71 |
105 |
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 = 'An error occurred rendering the article-page!
')
97 | article.author = Object.assign(article.author, { self: user && user.username === article.author.username })
98 | this.innerHTML = `
99 |
100 |
101 |
102 |
103 |
104 |
${article.title}
105 |
106 |
107 |
108 |
109 |
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 |
129 |
130 |
131 |
132 |
133 |
134 | ${user
135 | ? `
136 |
137 | `
138 | : '
'}
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') ? '' : 'An error occurred fetching the article!
')))
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 |
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 |
37 |
38 |
conduit
39 |
A place to share your knowledge.
40 |
41 |
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 |
92 | Sign in
93 |
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 ? 'An error occurred fetching the profile!
' : this.loading)
108 | this.innerHTML = /* html */`
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
${profile.username}
118 |
119 | ${profile.bio || ''}
120 |
121 |
122 | ${user && profile && user.username === profile.username
123 | ? `
124 | Edit Profile Settings
125 | `
126 | : `
127 |
128 |
129 | ${profile.following ? 'Unfollow' : 'Follow'} ${profile.username}
130 | `
131 | }
132 |
133 |
134 |
135 |
136 |
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 |
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 | `
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}>${testName}>`
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 |
--------------------------------------------------------------------------------