From b2ed82604b8daa47f98bb00de86dc264883ff896 Mon Sep 17 00:00:00 2001 From: Arjan van Wijk Date: Sun, 12 Nov 2017 21:33:23 +0100 Subject: [PATCH] Add utils and documentation for working with dynamic data Add documentation that describes some examples and best practices for working with dynamic data (fetch from the backend). Add the `updateElement` util to update the HTML content of an element, including cleanup and initializing the new components. This can be useful when you retrieved new HTML from the backend and need to replace a section of the page. Add the `initTextBinding` util to set up a knockout binding to an element and include its content as the initial value for the observable. Add the `initListBinding` util to set up a knockout binding for a list that renders using a knockout template. It has the option to extract data from the already rendered items (or you can pass them manually), so they can be re-rendered client-side. --- docs/dynamic-data.md | 372 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/app/muban/componentUtils.ts | 31 +++ src/app/muban/knockoutUtils.ts | 122 +++++++++++ 4 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docs/dynamic-data.md create mode 100644 src/app/muban/knockoutUtils.ts 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; +}