Files
tipper/static/omg.js

2520 lines
75 KiB
JavaScript
Raw Normal View History

2026-07-21 05:29:37 +03:00
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<links.length; i++) {
let link = links[i]
//console.log(link)
link.addEventListener('click', (e) => {
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<this._arr.length; idx++) {
let item = this._arr[idx]
if (keyFunc(item.obj)) {
updateFunc(item.tags, item.obj)
return
}
}
}
ListModel.prototype.updateBy = function(newobj, keyFunc) {
for (let idx=0; idx<this._arr.length; idx++) {
let item = this._arr[idx]
if (keyFunc(item.obj)) {
this._update(newobj, item)
return
}
}
}
// fix add silent select after update
ListModel.prototype._update = function(newobj, si) {
if (this._preFunc) {
this._preFunc(newobj)
}
let hashcode;
if (this._unique) {
hashcode = this._unique(newobj)
if (si.hashcode != hashcode && this._hashcodes.get(hashcode)) {
// дубликат
return
}
}
let checked;
if (si.checkbox) {
checked = si.checkbox.checked
}
let item;
if (this._cmp) {
// удаляем
this._arr = this._arr.filter(item => 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<this._arr.length; idx++) {
let item = this._arr[idx]
if (f(item.obj)) {
this._select(item)
return
}
}
}
ListModel.prototype.selectByKey = function(id, silent) {
for (let i=0; i<this._arr.length; i++) {
let item = this._arr[i]
if (item.obj[this._key] === id) {
this._select(item, silent)
return
}
}
}
// Идея метода следующая. Представим левое меню и 2 случая:
// - юзер кликает по пункту, меняется URL, запускается обработчик,
// который по getSelected берет выделенный элемент и что-то делает
// - юзер после клика обновляет страницу. JS router исходя из адреса
// запускает обработчик. А обработчик должен во первых понять что в модели
// нет выделенных элементов, затем найти в модели нужный элемент по ID
// (взятому из URL), затем тихо выделить (silent select, без генерации события
// 'select') пункт меню (элемент модели) и в конце что-то сделать с элементом.
//
// Эти 2 кейса можно реализовать в 1 методе и не усложнять клиентскую логику и код.
// Как работает метод: проверяет есть ли selected элемент, если есть и его ключ
// совпадает с аргументом - возвращает объект. Если selected элемента нет - ищет
// элемент по ключу, тихо выделяет (без генерации 'select' события) и возвращает.
ListModel.prototype.silentSelectByKey = function(id) {
let obj = this.getSelected()
if (obj) {
//console.log('item:', item)
if (obj[this._key] === id) {
return obj
}
}
for (let i=0; i<this._arr.length; i++) {
let item = this._arr[i]
if (item.obj[this._key] === id) {
if (this._selectedItem != item) {
if (this._selectedItem && this._selectedItem.select) {
this._selectedItem.select.classList.remove(this._activeClass)
}
if (item.select) {
item.select.classList.add(this._activeClass)
}
this._selectedItem = item
}
return item.obj
}
}
}
ListModel.prototype._select = function(item, silent) {
// клик на любом элементе - снимает выделение с текущего
if (this._selectedItem && this._selectedItem.select) {
this._selectedItem.select.classList.remove(this._activeClass)
if (!silent) {
this.emit('select', undefined, this._selectedItem.tags)
}
}
if (this._selectedItem == item) {
// Клик на текущем эелементе - снимает выделение
this._selectedItem = null
} else {
if (item.select) {
item.select.classList.add(this._activeClass)
}
this._selectedItem = item
if (!silent) {
this.emit('select', item.obj, item.tags)
}
}
}
ListModel.prototype.get = function(idx) {
let item = this._arr[idx]
if (item) {
return item.obj
}
}
// Выделяет текущий элемент по индексу
ListModel.prototype.getByKey = function(id) {
for (let i=0; i<this._arr.length; i++) {
let item = this._arr[i]
if (item.obj[this._key] === id) {
return item.obj
}
}
}
ListModel.prototype.remove = function(idx) {
let item = this._arr[idx]
if (!item) {
return
}
this._container.removeChild(item.elem)
// удаляем
this._arr.splice(idx, 1)
if (this._unique) {
this._hashcodes.delete(item.hashcode)
}
this._setEmptyMessageIfNeeded()
if (this._selectedItem == item) {
this._selectedItem = null
this.emit('select')
}
if (item.checkbox && item.checkbox.checked) {
this.emit('checkListChanged')
}
this.emit('listChanged')
}
ListModel.prototype.removeSelected = function() {
let idx = this._arr.findIndex(item => item == this._selectedItem)
if (idx != -1) {
this.remove(idx)
}
}
// функция должна вернуть true. Удялет только 1 элемент
ListModel.prototype.removeBy = function(f) {
for (let i=0; i<this._arr.length; i++) {
if (f(this._arr[i].obj)) {
this.remove(i)
return
}
}
}
ListModel.prototype.getList = function() {
let arr = []
this._arr.forEach(item => 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<this._select.options.length; i++) {
let option = this._select.options[i]
if (option.value !== '') {
let v;
let value = option.value.trim()
if (opt.valueType) {
switch (opt.valueType) {
case 'int':
v = parseInt(value)
if (!isNaN(v)) {
this._arr.push(v)
}
break
case 'float':
v = parseFloat(value)
if (!isNaN(v)) {
this._arr.push(v)
}
break
default:
throw new Error(`Unknown valueType '${opt.valueType}'`)
}
} else {
this._arr.push(value)
}
}
}
}
//console.log('filled:', this._arr)
}
Select.prototype = Object.create(Emiter.prototype)
Select.prototype.constructor = Select
Select.prototype.getActionType = function() {
return 'select'
}
Select.prototype.getType = function() {
return 'select'
}
Select.prototype.enable = function() {
this._select.disabled = false
}
Select.prototype.disable = function() {
this._select.disabled = true
}
Select.prototype.getAnchor = function() {
return this._select
}
Select.prototype.getCover = function() {
return this._select
}
Select.prototype.setList = function(list) {
//console.log('SET LIST select:', list)
this._select.innerHTML = ''
if (Array.isArray(list)) {
if (!this._noZero) {
// добавляем пустой option
this._select.appendChild(document.createElement('option'))
}
// заполняем данными
this._arr = list
this._arr.forEach((item) => {
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<this._buttons.length; i++) {
let checkbox = this._buttons[i]
if (checkbox.checked) {
if (idx === checkedIdx) {
return checkbox.parentNode
}
checkedIdx++
}
}
return this._cover
}
/*
CheckList.prototype.setError = function(err, idx) {
if (idx !== undefined) {
// ссылка на label, а не checkbox
let elem = this._buttons[idx].parentNode
return insertError({
after: elem,
err: err,
class: this._errorClass
})
}
return insertError({
after: this._cover,
err: err,
class: this._errorClass
})
}
*/
CheckList.prototype.getCover = function() {
return this._cover
}
CheckList.prototype._onclick = function(idx) {
this.emit('changed')
}
///////////////////// URL
// Добавляем в URL переменную, не трогая остальные
function setParamToURL(name, value) {
// по умолчанию state = null
if (history.state) {
// Позволяет устранить проблему: когда юзер выбирает пункт в select и когда
// программа выбирает пункт при обработке popstate события (юзер нажал
// кнопку Назад) - происходит зацикливание истории (кнопка назад срабатывает
// 1 раз и затем страница не меняется).
if (history.state[name] === value) {
return
}
}
const u = new URL(location.href)
let usp = new URLSearchParams(u.searchParams)
if (value === undefined) {
// например, выбрали в select пустой элемент
usp.delete(name)
} else {
usp.set(name, value)
}
let state = {}
state[name] = value
// если склеивать path без условных проверок - получим путь вида /configs?
// при сбросе параметра (value = undefined)
let path = u.pathname
const searchParams = usp.toString()
if (searchParams !== '') {
path += '?' + searchParams
}
path += u.hash
history.pushState(state, "", path)
}
function getParamFromURL(name, dataType) {
const u = new URL(location.href)
let usp = new URLSearchParams(u.searchParams)
if (!dataType) {
dataType = 'str'
}
let v;
switch (dataType) {
case 'str':
v = usp.get(name) || ''
break
case 'int':
v = usp.get(name)
v = parseInt(v) || 0
break
case 'float':
v = usp.get(name)
v = parseFloat(v) || 0
case 'bool':
v = usp.get(name)
if (v == 'on') {
v = true
} else {
v = false
}
break
default:
throw new Error(`Unknown type: ${dataType}`)
}
return v
}
/////////////////
function insertError(opt) {
let div = document.createElement('div')
if (opt.class) {
div.className = opt.class
}
let msg;
if (typeof opt.err == 'string') {
msg = opt.err
} else {
msg = opt.err.message
}
div.textContent = msg
if (opt.after) {
opt.after.parentNode.insertBefore(div, opt.after.nextSibling)
} else if (opt.before) {
// например перед submit кнопкой
opt.before.parentNode.insertBefore(div, opt.before)
}
return div
}
function insertMessage(opt) {
let div = document.createElement('div')
if (opt.class) {
div.className = opt.class
}
div.textContent = opt.msg
if (opt.after) {
opt.after.parentNode.insertBefore(div, opt.after.nextSibling)
} else if (opt.before) {
// например перед submit кнопкой
opt.before.parentNode.insertBefore(div, opt.before)
}
return div
}
//////////////////
function getElemSafe(name, opt) {
if (opt instanceof Element) {
return opt
}
if (typeof opt == 'string') {
let elem = document.getElementById(opt)
if (!elem) {
throw new Error(`'${name}' option not found in DOM by id=${opt}`)
}
return elem
}
throw new Error(`'${name}' option must be Element or id string, not ${JSON.stringify(opt)}`)
}
/*
1. Все параметры создаются вместе с конфигом через админку http://167.99.157.181:8081/configs в разделе Field Set Configs.
2. Конфиг и параметры редактировать нельзя, Вы можете привязыватся к имени параметра без проблем.
После создания имя изменить нельзя, конфиг (таблица configs) отредактировать нельзя.
Если создан конфиг, например, с 3 параметрами, имена и количество
Создавать таблицу где имя параметра будет именем колонки?
Если я буду программно добавлять/удалять колонки (вместо строк) - как-то нереляционно.
Если вы предполагаете что все параметры можно предусмотреть заранее - это ошибка.
Завтра будут новые приборы, новые конфиги и новые параметры. Делать для каждого
нового параметра вручную ALTER TABLE ADD COLUMN - неправильно.
*/