Extend bindings with one-way content update bind

This commit is contained in:
Bartek Fabiszewski 2019-12-17 20:01:59 +01:00
parent 9dd8ad007f
commit 0745bfc92e
6 changed files with 79 additions and 33 deletions

View File

@ -31,7 +31,6 @@ export default class ConfigViewModel extends ViewModel {
constructor(state) { constructor(state) {
super(config); super(config);
this.state = state; this.state = state;
this.intervalEl = document.querySelector('#interval');
this.model.onSetInterval = () => this.setAutoReloadInterval(); this.model.onSetInterval = () => this.setAutoReloadInterval();
this.bindAll(); this.bindAll();
this.onChanged('mapApi', (api) => { this.onChanged('mapApi', (api) => {
@ -46,7 +45,6 @@ export default class ConfigViewModel extends ViewModel {
ConfigViewModel.reload(); ConfigViewModel.reload();
}); });
this.onChanged('interval', (interval) => { this.onChanged('interval', (interval) => {
this.intervalEl.innerHTML = interval.toString();
uUtils.setCookie('interval', interval); uUtils.setCookie('interval', interval);
}); });
} }

View File

@ -45,6 +45,8 @@ export default class TrackViewModel extends ViewModel {
autoReload: false, autoReload: false,
/** @type {string} */ /** @type {string} */
inputFile: false, inputFile: false,
/** @type {string} */
summary: false,
// click handlers // click handlers
/** @type {function} */ /** @type {function} */
onReload: null, onReload: null,
@ -58,7 +60,6 @@ export default class TrackViewModel extends ViewModel {
this.setClickHandlers(); this.setClickHandlers();
/** @type HTMLSelectElement */ /** @type HTMLSelectElement */
const listEl = document.querySelector('#track'); const listEl = document.querySelector('#track');
this.summaryEl = document.querySelector('#summary');
this.importEl = document.querySelector('#input-file'); this.importEl = document.querySelector('#input-file');
this.select = new uSelect(listEl); this.select = new uSelect(listEl);
this.state = state; this.state = state;
@ -279,7 +280,7 @@ export default class TrackViewModel extends ViewModel {
renderSummary() { renderSummary() {
if (!this.state.currentTrack || !this.state.currentTrack.hasPositions) { if (!this.state.currentTrack || !this.state.currentTrack.hasPositions) {
this.summaryEl.innerHTML = ''; this.model.summary = '';
return; return;
} }
const last = this.state.currentTrack.positions[this.state.currentTrack.length - 1]; const last = this.state.currentTrack.positions[this.state.currentTrack.length - 1];
@ -290,12 +291,12 @@ export default class TrackViewModel extends ViewModel {
const dateTime = uUtils.getTimeString(date); const dateTime = uUtils.getTimeString(date);
const dateString = (date.toDateString() !== today.toDateString()) ? `${dateTime.date}<br>` : ''; const dateString = (date.toDateString() !== today.toDateString()) ? `${dateTime.date}<br>` : '';
const timeString = `${dateTime.time}<span style="font-weight:normal">${dateTime.zone}</span>`; const timeString = `${dateTime.time}<span style="font-weight:normal">${dateTime.zone}</span>`;
this.summaryEl.innerHTML = ` this.model.summary = `
<div class="menu-title">${lang.strings['latest']}:</div> <div class="menu-title">${lang.strings['latest']}:</div>
${dateString} ${dateString}
${timeString}`; ${timeString}`;
} else { } else {
this.summaryEl.innerHTML = ` this.model.summary = `
<div class="menu-title">${lang.strings['summary']}</div> <div class="menu-title">${lang.strings['summary']}</div>
<div><img class="icon" alt="${lang.strings['tdistance']}" title="${lang.strings['tdistance']}" src="images/distance.svg"> ${lang.getLocaleDistanceMajor(last.totalMeters, true)}</div> <div><img class="icon" alt="${lang.strings['tdistance']}" title="${lang.strings['tdistance']}" src="images/distance.svg"> ${lang.getLocaleDistanceMajor(last.totalMeters, true)}</div>
<div><img class="icon" alt="${lang.strings['ttime']}" title="${lang.strings['ttime']}" src="images/time.svg"> ${lang.getLocaleDuration(last.totalSeconds)}</div>`; <div><img class="icon" alt="${lang.strings['ttime']}" title="${lang.strings['ttime']}" src="images/time.svg"> ${lang.getLocaleDuration(last.totalSeconds)}</div>`;

View File

@ -54,7 +54,7 @@ export default class ViewModel {
* Creates bidirectional binding between model property and DOM element. * Creates bidirectional binding between model property and DOM element.
* For input elements model property value change triggers change in DOM element and vice versa. * For input elements model property value change triggers change in DOM element and vice versa.
* In case of anchor element binding is one way. Model property is callback that will receive click event. * In case of anchor element binding is one way. Model property is callback that will receive click event.
* @param key * @param {string} key
*/ */
bind(key) { bind(key) {
const dataProp = 'bind'; const dataProp = 'bind';
@ -63,34 +63,67 @@ export default class ViewModel {
const name = element.dataset[dataProp]; const name = element.dataset[dataProp];
if (name === key) { if (name === key) {
if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) {
let prop = 'value'; this.onChangeBind(element, key);
let getVal = (val) => val;
if (element.type === 'checkbox') {
prop = 'checked';
getVal = (val) => !!val;
}
element.addEventListener('change', () => {
this._model[key] = element[prop];
});
uObserve.observe(this.model, key, (val) => {
val = getVal(val);
if (element[prop] !== val) {
element[prop] = val;
}
});
} else if (element instanceof HTMLAnchorElement) { } else if (element instanceof HTMLAnchorElement) {
element.addEventListener('click', (event) => { this.onClickBind(element, key);
if (typeof this._model[key] !== 'function') { } else {
throw new Error(`Property ${key} is not a callback`); this.viewUpdateBind(element, key);
}
this._model[key](event);
event.preventDefault();
});
} }
} }
}); });
} }
/**
* One way bind: view element click event to view model event handler
* @param {HTMLAnchorElement} element
* @param {string} key
*/
onClickBind(element, key) {
element.addEventListener('click', (event) => {
if (typeof this._model[key] !== 'function') {
throw new Error(`Property ${key} is not a callback`);
}
this._model[key](event);
event.preventDefault();
});
}
/**
* Two way bind: view element change event to view model property
* @param {(HTMLInputElement|HTMLSelectElement)} element
* @param {string} key
*/
onChangeBind(element, key) {
let prop = 'value';
let getVal = (val) => val;
if (element.type === 'checkbox') {
prop = 'checked';
getVal = (val) => !!val;
}
element.addEventListener('change', () => {
this._model[key] = element[prop];
});
uObserve.observe(this.model, key, (val) => {
val = getVal(val);
if (element[prop] !== val) {
element[prop] = val;
}
});
}
/**
* One way bind: view model property to view element content
* @param {HTMLElement} element
* @param {string} key
*/
viewUpdateBind(element, key) {
uObserve.observe(this.model, key, (content) => {
if (element.innerHTML !== content) {
element.innerHTML = content;
}
});
}
/** /**
* @param {string} property * @param {string} property
* @param {ObserveCallback} callback * @param {ObserveCallback} callback
@ -106,5 +139,4 @@ export default class ViewModel {
unsubscribe(property, callback) { unsubscribe(property, callback) {
uObserve.unobserve(this.model, property, callback); uObserve.unobserve(this.model, property, callback);
} }
} }

View File

@ -52,7 +52,7 @@ describe('ConfigViewModel tests', () => {
const fixture = `<div id="fixture"> const fixture = `<div id="fixture">
<div class="section"> <div class="section">
<a id="set-interval" data-bind="onSetInterval"><span id="interval">${config.interval}</span></a> <a id="set-interval" data-bind="onSetInterval"><span id="interval" data-bind="interval">${config.interval}</span></a>
</div> </div>
<div> <div>
<label for="api">api</label> <label for="api">api</label>

View File

@ -65,7 +65,7 @@ describe('TrackViewModel tests', () => {
<input id="auto-reload" type="checkbox" data-bind="autoReload"> <input id="auto-reload" type="checkbox" data-bind="autoReload">
<a id="force-reload" data-bind="onReload">reload</a> <a id="force-reload" data-bind="onReload">reload</a>
</div> </div>
<div id="summary" class="section"></div> <div id="summary" class="section" data-bind="summary"></div>
<div class="section"> <div class="section">
<a id="export-kml" class="menu-link" data-bind="onExportKml">kml</a> <a id="export-kml" class="menu-link" data-bind="onExportKml">kml</a>
<a id="export-gpx" class="menu-link" data-bind="onExportGpx">gpx</a> <a id="export-gpx" class="menu-link" data-bind="onExportGpx">gpx</a>
@ -113,7 +113,6 @@ describe('TrackViewModel tests', () => {
const trackViewModel = new TrackViewModel(state); const trackViewModel = new TrackViewModel(state);
// then // then
expect(trackViewModel).toBeInstanceOf(ViewModel); expect(trackViewModel).toBeInstanceOf(ViewModel);
expect(trackViewModel.summaryEl).toBeInstanceOf(HTMLDivElement);
expect(trackViewModel.importEl).toBeInstanceOf(HTMLInputElement); expect(trackViewModel.importEl).toBeInstanceOf(HTMLInputElement);
expect(trackViewModel.select.element).toBeInstanceOf(HTMLSelectElement); expect(trackViewModel.select.element).toBeInstanceOf(HTMLSelectElement);
expect(trackViewModel.state).toBe(state); expect(trackViewModel.state).toBe(state);

View File

@ -152,6 +152,22 @@ describe('ViewModel tests', () => {
expect(model[propertyFunction].calls.mostRecent().args[0].target).toBe(anchorElement); expect(model[propertyFunction].calls.mostRecent().args[0].target).toBe(anchorElement);
}); });
it('should bind DOM div element to model property', () => {
// given
/** @type {HTMLDivElement} */
const divElement = uUtils.nodeFromHtml(`<div data-bind="${propertyString}"></div>`);
document.body.appendChild(divElement);
const newContent = '<span>new value</span>';
// when
vm.bind(propertyString);
// then
expect(uObserve.isObserved(vm.model, propertyString)).toBe(true);
// when
model[propertyString] = newContent;
// then
expect(divElement.innerHTML).toBe(newContent);
});
it('should start observing model property', () => { it('should start observing model property', () => {
// given // given
// eslint-disable-next-line no-empty-function // eslint-disable-next-line no-empty-function