Skip to content

Commit

Permalink
Add utils and documentation for working with dynamic data
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ThaNarie committed Nov 12, 2017
1 parent e749d43 commit b2ed826
Show file tree
Hide file tree
Showing 4 changed files with 526 additions and 1 deletion.
372 changes: 372 additions & 0 deletions docs/dynamic-data.md
Original file line number Diff line number Diff line change
@@ -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(<HTMLElement>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(<HTMLElement>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 = <HTMLELement>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:
```
<!--
List item template, keep in HTML since it will be used by javascript.
The HTML in the script-template is similar to the html in the handlebars list below.
The handlebars template will be rendered on the server, and the script-template will
be used by knockout to render the list client-side (when new data comes in).
-->
<script type="text/html" id="item-template">
<h3 class="title" data-bind="text: title"></h3>
<p class="description" data-bind="html: description"></p>
<div class="tags">
<!-- ko foreach: tags -->
<span class="tag" data-bind="text: $data"></span>
<!-- /ko -->
</div>
</script>
<section class="items">
{{#each items}}
<article class="item">
<h3 class="title">{{title}}</h3>
<p class="description">{{description}}</p>
<div class="tags">
{{#each tags}}
<span class="tag">{{this}}</span>
{{/each}}
</div>
</article>
{{/each}}
</section>
```

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(
<HTMLElement>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);
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading

0 comments on commit b2ed826

Please sign in to comment.