function copyTemplateInto(templateId, dstId) { let t = document.getElementById(templateId) if (!t) { throw new Error(`tag id=${templateId} not found`) } //let dst = document.getElementById(dstId) //if (!dst) { // throw new Error(`tag id=${dstId} not found`) //} let dst = getElemSafe('container', dstId) let node = t.content.cloneNode(true) dst.innerHTML = '' dst.appendChild(node) } function Router() { let u = new URL(location.href) // Базовый url вида http://localhost:8081 this._origin = u.origin this._routes = {} } // goto . Аргумент - полный URL, либо path + searchParams вида '/configs?deviceID=7'. // Полный URL обычно получаем при чтении текущего адрес location.href, либо при чтении // ссылки somelink.href. Path получим если меню реализовать в виде ListModel, где // ключем очевидно будет path, а не полный URL. Router.prototype.goto = function(url, params) { if (!url) { throw new Error(`Wrong 'url' argument: ${url}`) } console.log('goto:', url) if (url.startsWith('/')) { url = this._origin + url } let u = new URL(url) let r = this._routes[u.pathname] if (!r) { return } let path = u.pathname if (params) { //let searchParams = new URLSearchParams() let searchParams = u.searchParams for (let name in params) { searchParams.set(name, params[name]) } let queryString = searchParams.toString() if (queryString !== '') { path += '?' + queryString } } else { if (u.searchParams.toString()) { //console.log('serachParams:', u.searchParams.toString()) path += '?' + u.searchParams } } path += u.hash url = this._origin + path console.log('route:', r) console.log('group:', this._currentGroup) if (r.group != this._currentGroup) { if (this._currentGroup) { if (r.group.base != this._currentGroup.base) { location.href = url return } } this._currentGroup = r.group if (r.group.init) { //console.log('call init and wait') r.group.init((ctx) => { this._ctx = ctx this._processRoute(r, url, path) }) } else { this._ctx = {} this._processRoute(r, url, path) } } else { this._processRoute(r, url, path) } } Router.prototype._processRoute = function(r, url, path) { if (r.group.toggle) { r.group.toggle(this._ctx, r.data) } let state = {} state['omg.router.url'] = url if (url != location.href) { console.log('pushState:', url) // В первую очередь проверка позволяет не добавлять в историю страницу, // которая только что загрузилась (реакция на событие DOMContentLoaded) history.pushState(state, "", path) } else { console.log('replaceState:', url) history.replaceState(state, "", path) } r.func(this._ctx) } Router.prototype.add = function(routeGroup) { let group = { base: routeGroup.base, init: routeGroup.init, toggle: routeGroup.toggle } routeGroup.routes.forEach((route) => { if (!route.path) { throw new Error(`Empty path in route: ${JSON.stringify(route)}`) } this._routes[route.path] = { func: route.func, data: route.data, group: group } }) } // Читает path из URL и запускает соответсвующий обработчик Router.prototype.init = function() { document.addEventListener('DOMContentLoaded', () => { let links = document.getElementsByClassName('route') //console.log(links) for (let i=0; i { let u = new URL(link.href) let r = this._routes[u.pathname] if (r) { // if current path in routes e.preventDefault() //console.log('CLICK:', link.href) this.goto(link.href) } else { console.log("not in routes") } }) } window.addEventListener('popstate', (e) => { if (!e.state) { return } let url = e.state['omg.router.url'] //console.log('popstate:', url) this.goto(url) }) this.goto(location.href) }) } /** * A opt * @typedef {Object} Opt * @property {string} key - ключ. * @property {Element} cover - обложка */ /** * * @param {Opt} opt - объект */ /* opt - Опции - container {Element} HTML-элемент, контейнер для элементов модели - cover * {Element} HTML-элемент, внешний контейнер для всей модели. Нужен для одного кейса - когда необходимо показать сообщение об ошибке (например, ошибка загрузки по API), а добавление ошибки после container невозможна. Например, container - это TBODY. По умолчанию - нет. - unique * {func} Функция вычисляет hashcode для каждого элемента модели и не позволяет добавлять в модель дубликаты. Применяется в методах add() и updateSelected() - activeClass * {string} CSS класс для выбранного (кликом мыши) элемента модели. По умолчанию 'selected'. - errorClass * {string} CSS класс для сообщения об ошибке (например, при загрузке модели по API или удалении элементов по API). По умолчанию 'errmsg'. - messageClass * {string} CSS класс для сообщения о пустой модели. - emptyMessage * {string} Сообщение, которое нужно показать если setList устанавливает пустую модель. - key * {string} Имя id-поля объекта. Используется в единственном месте - для AJAX-запроса на удаление элемента. - btnRemove * {Element} HTML-кнопка для удаления элементов модели. Если объявлена removeFunc - удаление будет происходить AJAX запросами к API, иначе сразу из модели. Если по умолчанию кнопка disabled - модель сама будет включать и отключать кнопку в зависимости от проставленных галочек напротив элемментов модели. - template {Object} Настройки шаблона для рендера элемента модели - id {string} ID template элемента - renders * {Object} Рендер-функции шаблонов (смотри Template) - sort * {Object} Сортировка элементов модели - key * {string} имя поля по которому сортируем - func * {func} собственная функция сортировки - reverse * {boolean} флаг, показывает что все нужно сортировать наоборот По умолчанию не сортирует, а элементы добавляет в конец списка. Если указать reverse=true, добавлять элементы будет в начало списка. Сортировка используется в методах add() и updateSelected() - ajax * {Object} Настройки для загрузки модели и/или удаления элементов модели по API - api {API} смотри API - listFunc * {string} имя API метода для загрузки модели - removeFunc * {string} имя API метода для удаления элемента модели по ID. Имя поля объекта, который хранит ID задается в опции key. */ function ListModel(opt) { Emiter.call(this) this._container = getElemSafe('container', opt.container) // Внешний блок к которому можно добавлять сообщения об ошибках. // Нужен для стандартной ситуации - в HTML разметке есть таблица с THEAD и TBODY. // TBODY - это контейнер для добавления элементов модели. Добавить к нему DIV с // сообщением об ошибке нельзя. Нужна ссылка на TABLE, которую передаем через // опцию 'cover'. // Если cover не указан, то cover = container if (opt.cover) { this._cover = getElemSafe('cover', opt.cover) } else { this._cover = this._container } this._defaultChild = this._container.firstChild this._key = opt.key // Для отслеживания уникальности элементов модели. Сигнатура: func(obj) hashcode // Хешкод добавляется в _hashcodes this._unique = opt.unique this._preFunc = opt.preFunc this._activeClass = opt.activeClass || 'selected' this._errorClass = opt.errorClass || 'errmsg' this._messageClass = opt.messageClass if (opt.btnRemove) { this._btnRemove = getElemSafe('btnRemove', opt.btnRemove) this._btnRemove.addEventListener('click', () => { if (this._removeFunc) { this._btnRemove.disabled = true this._disableCheckboxes() // Удаляем сообщение об ошибке от предыдущего API запроса if (this._divRemoveError) { this._divRemoveError.remove() } this._removing = true // удаляем элементы по очереди this._removeNext() } else { this.removeChecked() } }) this._isBtnRemoveDisabledByDefault = this._btnRemove.disabled this._isBtnRemoveHiddenByDefault = this._btnRemove.hidden if (this._isBtnRemoveHiddenByDefault) { this.on('checkListChanged', () => { if (this._removing) { return } if (this.countChecked() > 0) { this._btnRemove.hidden = false } else { this._btnRemove.hidden = true } }) } else if (this._isBtnRemoveDisabledByDefault) { this.on('checkListChanged', () => { if (this._removing) { return } if (this.countChecked() > 0) { this._btnRemove.disabled = false } else { this._btnRemove.disabled = true } }) } } let ajax = opt.ajax if (ajax) { this._api = ajax.api if (!ajax.api) { throw new Error("'ajax.api' option is required") } this._listFunc = ajax.listFunc this._removeFunc = ajax.removeFunc if (this._removeFunc) { if (!this._key) { throw new Error("'ajax.removeFunc' option is requires 'key' option") } } } this._template = new Template({ id: opt.template.id, renders: opt.template.renders, }); // Сортировка (опционально) let by = opt.sort if (by) { if (by.key) { if (by.reverse) { this._cmp = function(a, b) { if (a[by.key] < b[by.key]) { return -1 } return 1 } } else { this._cmp = function(a, b) { if (a[by.key] < b[by.key]) { return 1 } return -1 } } } else if (by.func) { if (by.reverse) { this._cmp = (a, b) => { return by.func(b, a) } } else { this._cmp = by.func } } else { if (by.reverse) { // 3й кейс - добавляем в начало this._isReverse = true } } } this._arr = [] this._hashcodes = new Map() } ListModel.prototype = Object.create(Emiter.prototype) ListModel.prototype.constructor = ListModel ListModel.prototype.isAjax = function() { return this._api !== undefined } ListModel.prototype.disable = function() { if (this._btnRemove) { this._btnRemove.disabled = true } this._disableCheckboxes() } ListModel.prototype.countChecked = function() { let q = 0 this._arr.forEach((item) => { if (item.checkbox && item.checkbox.checked) { q++ } }) return q } ListModel.prototype.listChecked = function() { let list = [] this._arr.forEach((item) => { if (item.checkbox.checked) { list.push(item.obj) } }) return list } ListModel.prototype.removeChecked = function() { while (true) { let idx = this._arr.findIndex(item => item.checkbox.checked) if (idx == -1) { return } this.remove(idx) } } ListModel.prototype._disableCheckboxes = function() { this._arr.forEach((item) => { item.checkbox.disabled = true }) } ListModel.prototype._enableCheckboxes = function() { this._arr.forEach((item) => { item.checkbox.disabled = false }) } ListModel.prototype._clear = function() { // Для отслеживания уникальности элементов модели (если объявлена unique функция) this._hashcodes = new Map() this._container.innerHTML = '' this._arr = [] this._selectedItem = null if (this._divLoadError) { this._divLoadError.remove() } if (this._divMessage) { this._divMessage.remove() } // Блок с сообщением об ошибке удаления (по API) if (this._divRemoveError) { this._divRemoveError.remove() } if (this._btnRemove) { this._btnRemove.disabled = this._isBtnRemoveDisabledByDefault this._btnRemove.hidden = this._isBtnRemoveHiddenByDefault } } ListModel.prototype.clear = function() { let wasChecked = this.countChecked() let selected = this._selectedItem this._clear() this._setEmptyMessageIfNeeded() if (selected) { this.emit('select') } if (wasChecked > 0) { this.emit('checkListChanged') } this.emit('listChanged') } ListModel.prototype.setIsDisabledFunc = function(f) { this._isDisabledFunc = f } ListModel.prototype.setList = function(list) { //console.trace() // Запоминаем сколько элементов отмечено и выделено, чтобы отправить события // замены списка let wasChecked = this.countChecked() let selected = this._selectedItem this._clear() if (Array.isArray(list) && list.length > 0) { list.forEach(obj => this._add(obj)) if (this._isDisabledFunc) { if (this._isDisabledFunc()) { this.disable() } } } else { this._setEmptyMessageIfNeeded() } if (selected) { this.emit('select') } if (wasChecked > 0) { this.emit('checkListChanged') } this.emit('listChanged') this.emit('setList') } ListModel.prototype._add = function(obj) { if (this._preFunc) { this._preFunc(obj) } if (this._arr.length == 0) { this._container.innerHTML = '' } // console.log('model.add:', obj) // проверка на уникальность + сохраняем hashcode let hashcode; if (this._unique) { hashcode = this._unique(obj) if (this._hashcodes.get(hashcode)) { return } } let idx = 0; // добавляем в начало массива. 2 кейса: // - нет cmp функции и reverse=true // - есть cmp функция, но модель пустая if (this._cmp) { // ищем индекс, по которому нужно вставить элемент for (; idx < this._arr.length; idx++) { if (this._cmp(obj, this._arr[idx].obj) == -1) { break } } } else if (!this._isReverse) { // добавляем в конец массива idx = this._arr.length } let item = this._renderObj(obj) if (this._unique) { item.hashcode = hashcode this._hashcodes.set(hashcode, true) } // вставляем в массив this._arr.splice(idx, 0, item) // вставляем в DOM this._container.insertBefore(item.elem, this._container.children[idx]) return true } ListModel.prototype.add = function(obj) { //console.log('add to model:', obj) let ok = this._add(obj) if (ok) { this.emit('listChanged') return true } } // keyFunc - return true if found, updateFunc(tags, obj, upd) ListModel.prototype.partialUpdateBy = function(updateFunc, keyFunc) { for (let idx=0; idx item != si) this._container.removeChild(si.elem) let idx = 0 // ищем индекс, по которому нужно вставить элемент for (; idx < this._arr.length; idx++) { if (this._cmp(newobj, this._arr[idx].obj) == -1) { break } } // рендерим item = this._renderObj(newobj, checked) // вставляем в массив this._arr.splice(idx, 0, item) // вставляем в DOM this._container.insertBefore(item.elem, this._container.children[idx]) } else { // нужно сохранить позицию элемента в списке item = this._renderObj(newobj, checked) this._container.replaceChild(item.elem, si.elem) let idx = this._arr.findIndex(x => x == si) this._arr[idx] = item } if (this._unique) { item.hashcode = hashcode if (hashcode != si.hashcode) { // удаляем старый, добавляем новый this._hashcodes.delete(si.hashcode) this._hashcodes.set(hashcode, true) } } if (si == this._selectedItem) { this._selectedItem = item } } ListModel.prototype.updateSelected = function(newobj) { let si = this._selectedItem if (!si) { return } this._update(newobj, si) // /console.log('UPDATED:', this._arr) this._selectedItem = null this.emit('select') this.emit('listChanged') return true } // При обновлении элемента модели нужно сохранить галочку checked. ListModel.prototype._renderObj = function(obj, checked) { let r = this._template.render(obj) let item = { elem: r.elem, tags: r.tags, obj: obj } // Область для select кликов if (r.tags.select) { item.select = r.tags.select item.select.style.cursor = 'pointer' item.select.addEventListener('click', () => this._select(item)) } // Чекбоксы let checkbox = r.tags.id if (checkbox) { checkbox.setAttribute('type', 'checkbox') if (checked) { checkbox.checked = true } checkbox.addEventListener('change', () => this.emit('checkListChanged')) item.checkbox = checkbox } return item } /** * Выделяет текущий элемент по индексу * @param {number} idx индекс элемента */ ListModel.prototype.select = function(idx) { let item = this._arr[idx] if (!item) { return } this._select(item) } // Выделяет текущий элемент по индексу ListModel.prototype.unselect = function(silent) { if (this._selectedItem) { if (this._selectedItem.select) { this._selectedItem.select.classList.remove(this._activeClass) } this._selectedItem = null if (!silent) { this.emit('select') } } } // Выделяет текущий элемент по индексу ListModel.prototype.selectBy = function(f) { for (let idx=0; idx item == this._selectedItem) if (idx != -1) { this.remove(idx) } } // функция должна вернуть true. Удялет только 1 элемент ListModel.prototype.removeBy = function(f) { for (let i=0; i arr.push(item.obj)) return arr } ListModel.prototype.getSelected = function() { if (this._selectedItem) { return this._selectedItem.obj } } ListModel.prototype.size = function() { return this._arr.length } // FIX - либо сделать on('setList') либо встроить в setList - selectBy и параметр из URL // load - отдельный метод load нужен для такого случая - юзер выбирает // из выпадающего списка прибор, а для уже для конкретного прибора загружается // список конфигураций и строится модель ListModel.prototype.load = function(opt) { if (!this._api) { //return throw new Error('Try load non ajax ListModel') } this._opt = opt || {} // В случае повторных ошибок при попытке загрузить модель - можем получить // список ошибок, поэтому предыдущую удаляем if (this._divLoadError) { this._divLoadError.remove() } this._api.req({ "func": this._opt.listFunc || this._listFunc, "data": this._opt.data, "onError": (err) => { let anchor; if (this._divMessage) { anchor = this._divMessage } else { anchor = this._cover } this._divLoadError = insertError({ after: anchor, err: err, class: this._errorClass }) if (this._opt.onError) { this._opt.onError(err) } }, "onSuccess": (resp) => { this.setList(resp) // Success callback нужен для следующего кейса: после загрузки (и // setList !) модели берем ID из URL и делаем selectBy if (this._opt.onSuccess) { this._opt.onSuccess(resp) } } }) } // Перезагружаем модель. Зачем нужен такой метод? Простой кейс - после submit формы на // сервер, Formobj перезагружает привязанную модель. Но, например, в случае добавления // новой конфигурации к прибору - модель у нас не простая, а привязана к deviceID. ListModel.prototype.reload = function() { this.load(this._opt) } ListModel.prototype.setError = function(err) { let anchor; if (this._divMessage) { anchor = this._divMessage } else if (this._divLoadError) { anchor = this._divLoadError } else { anchor = this._cover } return insertError({ after: anchor, err: err, class: this._errorClass }) } ListModel.prototype._removeNext = function() { let idx = this._arr.findIndex((item) => item.checkbox.checked) if (idx == -1) { // не нашли элемент с галочкой - все удалены this._whenRemoveEnds() } else { // ВАЖНО! // Вместо id отправляется объект вида {configID: 231} //let p = {} //p[this._key] = this._arr[idx].obj[this._key] let p = this._arr[idx].obj[this._key] this._api.req({ "func": this._removeFunc, "data": p, "onError": err => { this._divRemoveError = insertError({ before: this._btnRemove, err: err, class: this._errorClass }) this._whenRemoveEnds() }, "onSuccess": () => { this.remove(idx) this._removeNext() } }) } } ListModel.prototype._whenRemoveEnds = function() { this._removing = false if (this.countChecked() > 0) { // после ошибки могли this._btnRemove.disabled = false this._btnRemove.hidden = false } else { this._btnRemove.disabled = this._isBtnRemoveDisabledByDefault this._btnRemove.hidden = this._isBtnRemoveHiddenByDefault } this._enableCheckboxes() this._setEmptyMessageIfNeeded() } ListModel.prototype._setEmptyMessageIfNeeded = function() { console.log(this._arr.length, this._defaultChild) if (this._arr.length == 0) { if (this._defaultChild) { this._container.appendChild(this._defaultChild) } } } /////////////// /* Шаблонизатор нужен в 2 случаях: - для отрисовки модели - для построения интерфейса. Пример - список конфигурационных параметров с полями ввода Модель можно сделать отдельной или встроенной в Select, Div-list и т.д. Методы модели: - добавить элемент - удалить элемент - выделить элемент - снять выделение - получить выделенный объект - сортировка элементов списка? - добавление / удаление элементов без перерисовки всего списка, чтобы сохранились выделения других элементов или взаимодействия с DOM в других элементах ? Для рендера отдельных элементов в шаблонизаторе нужно реализовать 2 метода: - рендер, который возвращает отдельно HTMLElement - рендер, который проходит по списку объектов, для каждого рендерит HTMLElement, и добавляет в контейнер */ function Template(opt) { if (opt.id) { this._loadTemplate(opt.id) } else { throw new Error("'id' option is required") } this._renders = opt.renders || {} this._templateID = opt.id } Template.prototype._loadTemplate = function(id) { let tag = document.getElementById(id) if (!tag) { throw new Error(`Template id=${id} not found`) } if (tag.tagName != 'TEMPLATE') { throw new Error(`Tag id=${id} must be template tag`) } return tag.content } /* Template.prototype.fromString = function(templateString) { let tag = document.createElement('template') tag.innerHTML = templateString this._frag = tag.content return this } */ Template.prototype.renderTo = function(list, container) { container.innerHTML = '' if (Array.isArray(list)) { list.forEach((item)=>{ let r = this._render(this._templateID, item) container.appendChild(r.elem) }) } } // Возвращает объект вида {elem: Element, tags: tags object}, // где elem - это отрендеренный HTML элемент (НЕ DocumentFragment), // а tags - это карта быстрых ссылок на HTML элементы шаблона, отмеченные // аттрибутом data-id="name" Template.prototype.render = function(item) { return this._render(this._templateID, item) } Template.prototype._render = function(templateID, obj) { let getvalue = function(obj, path) { let v = obj path.split('.').forEach((prop) => { if (v === undefined) { throw new Error(`Cannot get property '${prop}' of undefined in chain '${path}'`) } v = v[prop] }) return v } let frag = this._loadTemplate(templateID) frag = frag.cloneNode(true) let elems = frag.querySelectorAll('[data-prop], [data-id], [data-value]') let tags = {} for (let elem of elems) { let id = elem.dataset.id let prop = elem.dataset.prop let nestedTemplateID = elem.dataset.template let value = elem.dataset.value if (id) { tags[id] = elem } if (prop) { if (nestedTemplateID) { let list = getvalue(obj, prop) //console.log('prop:', prop, '/ nestedTemplateID:', nestedTemplateID, '/ list:', list, ' /elem:', elem) if (Array.isArray(list)) { list.forEach((item)=>{ let r = this._render(nestedTemplateID, item) elem.appendChild(r.elem) }) } } else { let x = getvalue(obj, prop) if (x !== undefined) { elem.textContent = x } //console.log('prop:', prop, '/ x:', x, ' /elem:', elem) } } if (value) { let x = getvalue(obj, value) if (x !== undefined) { elem.value = x } } } // /console.log(tags, elems, obj) let f = this._renders[templateID] if (f) { f(tags, obj) } return { elem: frag.firstElementChild, tags: tags } } /* метод onChange нужен чтобы: - показывать / скрывать кнопку удалить (если выделен или нет элемент списка) - отображать детали по выделенному элементу. Например, клик по названию в списке - в соседней форме отображаем все поля (для редактирования). Сохранять elem нужно, чтобы убрать activeClass при выборе другого элемента Сохранять obj нужно чтобы, например, при нажатии кнопки "удалить" знать какой объект удаляем. */ ///////////////////// /* Options: url data responseType (по умолчанию 'json') timeout (milliseconds) onDone (errorMessage, obj) data - параметр передается в метод xhr.send() как есть errorMessage - строка вида "statusCode: statusText" для HTTP кодов отличных от 200, строка "0: Unknown status" для незавершенных запросов (прерванных по таймауту, вызовом метода abort(), из-за сетевой ошибки), строка вида "parse json: error message" для ошибки парсинга ответа. Если ошибки нет - undefined. Разделять ошибки на timeout, abort, error (сетевые), как это сделано в jQuery, нет смысла, ибо JS не дает никакой информации по ошибке кроме вызова соответствующего обработчика. А для сетевых ошибок вообще кидает в консоль исключение, которое нельзя перехватить. onDone - одна функция вместо двух onError и onSuccess по простой причине - большинство ajax запросов - это отправка данных формы. До отправки некоторые контролы нужно заблокировать (disabled = true), а после получения ответа от сервера - разблокировать, причем независимо от результата. */ function ajaxreq(opt) { let xhr = new XMLHttpRequest() xhr.addEventListener('loadend', function() { if (xhr.status != 200) { let txt if (xhr.status == 0) { txt = 'Unknown status' } else { txt = xhr.statusText } opt.onDone(xhr.status + ': ' + txt) } else { let obj switch (opt.responseType || 'json') { case 'json': try { obj = JSON.parse(xhr.responseText) } catch (e) { opt.onDone('json parse: ' + e.message) return } break default: obj = xhr.responseText break } opt.onDone(undefined, obj) } }) xhr.open('POST', opt.url) if (opt.timeout) { xhr.timeout = opt.timeout } xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') xhr.setRequestHeader('Content-Type', 'text/json; charset=utf-8') xhr.send(opt.data) } //////////////////// WS export class WebsocketClient { constructor({url, path, reconnectDelay, lostConnectionTimeout, sid}) { if (!lostConnectionTimeout) { throw new Error('lostConnectionTimeout option is required') } if (url) { this._url = url } else if (path) { if (!path.startsWith('/')) { throw new Error("path must starts with '/'") } let u = new URL(location.href) this._url = `${u.protocol == 'http:' ? 'ws' : 'wss'}://${u.host}${path}` } else { throw new Error('url or path option is required') } this._reconnectDelay = reconnectDelay || 5000 this._lostConnTimeout = lostConnectionTimeout * 1000 this._sid = sid this._map = new Map() this.autoReconnect = true this._isConnected = false this._funcMap = new Map() this._prefixMap = new Map() } connect() { this._ws = new WebSocket(this._url) this._ws.onmessage = this._onMessage.bind(this) this._ws.onclose = this._onClose.bind(this) this._ws.onerror = this._onError.bind(this) this._ws.onopen = this._onOpen.bind(this) } _onOpen() { this._isConnected = true if (this._sid) { this.sendBytes(JSON.stringify(this._sid)) } this._prefixMap.forEach(x => { for (let channel of x.channels) { this.send('subscribe', channel) } }) this._pingIntervalId = setInterval(_ => { this.send('ping') }, this._lostConnTimeout/2) this._resetLostConnTimeout() this.onConnected?.() } _onClose() { this._isConnected = false this._ws = null this.onDisconnected?.() if (this.autoReconnect) { this._reconnectTimeoutId = setTimeout(this.connect.bind(this), this._reconnectDelay) } } _onError(err) { this._isConnected = false } _onMessage(e) { if (this.onMessage) { this.onMessage(e.data) } else { let envelope; try { envelope = JSON.parse(e.data) } catch (err) { throw new Error(`JSON.parse websocket message: ${err}`) } let func = this._funcMap.get(envelope.type) if (func) { func(envelope.data) } } this._resetLostConnTimeout() } // задает 2 таймера - на отправку пинга и на протухание соединения _resetLostConnTimeout() { if (this._lostConnTimeoutId) { clearTimeout(this._lostConnTimeoutId) } this._lostConnTimeoutId = setTimeout(_ => { if (this._ws) { this._ws.close() } }, this._lostConnTimeout) } send(messageType, param) { if (this._isConnected) { this._ws.send(JSON.stringify({ type: messageType, data: param })) } } sendBytes(data) { if (this._isConnected) { this._ws.send(data) } } handle(channelPrefix, handler) { if (this._prefixMap.has(channelPrefix)) { throw new Error(`channel prefix '${channelPrefix}' already has handler`) } let funcNames = [] for (let funcName in handler) { if (this._funcMap.has(funcName)) { throw new Error(`message type '${funcName}' already has handle func`) } this._funcMap.set(funcName, handler[funcName]) funcNames.push(funcName) } this._prefixMap.set(channelPrefix, { channels: [], funcNames: funcNames }) } unhandle(channelPrefix) { let x = this._prefixMap.get(channelPrefix) x.funcNames.forEach(funcName => { this._funcMap.delete(funcName) }) for (let channel of x.channels) { this.send('unsubscribe', channel) } this._prefixMap.delete(channelPrefix) } handleFunc(funcName, f) { if (this._funcMap.has(funcName)) { throw new Error(`message type '${funcName}' already has handle func`) } this._funcMap.set(funcName, f) } unhandleFunc(funcName) { // fix проверить что удаляем одинокую функцию this._funcMap.delete(funcName) } subscribe(channel) { let channelPrefix = channel.split('=')[0] let x = this._prefixMap.get(channelPrefix) if (!x) { throw new Error(`handler for channel prefix ${channelPrefix} is not set`) } for (let ch of x.channels) { if (ch == channel) { return } } x.channels.push(channel) this.send('subscribe', channel) } unsubscribe(channel) { let channelPrefix = channel.split('=')[0] let x = this._prefixMap.get(channelPrefix) if (!x) { throw new Error(`handler for channel prefix ${channelPrefix} is not set`) } let idx = x.channels.findIndex(ch => ch == channel) if (idx >= 0) { x.channels.splice(idx, 1) this.send('unsubscribe', channel) } } close() { this._prefixMap.clear() this._funcMap.clear() this.autoReconnect = false if (this._reconnectTimeoutId) { clearTimeout(this._reconnectTimeoutId) } if (this._lostConnTimeoutId) { clearTimeout(this._lostConnTimeoutId) } if (this._pingIntervalId) { clearInterval(this._pingIntervalId) } if (this._ws) { this._ws.close() } } } /* message вида: { func: "getAccount", param: { id: 123 } } */ ////////////////////////////////////////////////////////////////////////////////// function StatusMessage(opt) { if (!opt.elem) { throw new Error("'elem' option is required") } if (typeof opt.elem == 'string') { this._elem = document.getElementById(opt.elem) if (!this._elem) { throw new Error(`'elem' (id=${opt.elem}) not found`) } } else { this._elem = opt.elem } this._okClass = opt.okClass || StatusMessage.okClass this._errorClass = opt.errorClass || StatusMessage.errorClass this._delay = opt.delay || 4000 this._hide = opt.hide } StatusMessage.prototype.flash = function(msg) { //console.log('FLASH msg:', msg) this._elem.classList.remove(this._errorClass) this._elem.classList.add(this._okClass) this._elem.textContent = msg if (this._timerId) { clearTimeout(this._timerId) } this._timerId = setTimeout(() => { if (this._hide) { this._elem.style.display = 'none' } this._elem.textContent = '' }, this._delay) } StatusMessage.prototype.error = function(msg) { //console.log('ERROR msg:', msg) if (this._timerId) { clearTimeout(this._timerId) } this._elem.classList.remove(this._okClass) this._elem.classList.add(this._errorClass) this._elem.textContent = msg if (this._hide) { this._elem.style.display = 'block' } } StatusMessage.prototype.ok = function(msg) { //console.log('OK msg:', msg) if (this._timerId) { clearTimeout(this._timerId) } this._elem.classList.remove(this._errorClass) this._elem.classList.add(this._okClass) this._elem.textContent = msg if (this._hide) { this._elem.style.display = 'block' } } StatusMessage.prototype.clear = function() { //console.log('CLEAR msg') if (this._timerId) { clearTimeout(this._timerId) } if (this._hide) { this._elem.style.display = 'none' } this._elem.textContent = '' } //////////// API /* Два типа запросов: которые возвращают данные и запросы действия, которые ничего не возвращают. Для первого типа запросов нужно выводить ошибки, для второго типа - ошибки и (опционально) сообщение об успехе (типа "параметры обновлены, интерфейсы удалены") */ /* Опции: - url, адрес API - timeout, таймаут для ajax запросов - hideStatus, скрывать status (display: none) в момент отправки запроса или нет (true/false) - errorClass, css класс для сообщений об ошибке - okClass, css класс для ok-сообщений - flash, длительность показа ok-сообщения (миллисекунды). По истечении времени, сообщение скрывается. Если hideStatus=true, блок также будет скрыт. По умолчанию, ok-сообщение не скрывается. */ function API(opt) { this._url = opt.url this._timeout = opt.timeout } /* Опции: - status, html элемент для вывода ошибок и ok-сообщений - ok, сообщение, которое будет выведено в status с стилем okClass, если сервер вернул что-то кроме ошибки, null. - empty, сообщение, которое будет показано если сервер вернет null. Например, юзер выбрал в списке модем, а на сервере его уже нет. Сервер вернет null, юзер увидит сообщение. onError - колбек вызывается в случае ошибки, получает 1 параметр: - либо errmsg, строка которая содержит сетевую ошибку либо ошибку парсинга JSON - либо error объект из ответа сервера. По умолчанию {code: int, message: string} onSuccess - колбек, вызывается если не было ошибок */ API.prototype.req = function(opt) { let p = { func: opt.func } if (opt.data !== undefined) { p.data = opt.data } if (opt.status) { opt.status.clear() } ajaxreq({ "url": this._url, "data": JSON.stringify(p), "timeout": this._timeout, "onDone": (errmsg, resp) => { if (errmsg) { if (opt.status) { opt.status.error(errmsg) } else { console.log('ajaxreq:', errmsg) } if (opt.onError) { opt.onError(errmsg) } return } if (resp && resp.error) { if (opt.status) { opt.status.error(resp.error.message) } else { console.log('ajaxreq resp:', resp.error.message) } if (opt.onError) { opt.onError(resp.error) } return } if (opt.onSuccess) { opt.onSuccess(resp) } } }) } /////////////// function Emiter() { this._emit_map = {} } Emiter.prototype.on = function(event, func) { let arr = this._emit_map[event] if (arr) { arr.push(func) } else { this._emit_map[event] = [func] } } Emiter.prototype.emit = function(event, ...obj) { let arr = this._emit_map[event] if (arr) { arr.forEach((func) => { func(...obj) }) } } /////////////////// SELECT //function ExistingSelect(opt) { //thi //} // Событие 'select', обработчик получает на вход ID function Select(opt) { Emiter.call(this) if (!opt.field) { throw new Error("'field' option is required") } this._select = opt.field /* if (typeof opt.select == 'string') { this._select = document.getElementById(opt.select) if (!this._select) { throw new Error(`'elem' (id=${opt.select}) not found`) } } else { this._select = opt.select } */ //if (!opt.key) { // throw new Error("'key' option is required") //} this._select.addEventListener('change', this._onchange.bind(this)) this._arr = [] this._key = opt.key this._noZero = opt.noZero this._errorClass = opt.errorClass || 'errmsg' if (opt.name) { if (typeof opt.name == 'string') { this._getName = (item) => { return item[opt.name] } } else { // fix function this._getName = opt.name } } else { this._getName = (item) => { return item } } if (opt.filled) { for (let i=0; i { let option = document.createElement('option') if (this._key) { option.value = item[this._key] } else { option.value = item } option.textContent = this._getName(item) this._select.appendChild(option) }) this.emit('listChanged') } } Select.prototype.clear = function() { //console.log('CLEAR select:', this._select) this._select.innerHTML = '' this._arr = [] if (this._divLoadError) { this._divLoadError.remove() } this.emit('listChanged') } Select.prototype.likeReset = function() { this.emit('select') } Select.prototype.reset = function() { //console.log('RESET select:', this._select) if (this._arr.length == 0) { return } if (this._noZero) { let item = this._arr[0] if (this._key) { this._select.value = item[this._key] } else { this._select.value = item } } else { this._select.value = "" } this._select.dispatchEvent(new Event('change')) } Select.prototype.getSelected = function() { //console.log(this._select.selectedIndex) let idx = this._select.selectedIndex // индекс 0 - это пустой option, поэтому idx-1 if (!this._noZero) { idx-- } return this._arr[idx] } /* Select.prototype.setError = function(err) { let div; if (this._divLoadError) { div = insertError({ after: this._divLoadError, err: err, class: this._errorClass }) } else { div = insertError({ after: this._select, err: err, class: this._errorClass }) } return div } */ Select.prototype._onchange = function() { //console.log('on change:') //this.emit('select', this.getSelected()) let item = this.getSelected() if (!item) { this.emit('select') } else if (this._key) { this.emit('select', item[this._key]) } else { this.emit('select', item) } } Select.prototype.select = function(id) { //console.log('SELECT select:', id) //console.log("select.select:", this._select.value, id) this._select.value = id this._select.dispatchEvent(new Event('change')) } Select.prototype.getList = function() { return this._arr } ////////// RADIO LIST function RadioList(opt) { Emiter.call(this) /* let required = ['templateId', 'container', 'key'] required.forEach((option) => { if (!opt[option]) { throw new Error(`'${option}' option is required`) } }) */ this._errorClass = opt.errorClass || 'errmsg' this._setEmptyMessage = opt.setEmptyMessage if (opt.templateId) { let renders = {} renders[opt.templateId] = (tags) => { if (!tags.radio) { return } tags.radio.setAttribute('type', 'radio') let idx = this._buttons.length this._buttons.push(tags.radio) tags.radio.addEventListener('click', () => this._onclick(idx)) } this._template = new Template({ id: opt.templateId, renders: renders, }) } else { } this._container = getElemSafe('container', opt.container) if (opt.cover) { this._cover = getElemSafe('cover', opt.cover) } else { this._cover = this._container } this._key = opt.key this._buttons = [] this._arr = [] } RadioList.prototype = Object.create(Emiter.prototype) RadioList.prototype.constructor = RadioList RadioList.prototype.getActionType = function() { return 'select' } RadioList.prototype.getType = function() { return 'select' } RadioList.prototype.getAnchor = function() { return this._cover } RadioList.prototype.getCover = function() { return this._cover } RadioList.prototype.disable = function() { //console.log('DISABLE RADIO', this._buttons) this._buttons.forEach((radio) => { radio.disabled = true }) } RadioList.prototype.enable = function() { //console.log('ENABLE RADIO', this._buttons) this._buttons.forEach((radio) => { radio.disabled = false }) } RadioList.prototype.clear = function() { //console.log('CLEAR radio:') this._currentIdx = undefined this._container.innerHTML = '' this._buttons = [] this._arr = [] if (this._messageDiv) { this._messageDiv.remove() } if (this._divLoadError) { this._divLoadError.remove() } } RadioList.prototype.setList = function(list) { //console.log('setList radio:', list) this.clear() if (Array.isArray(list)) { this._arr = list this._template.renderTo(list, this._container) } else { if (this._setEmptyMessage) { let div = document.createElement('div') div.textContent = this._setEmptyMessage this._messageDiv = div this._cover.parentNode.insertBefore(div, this._cover.nextSibling) } } } RadioList.prototype.getList = function() { return this._arr } RadioList.prototype.select = function(id) { //console.log('SELECT radio:', id, this._key, this._arr) //console.log(this._arr) this._arr.forEach((obj, idx) => { if (id === obj[this._key]) { this._onclick(idx) return } }) } RadioList.prototype.getSelected = function() { if (this._currentIdx !== undefined) { return this._arr[this._currentIdx] } } /* RadioList.prototype.setError = function(err) { return insertError({ after: this._cover, err: err, class: this._errorClass }) } */ RadioList.prototype._onclick = function(idx) { //console.log('radioList select:', idx, this._currentIdx, this._buttons) let radio = this._buttons[idx] let obj = this._arr[idx] let id = obj[this._key] if (this._currentIdx === idx) { // снимаем отметку radio.checked = false this._currentIdx = undefined this.emit('select') } else { let prev = this._buttons[this._currentIdx] if (prev) { prev.checked = false } radio.checked = true this._currentIdx = idx this.emit('select', id) } } ///////////////////////// function ReadonlyInput(opt) { Emiter.call(this) this._key = opt.key this._name = opt.name this._input = getElemSafe('input', opt.input) //this._input.addEventListener('input', () => this.emit('changed')) this._errorClass = opt.errorClass || 'errmsg' } ReadonlyInput.prototype = Object.create(Emiter.prototype) ReadonlyInput.prototype.constructor = ReadonlyInput ReadonlyInput.prototype.getActionType = function() { return 'input' } ReadonlyInput.prototype.getType = function() { return 'input' } ReadonlyInput.prototype.disable = function() { this._input.disabled = true } ReadonlyInput.prototype.enable = function() { this._input.disabled = false } ReadonlyInput.prototype.reset = function() { this._input.value = undefined this.emit('change') } ReadonlyInput.prototype.clear = function() { this._input.value = '' this._value = undefined this.emit('change') } ReadonlyInput.prototype.getValue = function() { return this._value } ReadonlyInput.prototype.setValue = function(value) { // console.log('setValue:', value) if (value === undefined) { this._value = undefined this._input.value = '' this.emit('change') } else { this._value = value if (this._key) { this.emit('change', value[this._key]) } else { this.emit('change', value) } if (this._name) { this._input.value = value[this._name] } else { this._input.value = value } } } ReadonlyInput.prototype.getAnchor = function() { return this._input } /* ReadonlyInput.prototype.setError = function(err) { return insertError({ after: this._input, err: err, class: this._errorClass }) } */ //////////////////////// function InputList(opt) { Emiter.call(this) if (!opt.templateId) { throw new Error("option 'templateId' is required") } let renders = {} renders[opt.templateId] = (tags) => { if (!tags.input) { return } this._inputs.push(tags.input) tags.input.addEventListener('input', () => { this.emit('changed') }) } this._template = new Template({ id: opt.templateId, renders: renders, }) this._container = getElemSafe('container', opt.container) if (opt.cover) { this._cover = getElemSafe('cover', opt.cover) } else { this._cover = this._container } this._errorClass = opt.errorClass || 'errmsg' this._inputs = [] this._setEmptyMessage = opt.setEmptyMessage } InputList.prototype = Object.create(Emiter.prototype) InputList.prototype.constructor = InputList InputList.prototype.getType = function() { return 'list' } InputList.prototype.disable = function() { this._inputs.forEach((input) => { input.disabled = true }) } InputList.prototype.enable = function() { this._inputs.forEach((input) => { input.disabled = false }) } InputList.prototype.reset = function() { // FIX } InputList.prototype.clear = function() { this._container.innerHTML = '' this._inputs = [] if (this._messageDiv) { this._messageDiv.remove() } } InputList.prototype.setList = function(list) { //console.log('INPUT LIST: setList', list) if (Array.isArray(list)) { this._template.renderTo(list, this._container) } else { if (this._setEmptyMessage) { let div = document.createElement('div') div.textContent = this._setEmptyMessage this._messageDiv = div this._cover.parentNode.insertBefore(div, this._cover.nextSibling) } } } InputList.prototype.getList = function() { //console.log('getList') return [] } InputList.prototype.getValues = function() { //console.log('getValues') let arr = [] this._inputs.forEach((input) => { arr.push(input.value) }) return arr } InputList.prototype.setValues = function(values) { //console.log('setValues') this._inputs.forEach((input, idx) => { input.value = values[idx] }) } /* InputList.prototype.setError = function(err, idx) { let anchor = this._inputs[idx] return insertError({ after: anchor, err: err, class: this._errorClass }) } */ InputList.prototype.getAnchor = function(idx) { if (idx !== undefined ) { return this._inputs[idx] // fix out of range } return this._cover } InputList.prototype.getCover = function() { return this._cover } ////////// CHECK LIST function CheckList(opt) { Emiter.call(this) this._errorClass = opt.errorClass || 'errmsg' if (!opt.templateId) { throw new Error("option 'templateId' is required") } let renders = {} renders[opt.templateId] = (tags) => { if (!tags.checkbox) { return } tags.checkbox.setAttribute('type', 'checkbox') let idx = this._buttons.length this._buttons.push(tags.checkbox) tags.checkbox.addEventListener('click', () => this._onclick(idx)) } this._template = new Template({ id: opt.templateId, renders: renders, }) this._container = getElemSafe('container', opt.container) if (opt.cover) { this._cover = getElemSafe('cover', opt.cover) } else { this._cover = this._container } if (!opt.key) { throw new Error("option 'key' is required") } this._key = opt.key this._buttons = [] this._arr = [] } CheckList.prototype = Object.create(Emiter.prototype) CheckList.prototype.constructor = CheckList CheckList.prototype.getType = function() { return 'list' } CheckList.prototype.getActionType = function() { return 'input' } CheckList.prototype.disable = function() { //console.log('DISABEL RADIO', this._buttons) this._buttons.forEach((c) => { c.disabled = true }) } CheckList.prototype.enable = function() { //console.log('DISABEL RADIO', this._buttons) this._buttons.forEach((c) => { c.disabled = false }) } CheckList.prototype.reset = function() { this._buttons.forEach((checkbox) => { checkbox.checked = false }) } CheckList.prototype.clear = function() { //console.log('CLEAR radio:') this._currentIdx = undefined this._container.innerHTML = '' this._buttons = [] this._arr = [] if (this._messageDiv) { this._messageDiv.remove() } if (this._divLoadError) { this._divLoadError.remove() } } CheckList.prototype.setList = function(list) { this.clear() if (Array.isArray(list)) { this._arr = list this._template.renderTo(list, this._container) } else { if (this._setEmptyMessage) { let div = document.createElement('div') div.textContent = this._setEmptyMessage this._messageDiv = div this._container.parentNode.insertBefore(div, this._container.nextSibling) } } } CheckList.prototype.getList = function() { return this._arr } CheckList.prototype.setValues = function(ids) { console.log('SET VALUES:', ids, this._arr) if (!Array.isArray(ids)) { return } ids.forEach(id => { this._arr.forEach((obj, idx) => { if (obj[this._key] === id) { this._buttons[idx].checked = true } }) }) this.emit('changed') } CheckList.prototype.getValues = function() { let r = [] this._arr.forEach((obj, idx) => { if (this._buttons[idx].checked) { r.push(obj[this._key]) } }) console.log('GET VALUES:', r) return r } // getAnchor - возвращает HTML Element, к которому можно привязать ошибку. // Если idx не указан или ошибочный чекбокс не будет найден - вернет ссылку на cover. // Иначе вернет ссылку на родительский элемент для ошибочного чекбокса, предполагается // что это будет LABEL. // Idx считается только для отмеченных чекбоксов. То есть idx=1 будет указывать на 2й // отмеченный чекбокс. CheckList.prototype.getAnchor = function(idx) { if (idx === undefined) { return this._cover } let checkedIdx = 0 for (let i=0; i