Files
tipper/static/omg.js
2026-07-21 05:29:37 +03:00

2520 lines
75 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 - неправильно.
*/