diff --git a/js/src/viewmodel.js b/js/src/viewmodel.js
new file mode 100644
index 0000000..6a7fbc4
--- /dev/null
+++ b/js/src/viewmodel.js
@@ -0,0 +1,110 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import uObserve from './observe.js';
+
+/**
+ * @class ViewModel
+ * @property {Object} model
+ */
+export default class ViewModel {
+
+ /**
+ * @param {Object} model
+ */
+ constructor(model) {
+ this._model = model;
+ }
+
+ /**
+ * @return {Object}
+ */
+ get model() {
+ return this._model;
+ }
+
+ /**
+ * Apply bindings for model properties
+ */
+ bindAll() {
+ for (const key in this._model) {
+ if (this._model.hasOwnProperty(key)) {
+ this.bind(key);
+ }
+ }
+ }
+
+ /**
+ * Creates bidirectional binding between model property and DOM element.
+ * 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.
+ * @param key
+ */
+ bind(key) {
+ const dataProp = 'bind';
+ const observers = document.querySelectorAll(`[data-${dataProp}]`);
+ observers.forEach(/** @param {HTMLElement} element */ (element) => {
+ const name = element.dataset[dataProp];
+ if (name === key) {
+ if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) {
+ 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;
+ }
+ });
+ } else if (element instanceof HTMLAnchorElement) {
+ 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();
+ });
+ }
+ }
+ });
+ }
+
+ /**
+ * @param {string} property
+ * @param {ObserveCallback} callback
+ */
+ onChanged(property, callback) {
+ uObserve.observe(this.model, property, callback);
+ }
+
+ /**
+ * @param {string} property
+ * @param {ObserveCallback} callback
+ */
+ unsubscribe(property, callback) {
+ uObserve.unobserve(this.model, property, callback);
+ }
+
+}
diff --git a/js/test/viewmodel.test.js b/js/test/viewmodel.test.js
new file mode 100644
index 0000000..b889da9
--- /dev/null
+++ b/js/test/viewmodel.test.js
@@ -0,0 +1,179 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import ViewModel from '../src/viewmodel.js';
+import uObserve from '../src/observe.js';
+import uUtils from '../src/utils.js';
+
+describe('ViewModel tests', () => {
+
+ let model;
+ let vm;
+ const propertyString = 'propertyString';
+ const propertyStringVal = '1';
+ const propertyBool = 'propertyBool';
+ const propertyBoolVal = false;
+ const propertyFunction = 'propertyFunction';
+
+ beforeEach(() => {
+ model = {};
+ model[propertyString] = propertyStringVal;
+ model[propertyBool] = propertyBoolVal;
+ // eslint-disable-next-line no-empty-function
+ model[propertyFunction] = () => {};
+ vm = new ViewModel(model);
+ });
+
+ it('should create instance with model as parameter', () => {
+ // when
+ const viewModel = new ViewModel(model);
+ // then
+ expect(viewModel.model).toBe(model);
+ });
+
+ it('should call bind method with each model property as parameter', () => {
+ // given
+ spyOn(ViewModel.prototype, 'bind');
+ // when
+ vm.bindAll();
+ // then
+ expect(ViewModel.prototype.bind).toHaveBeenCalledTimes(3);
+ expect(ViewModel.prototype.bind).toHaveBeenCalledWith(propertyString);
+ expect(ViewModel.prototype.bind).toHaveBeenCalledWith(propertyBool);
+ });
+
+ it('should set up binding between model property and DOM input element', () => {
+ // given
+ /** @type {HTMLInputElement} */
+ const inputElement = uUtils.nodeFromHtml(``);
+ document.body.appendChild(inputElement);
+ // when
+ vm.bind(propertyString);
+ // then
+ expect(uObserve.isObserved(vm.model, propertyString)).toBe(true);
+ expect(uObserve.isObserved(vm.model, propertyBool)).toBe(false);
+ expect(vm.model[propertyString]).toBe(propertyStringVal);
+ expect(inputElement.value).toBe(propertyStringVal);
+ // when
+ inputElement.value = propertyStringVal + 1;
+ inputElement.dispatchEvent(new Event('change'));
+ // then
+ expect(vm.model[propertyString]).toBe(propertyStringVal + 1);
+ // when
+ vm.model[propertyString] = propertyStringVal;
+ // then
+ expect(inputElement.value).toBe(propertyStringVal);
+ });
+
+ it('should set up binding between model property and DOM select element', () => {
+ // given
+ const html = ``;
+ /** @type {HTMLInputElement} */
+ const selectElement = uUtils.nodeFromHtml(html);
+ document.body.appendChild(selectElement);
+ // when
+ vm.bind(propertyString);
+ // then
+ expect(uObserve.isObserved(vm.model, propertyString)).toBe(true);
+ expect(uObserve.isObserved(vm.model, propertyBool)).toBe(false);
+ expect(vm.model[propertyString]).toBe(propertyStringVal);
+ expect(selectElement.value).toBe(propertyStringVal);
+ // when
+ selectElement.value = '';
+ selectElement.dispatchEvent(new Event('change'));
+ // then
+ expect(vm.model[propertyString]).toBe('');
+ // when
+ vm.model[propertyString] = propertyStringVal;
+ // then
+ expect(selectElement.value).toBe(propertyStringVal);
+ });
+
+ it('should set up binding between model property and DOM checkbox element', () => {
+ // given
+ /** @type {HTMLInputElement} */
+ const checkboxElement = uUtils.nodeFromHtml(``);
+ document.body.appendChild(checkboxElement);
+ checkboxElement.checked = false;
+ // when
+ vm.bind(propertyBool);
+ // then
+ expect(uObserve.isObserved(vm.model, propertyBool)).toBe(true);
+ expect(uObserve.isObserved(vm.model, propertyString)).toBe(false);
+ expect(vm.model[propertyBool]).toBe(propertyBoolVal);
+ expect(checkboxElement.checked).toBe(propertyBoolVal);
+ // when
+ const newValue = !propertyBoolVal;
+ checkboxElement.checked = newValue;
+ checkboxElement.dispatchEvent(new Event('change'));
+ // then
+ expect(vm.model[propertyBool]).toBe(newValue);
+ // when
+ vm.model[propertyBool] = !newValue;
+ // then
+ expect(checkboxElement.checked).toBe(!newValue);
+ });
+
+ it('should bind DOM anchor element click event to model property', () => {
+ // given
+ /** @type {HTMLAnchorElement} */
+ const anchorElement = uUtils.nodeFromHtml(``);
+ document.body.appendChild(anchorElement);
+ spyOn(model, propertyFunction);
+ // when
+ vm.bind(propertyFunction);
+ // then
+ expect(uObserve.isObserved(vm.model, propertyFunction)).toBe(false);
+ expect(vm.model[propertyFunction]).toBeInstanceOf(Function);
+ // when
+ anchorElement.dispatchEvent(new Event('click'));
+ // then
+ expect(model[propertyFunction]).toHaveBeenCalledTimes(1);
+ expect(model[propertyFunction]).toHaveBeenCalledWith(jasmine.any(Event));
+ expect(model[propertyFunction].calls.mostRecent().args[0].target).toBe(anchorElement);
+ });
+
+ it('should start observing model property', () => {
+ // given
+ // eslint-disable-next-line no-empty-function
+ const callback = () => {};
+ spyOn(uObserve, 'observe');
+ // when
+ vm.onChanged(propertyString, callback);
+ // then
+ expect(uObserve.observe).toHaveBeenCalledTimes(1);
+ expect(uObserve.observe).toHaveBeenCalledWith(vm.model, propertyString, callback);
+ });
+
+ it('should stop observing model property', () => {
+ // given
+ // eslint-disable-next-line no-empty-function
+ const callback = () => {};
+ spyOn(uObserve, 'unobserve');
+ // when
+ vm.unsubscribe(propertyString, callback);
+ // then
+ expect(uObserve.unobserve).toHaveBeenCalledTimes(1);
+ expect(uObserve.unobserve).toHaveBeenCalledWith(vm.model, propertyString, callback);
+ });
+
+});