diff --git a/docs/dynamic-data.md b/docs/dynamic-data.md new file mode 100644 index 00000000..ce644440 --- /dev/null +++ b/docs/dynamic-data.md @@ -0,0 +1,372 @@ +# Dynamic Data + +Muban is designed to work with HTML that is fully generated by the server, where it only provides +the `js` and `css` to make the website look and work the way it should. The big downside is that +it's not possible to work with data-binding template engines that frameworks like Vue, React and +Angular do, because they have control over the HTML. + +This means we create (interactive) components by passing the HTML element, and the component +should use querySelectors and other DOM APIs to read from and write to the DOM. We have added +Knockout to Muban to allow you to set up data-bindings from within JavaScript, but that only +gets you so far. + +When having to deal with dynamic data fetched from JavaScript, or rendered lists that need to be +sorted of filtered client-side, we need to think of something else. Below are some common +scenarios and how you can deal with them. + +## fetch() + +For basic XHR calls, you should use the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). +To support older browsers (IE), you should include the [fetch polyfill](https://github.com/github/fetch). + +Install: +``` +yarn add whatwg-fetch +``` + +Import in the file in `dev.js` and `dist.js`: +``` +import 'whatwg-fetch'; +``` + + +##### Getting HTML +``` +fetch('/users.html') + .then(response => response.text()) + .then(body => { + document.body.innerHTML = body; + }); +``` + +##### Getting JSON +``` +fetch('/users.json') + .then(response => response.json()) + .then(json => { + console.log('parsed json', json); + }).catch(ex => { + console.error('parsing failed', ex); + }); +``` + +##### Post form +``` +var form = document.querySelector('form') + +fetch('/users', { + method: 'POST', + body: new FormData(form), +}); +``` + +##### Post JSON +``` +fetch('/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'Hubot', + login: 'hubot', + }), +}); +``` + +##### File Upload +``` +const input = document.querySelector('input[type="file"]') + +const data = new FormData() +data.append('file', input.files[0]); // file +data.append('user', 'hubot'); // other data + +fetch('/avatars', { + method: 'POST', + body: data +}) +``` + + + +## Real world examples + +### Backend returns HTML for an updated section + +Sometimes, a section rendered by the backend has multiple options, and when switching options +you want new data for that section. If the backend cannot return JSON, they might return a HTML +snippet for that section. In that case we should: + +1. fetch the new section +2. clean up the old HTML element (remove attached classes, for memory leaks) +3. replace the HTML on the page +4. initialize new component instances for that section and nested components + +``` +// code is located a component, where this.element points to HTML element for that section + +import { cleanElement, initComponents } from '../../../muban/componentUtils'; + +fetch(`/api/section/${id}`) + .then(response => response.text()) + .then(body => { + const currentElement = this.element; + + // 2. dispose all created component instances + cleanElement(currentElement); + + // insert the new HTML into a temp container to construct the DOM + const temp = document.createElement('div'); + temp.innerHTML = body; + const newElement = temp.firstChild; + + // 3. replace the HTML on the page + currentElement.parentNode.replaceChild(newElement, currentElement); + + // 4. initialize new components for the new element + initComponents(newElement); + }); +``` + +Luckily there is a utility function for this: + +``` +// code is located a component, where this.element points to HTML element for that section + +import { updateElement } from '../../../muban/componentUtils'; + +fetch(`/api/section/${id}`) + .then(response => response.text()) + .then(body => { + updateElement(this.element, body); + }); +``` + +While this seams like a good option, keep in mind that the whole section will be reset into its +default state, which could (depending on the contents of the section) be a bad experience, +especially when dealing with animation/transitions. + + +### Backend returns JSON for an updated section + +This one might be a bit more work compared to just replacing HTML, but gives you way more control +over what happens on the page. The big benefit is that the state doesn't reset, allowing you to +make nice transitions while the new data is updated on the page. +``` +fetch(`/api/section/${id}`) + .then(response => response.json()) + .then(json => { + // this part really depends on what the data will be + + // if it's just text, you could: + this.element.querySelector('.js-content').innerHTML = json.content; + + // or pass new data to a child component + this.childComponent.setNewData(json.content); + }); +``` + +Or when using knockout to update your HTML: +``` +import { initTextBinding } from '../../../muban/knockoutUtils'; +import ko from 'knockout'; + +// when using knockout to bind your data, first init the observable with the correct intial data +this.content = ko.observable(this.element.querySelector('.content').innerHTML); + +// then apply the observable to the HTML element +ko.applyBindingsToNode(this.element.querySelector('.content'), { + 'html': this.content, +}); + +// or a better way to do the above two steps: +this.content = initTextBinding(this.element.querySelector('.content'), true); + +fetch(`/api/section/${id}`) + .then(response => response.json()) + .then(json => { + this.content(json.content); // content is an knockout observable + }); +``` + +### Sorting or filtering lists + +Sometimes the server renders a list of items on the page, but you have to sort or filter them +client-side, based on specific data in those items. Since we already have all the items and data +on the page, it's not that difficult. + +We can just query all the items, and retrieve the information we need to execute our logic, and +add them back to the page. + +``` +constructor() { + this.initItems(); + this.updateItems(); +} + +private initItems() { + // get all DOM nodes + const items = Array.from(this.element.querySelectorAll('.item')); + + // convert to list of useful data to filter/sort on + this.itemData = items.map(item => ({ + element: item, + title: item.querySelector('.title').textContent, + tags: Array.from(item.querySelectorAll('.tag')).map(tag => tag.textContent.toLowerCase()), + })); +} + +private updateItems() { + // empty the container + const container = this.element.querySelector('.items'); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + // filter on any tags that contains an 's' + let newItems = this.filterOnTags(this.itemData, 's'); + // sort descending + newItems = this.sortOnTitle(newItems, false); + + // append new items to the container + const fragment = document.createDocumentFragment(); + newItems.forEach(item => fragment.appendChild(item.element)); + container.appendChild(fragment); +} + + +// sort items base on the title attribute +private sortOnTitle(itemData, ascending:boolean = false) { + return [...itemData].sort((a, b) => a.title.localeCompare(b.title) * (ascending ? 1 : -1)); +} + +// filter items based on the tags array +private filterOnTags(itemData, filter:string) { + return itemData.filter(item => item.tags.some(tag => tag.includes(filter.toLowerCase()))); +} + +``` + +### Load more items to the page + +Sometimes the server renders the first page of items, but they want to have the second page to be +loaded and displayed from the client. If the server returns HTML, we can just re-use some of the +logic in our HTML example above. + +However, if the server returns JSON, we sort of want to re-use the markup of the existing items +on the page. We _could_ build up the HTML ourselves from JavaScript, but that would mean the HTML +lives in two places, on the server and in JavaScript, and it will be hard to keep them in sync. + +There are two options we can choose from. + +##### Clone and update element + +For smaller items, we could just clone the first element of the list, and create a function that +updates all the data in that item, so we can append it to the DOM. + +``` +// get the template node to clone later +const template = this.element.querySelector('.item'); +// create a documentFragment for better performance when adding items +const fragment = document.createDocumentFragment(); + +// clone template, update data, and add to fragment +newItems.forEach(item => { + const clone = template.cloneNode(true); + clone.querySelector('.title').textContent = item.title; + clone.querySelector('.description').textContent = item.description; + fragment.appendChild(clone); +}); + +// add fragment to the list +this.element.querySelector('.list').appendChild(fragment); +``` + +##### Use Knockout with a template + +This option works best when only used on the client, but when having server-rendered items in the +DOM you would first need to convert them to data to properly render them. + +Handlebars template: +``` + + + +
+ {{#each items}} +
+

{{title}}

+

{{description}}

+
+ {{#each tags}} + {{this}} + {{/each}} +
+
+ {{/each}} +
+``` + +Script: +``` +// 1. transform old items to data +// get all DOM nodes +const items = Array.from(this.element.querySelectorAll('.item')); + +// convert to list of useful data to filter/sort on +const oldData = items.map(item => ({ + title: item.querySelector('.title').textContent, + description: item.querySelector('.description').innerHTML, + tags: Array.from(item.querySelectorAll('.tag')).map(tag => tag.textContent), +})); + +// 2. create observable and set old data +const itemData = ko.observableArray(oldData); + +// 3. apply bindings to list, this will re-render the items +ko.applyBindingsToNode(this.element.querySelector('.items'), { + 'template' : { 'name': 'item-template', 'foreach': itemData }, +}); + +// 4. add new data to the observable +// or do any other funky stuff to the array, like sorting/filtering +itemData.push(...newData); +``` + +The above can be simplified by using a util. +The 3rd parameter can also be `oldData` extract above instead of the passed config for more control. +``` +import { initListBinding } from '../../../muban/knockoutUtils'; + +// 1+2+3. extract data, create observable and apply bindings +const itemData = initListBinding( + this.element.querySelector('.items'), + 'item-template', + { + query: '.item', + data: { + title: '.title', + description: { query: '.description', htm: true }, + tags: { query: '.tag', list: true }, + } + }, +); + +// 4. add new data to the observable +// or do any other funky stuff to the array, like sorting/filtering +itemData.push(...newData); +``` diff --git a/package.json b/package.json index 2cfef29e..408d810d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muban", - "version": "2.2.0", + "version": "2.3.0", "description": "", "scripts": { "dev": "webpack-dev-server --config build-tools/config/webpack/webpack.config.js", diff --git a/src/app/muban/componentUtils.ts b/src/app/muban/componentUtils.ts index 9ee66bbc..f5b9f0d0 100644 --- a/src/app/muban/componentUtils.ts +++ b/src/app/muban/componentUtils.ts @@ -64,6 +64,14 @@ export function initComponents(rootElement: HTMLElement): void { components[BlockConstructor.displayName] = []; } + if (rootElement.getAttribute('data-component') === displayName) { + list.push({ + component, + element: rootElement, + depth: getComponentDepth(rootElement as HTMLElement), + }); + } + // find all DOM elements that belong the this block Array.from( rootElement.querySelectorAll(`[data-component="${displayName}"]`), @@ -141,6 +149,29 @@ export function cleanElement(element: HTMLElement): void { Array.from(element.querySelectorAll('[data-component]')).forEach(cleanElement); } +/** + * Updates the content of an element, including cleanup and initializing the new components. + * Useful when you retrieved new HTML from the backend and need to replace a section of the page. + * + * @param {HTMLElement} element + * @param {string} html + */ +export function updateElement(element: HTMLElement, html: string): void { + // dispose all created component instances + cleanElement(element); + + // insert the new HTML into a temp container to construct the DOM + const temp = document.createElement('div'); + temp.innerHTML = html; + const newElement = temp.firstChild; + + // replace the HTML on the page + element.parentNode.replaceChild(newElement, element); + + // initialize new components for the new element + initComponents(newElement); +} + /** * Returns the depth of an element in the DOM * diff --git a/src/app/muban/knockoutUtils.ts b/src/app/muban/knockoutUtils.ts new file mode 100644 index 00000000..0b4dec76 --- /dev/null +++ b/src/app/muban/knockoutUtils.ts @@ -0,0 +1,122 @@ +import ko from 'knockout'; + +/** + * Sets up a binding to the element, and sets the element's content as initial value + * + * @param {HTMLElement} element + * @param {boolean} html + * @return {KnockoutObservable} + */ +export function initTextBinding( + element: HTMLElement, + html: boolean = false, +): KnockoutObservable { + // init the observable with the correct initial data + const obs = ko.observable(element[html ? 'innerHTML' : 'textContent']); + + // then apply the observable to the HTML element + ko.applyBindingsToNode(element, { + [html ? 'html' : 'text']: obs, + }); + + return obs; +} + +/** + * Sets up a foreach template binding to a container, and can optionally extract the old data + * + * If extractData is an array, it will use that data as-is. This means you have extraced the + * data yourself. + * + * Otherwise extractData should be an config object which will be used to extract the data for you. + * An example of it is this: + * + * ``` + * { + * query: '.item', + * data: { + * title: '.title', + * description: { query: '.description', htm: true }, + * tags: { query: '.tag', list: true }, + * } + * } + * ``` + * + * The outer `query` is used to select the items in the container. + * For each item, it will store each key with the extract data. + * + * When given just a string, it will `query` that element and use the `textContent`. + * When given an object, you can pass additional configuration. + * The `query` parameter is the same that can be passed as just a string. + * When `html` is true, it will use `innerHTML` instead of `textContent`. + * When `list` is `true`, it use `querySelectorAll` and extract the values from those nodes into + * an array. + * + * The output of the example above will match: + * ``` + *
+ *

item 3

+ *

Description for item 3

+ *
+ * js + * html + *
+ *
+ * ``` + * + * To: + * ``` + * { + * "title": "item 3", + * "description": "Description for item 3", + * "tags": ["js", "html"], + * } + * ``` + * + * @param {HTMLElement} container + * @param {string} templateName + * @param {Array | any} extractData + * @return {KnockoutObservable>} + */ +export function initListBinding( + container: HTMLElement, + templateName: string, + extractData: Array | any, +): KnockoutObservable> { + let currentData; + + if (Array.isArray(extractData)) { + currentData = extractData; + } else { + // 1. transform old items to data + // get all DOM nodes + const items = Array.from(container.querySelectorAll(extractData.query)); + + // convert to list of useful data to filter/sort on + currentData = items.map((item: HTMLElement) => + Object.keys(extractData.data).reduce((obj, key): any => { + let info = extractData.data[key]; + if (typeof info === 'string') info = { query: info }; + + if (!info.list) { + obj[key] = item.querySelector(info.query)[info.html ? 'innerHTML' : 'textContent']; + } else { + obj[key] = Array.from(item.querySelectorAll(info.query)).map( + child => child[info.html ? 'innerHTML' : 'textContent'], + ); + } + return obj; + }, {}), + ); + } + + // 2. create observable and set old data + const list = ko.observableArray(currentData); + + // 3. apply bindings to list, this will re-render the items + ko.applyBindingsToNode(container, { + template: { name: templateName, foreach: list }, + }); + + return list; +}