UNPKG

comindware.core.ui

Version:

Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.

825 lines (726 loc) 30.8 kB
import { transliterator } from 'utils'; import Marionette from 'backbone.marionette'; import Backbone from 'backbone'; import _ from 'underscore'; import { validationSeverityTypes, validationSeverityClasses } from 'Meta'; import { classes } from '../meta'; import dropdown from 'dropdown'; import MobileService from '../../services/MobileService'; import CellViewFactory from '../CellViewFactory'; import { Column, GridItemModel } from '../types/types'; import { objectPropertyTypes, complexValueTypes, DOUBLECLICK_DELAY } from '../../Meta'; import ErrorsPanelView from '../../views/ErrorsPanelView'; const config = { TRANSITION_DELAY: 400 }; const defaultOptions = { levelMargin: 10, contextLevelMargin: 30, subGroupMargin: 20 }; /** * Some description for initializer * @name RowView * @memberof module:core.list.views * @class RowView * @extends Marionette.View * @constructor * @description View используемый по умолчанию для отображения строки списка * @param {Object} options Constructor options * @param {Array} options.columns Массив колонк * @param {Object} options.gridEventAggregator ? * @param {Number} [options.paddingLeft=20] Левый отступ * @param {Number} [options.paddingRight=10] Правый отступ * */ export default Marionette.View.extend({ tagName: 'tr', ui: { cells: '.js-grid-cell', collapsibleButton: '.js-collapsible-button', checkbox: '.js-checkbox', dots: '.js-dots', index: '.js-index' }, events: { 'click @ui.collapsibleButton': '__toggleCollapse', 'pointerdown @ui.checkbox': '__handleCheckboxClick', click: '__handleClick', dblclick: '__handleDblClick', dragstart: '__handleDragStart', dragenter: '__handleDragEnter', dragleave: '__handleDragLeave', drop: '__handleDrop', contextmenu: '__handleContextMenu', touchend: '__handleTouchEnd', touchmove: '__handleTouchMove', touchcancel: '__handleTouchCancel' }, modelEvents: { selected: '__handleSelection', deselected: '__handleDeselection', 'select:pointed': '__selectPointed', 'selected:enter': '__handleEnter', 'selected:exit': '__handleExit', highlighted: '__handleHighlight', unhighlighted: '__handleUnhighlight', change: '__handleChange', blink: '__blink', 'toggle:collapse': 'updateCollapsed', checked: '__updateState', unchecked: '__updateState', 'checked:some': '__updateState', validated: '__onValidated', 'set:draggable': '__setDraggable', 'dragleave': '__onModelDragLeave' }, initialize() { _.defaults(this.options, defaultOptions); this.gridEventAggregator = this.options.gridEventAggregator; this.listenTo(this.gridEventAggregator, 'set:draggable', this.__setDraggable); this.collection = this.model.collection; this.cellConfigs = {}; this.cellViewsByKey = {}; this.options.columns.forEach((column: Column, index: number) => { this.cellConfigs[column.key] = { isHidden: column.getHidden?.(this.model) }; if (typeof column.getHidden === 'function') { this.listenTo(this.model, 'change', () => this.__setCellHidden({ column, index, isHidden: Boolean(column.getHidden?.(this.model)) })); } }); this.__debounceSelectPointedOnClick = _.debounce((...args) => this.__selectPointedOnClick(...args), DOUBLECLICK_DELAY); }, getValue(id: string) { this.model.get(id); }, onRender() { const model = this.model; if (model.selected) { this.__handleSelection(); } if (this.model.checked !== undefined) { this.__updateState(); } if (this.getOption('isTree')) { this.insertFirstCellHtml(true); } this.__updateValidationErrors(); }, onDestroy() { if (this.cellViewsByKey) { Object.values(this.cellViewsByKey).forEach(x => x.destroy()); this.cellViewsByKey = {}; } this.errorPopout?.destroy(); this.multiValuePopout?.destroy(); }, updateCollapsed(model: ListItemModel) { const collaspibleButtons = this.el.getElementsByClassName(classes.collapsibleIcon); if (!model.collapsed) { if (collaspibleButtons.length) { collaspibleButtons[0].classList.add(classes.expanded); } } else if (collaspibleButtons.length) { collaspibleButtons[0].classList.remove(classes.expanded); } }, _renderTemplate() { if (typeof this.options.transliteratedFields === 'object') { transliterator.initializeTransliteration({ model: this.model, transliteratedFields: this.options.transliteratedFields, schemaForExtendComputed: this.options.columns }); } this.onDestroy(); const itemsHTML: Array<string> = this.itemsHTML = []; if (this.getOption('showCheckbox')) { this.__insertCellChechbox(); } const customCells: Array<{ index: number, CellView: Marionette.View<Backbone.Model> }> = []; this.options.columns.forEach((column: Column, index: number) => { if (column.cellView) { customCells.push({ index, CellView: column.cellView }) } else { const cellHTML = CellViewFactory.getCell(column, this.model); itemsHTML.push(cellHTML); } }); this.el.innerHTML = this.itemsHTML.join(''); customCells.forEach(({ index, CellView }) => { const cellView = this.__renderCell({ column: this.options.columns[index], index, CellView }); if (index === 0) { if (this.getOption('showCheckbox')) { const firstChildEl = this.el.firstChild; firstChildEl.insertAdjacentElement('afterend', cellView.el); } else { this.el.insertAdjacentElement('afterbegin', cellView.el); } } else { const childElBefore = this.__getCellByColumnIndex(index - 1); childElBefore.insertAdjacentElement('afterend', cellView.el); } cellView.triggerMethod('before:attach'); cellView.triggerMethod('attach'); }) }, __handleChange() { const changed = this.model.changedAttributes(); if (!changed) { return; } this.getOption('columns').forEach((column, index) => { if (!this.__isNeedToReplaceCell({ changed, column, index })) { return; } this.__insertReadonlyCell({ column, index, isReplace: true }); }); }, __isNeedToReplaceCell({ changed, column, index }) { if (column.cellView || column.getHidden?.(this.model)) { return false; } const isCellValueChanged = Object.prototype.hasOwnProperty.call(changed, column.key); if (!isCellValueChanged) { return false; } return this.lastPointedIndex !== index || !this.__isColumnEditable(index); }, __hasCellErrors(column: Column) { return column.required && _.isEmpty(this.model.get(column.key)); }, __isDropAllowed(): boolean { const draggingModels = this.collection.draggingModels; if (!draggingModels) { return false; } if (draggingModels.some(draggingModel => this.collection.indexOf(this.model) + 1 === this.collection.indexOf(draggingModel) && this.model.level <= draggingModel.level)) { return false; } if (draggingModels.some(draggingModel => this.__findInParents(draggingModel, this.model))) { return false; } return true; }, __findInParents(draggingModel: GridItemModel, model: GridItemModel): boolean { if (model === draggingModel) { return true; } if (model.parentModel) { return this.__findInParents(draggingModel, model.parentModel); } return false; }, __handleDragStart(event: { originalEvent: DragEvent }) { const checkedModels = this.model.collection.getCheckedModels(); if (checkedModels.length) { return; } this.model.collection.draggingModels = [this.model]; const originalEvent = event.originalEvent; if (!originalEvent.dataTransfer) { return; } originalEvent.dataTransfer.setData('Text', this.cid); // fix for FireFox }, __handleDragEnter(event: DragEvent) { this.__setDragEnterModel(this.model); }, __setDragEnterModel(model: Backbone.Model) { const previousDragEnterModel = this.model.collection.dragoverModel; if (previousDragEnterModel === model) { return; } previousDragEnterModel?.trigger('dragleave'); this.model.collection.dragoverModel = model; if (this.__isDropAllowed()) { this.el.classList.add(classes.dragover); } }, __handleDragLeave() { this.el.classList.remove(classes.dragover); }, __handleDrop(event: DragEvent) { event.preventDefault(); this.el.classList.remove(classes.dragover); if (this.__isDropAllowed()) { this.gridEventAggregator.trigger('drag:drop', this.model.collection.draggingModels, this.model); } delete this.collection.draggingModels; }, __handleContextMenu(event: MouseEvent) { if (this.__isNeedToShowMenu(event)) { this.model.trigger('contextmenu', this.model, event); } }, __isNeedToShowMenu(e: MouseEvent) { const target = <Element>e.target; const selection = document.getSelection(); return e.button === 0 || e.ctrlKey || !((selection?.toString().length && selection.focusNode?.contains(target)) || target.tagName === 'A'); }, __handleHighlight(fragment: string) { this.render(); }, __handleUnhighlight() { this.render(); }, updateIndex(index: number) { if (index !== this.model.currentIndex) { this.el.querySelector('.js-index').innerHTML = index; this.model.currentIndex = index; } }, insertFirstCellHtml(force: boolean) { const elements = this.el.children; if (!elements.length) { return; } const level = this.model.level || 0; let margin = level * this.options.levelMargin; const hasChildren = Boolean(this.model.children?.length); if (!force && this.lastHasChildren === hasChildren && this.lastMargin === margin) { return; } const el = this.__getCellByColumnIndex(0); const treeFirstCell = el.getElementsByClassName('js-tree-first-cell')[0]; if (treeFirstCell?.parentElement === el) { el.removeChild(treeFirstCell); } const isContext = el.getElementsByClassName('context-icon')[0]; if (isContext) { margin = level * this.options.contextLevelMargin; if (hasChildren) { el.insertAdjacentHTML( 'beforeend', `<i class="${classes.collapsible} js-tree-first-cell context-collapsible-btn ${Handlebars.helpers.iconPrefixer('angle-down')} ${this.model.collapsed === false ? classes.expanded : ''}"></i>` ); } isContext.style.marginLeft = `${margin + defaultOptions.subGroupMargin}px`; } else if (hasChildren) { el.insertAdjacentHTML( 'afterbegin', `<i class="js-tree-first-cell collapsible-btn ${classes.collapsible} ${Handlebars.helpers.iconPrefixer('angle-down')} ${this.model.collapsed === false ? classes.expanded : ''}" style="margin-left:${margin}px;"></i/` ); } else { el.insertAdjacentHTML('afterbegin', `<span class="js-tree-first-cell" style="margin-left:${margin + defaultOptions.subGroupMargin}px;"></span>`); } this.lastHasChildren = hasChildren; this.lastMargin = margin; }, __insertCellChechbox() { if (typeof this.model.__draggable !== 'boolean') { this.model.__draggable = this.model.collection.__allDraggable; } const isDraggable = this.options.draggable && this.model.__draggable; if (this.options.showRowIndex) { this.model.currentIndex = this.model.collection.indexOf(this.model) + 1; } const cellHTML = `<td class="${classes.checkboxCell} ${this.options.showRowIndex ? 'cell_selection-index' : 'cell_selection'}" ${isDraggable ? 'draggable="true"' : ''}> ${this.options.showRowIndex ? `<span class="js-index cell__index"> ${this.model.currentIndex} </span>` : '' } <div class="checkbox js-checkbox"></div> </td>` this.itemsHTML.push(cellHTML); }, __setDraggable(draggable: boolean): void { this.model.__draggable = draggable; const checkboxCellEl = this.el.querySelector(`.${classes.checkboxCell}`); if (!checkboxCellEl) { return; } const hasDraggableAttribute = checkboxCellEl.hasAttribute('draggable'); const needSet = draggable && !hasDraggableAttribute; const needRemove = !draggable && hasDraggableAttribute; if (needSet) { checkboxCellEl.setAttribute('draggable', true); } else if (needRemove) { checkboxCellEl.removeAttribute('draggable'); } }, __handleCheckboxClick(e: PointerEvent) { const isShiftKeyPressed = e.shiftKey; if (isShiftKeyPressed) { e.preventDefault(); //remove text highlighting from table } this.model.toggleChecked(isShiftKeyPressed); if (this.getOption('bindSelection')) { this.model.collection.updateTreeNodesCheck(this.model, undefined, e.shiftKey); } }, __handleClick(e: MouseEvent) { const model = this.model; const selectFn = model.collection.selectSmart || model.collection.select; const columnIndex = this.__getFocusedColumnIndex(e); const column = this.options.columns[columnIndex]; if (selectFn) { selectFn.call(model.collection, model, e.ctrlKey, e.shiftKey, undefined, { isModelClick: true }); } if (column) { // todo: find more clear way to handle this case const target = <Element>e.target; const isErrorButtonClicked = target && target.classList.contains('js-error-button'); const isShowEditor = this.__isColumnEditable(columnIndex) || column.isShowEditor; if (isShowEditor // temporary desicion for complex cells || (column.type === 'Complex' && [complexValueTypes.expression, complexValueTypes.script].includes(this.model.get(column.key)?.type))) { // change boolean value immediatly if (column.type === objectPropertyTypes.BOOLEAN && this.lastPointedIndex !== columnIndex) { const newValue = column.storeArray ? [!this.model.get(column.key)?.[0]] : !this.model.get(column.key); this.model.set(column.key, newValue); } } else { const values = this.model.get(column.key); if (values?.length > 1 && this.multiValueShownIndex !== columnIndex) { this.__showDropDown(columnIndex); } } this.__debounceSelectPointedOnClick({ isErrorButtonClicked, column, columnIndex }); } this.gridEventAggregator.trigger('click', this.model); }, __selectPointedOnClick({ isErrorButtonClicked, column, columnIndex }: { isErrorButtonClicked: boolean, column: Column, columnIndex: number }) { if (!this.model.selected) { return; } if (this.__isDoubleClicked) { if (this.lastPointedIndex > -1 && this.lastPointedIndex === columnIndex) { this.__deselectPointed(); } this.__isDoubleClicked = false; return; } this.__selectPointed(columnIndex, true); if (isErrorButtonClicked) { this.__showErrorsForColumn({ column, index: columnIndex }); } }, __showDropDown(index: number) { const column = this.options.columns[index]; this.lastShowDropodwnIndex = this.multiValuePopout = CellViewFactory.tryGetMultiValueCellPanel(column, this.model, this.__getCellByColumnIndex(index)); if (this.multiValuePopout) { this.multiValuePopout.open(); this.multiValuePopout.on('close', () => delete this.multiValueShownIndex); this.multiValueShownIndex = index; } }, __getCellByColumnIndex(columnIndex: number): Element { const index = this.getOption('showCheckbox') ? columnIndex + 1 : columnIndex; const cellElement = this.el.children[index]; return cellElement; }, __getEditableCell(column: Column): Marionette.View<Backbone.Model> { return CellViewFactory.getCellViewForColumn(column, this.model) }, __isColumnEditable(columnIndex: number): boolean { if (columnIndex < 0 || !this.gridEventAggregator.isEditable) { return false; } const column = this.getOption('columns')[columnIndex]; return column.editable && !column.cellView && (!column.getReadonly || !column.getReadonly(this.model)) && (!column.getHidden || !column.getHidden(this.model)); }, __insertEditableCell({ column, index, CellView }: { column: Column, index: number, CellView: Marionette.View<Backbone.Model> }) { const cellView = this.__renderCell({ column, index, CellView }); const cellEl = this.__getCellByColumnIndex(index); this.el.replaceChild(cellView.el, cellEl); cellView.triggerMethod('before:attach'); cellView.triggerMethod('attach'); if (this.getOption('isTree') && index === 0) { this.insertFirstCellHtml(true); } this.__updateValidationErrorForColumn({ column, index }); }, __getReadonlyCell(column: Column): string{ return CellViewFactory.getCell(column, this.model); }, __insertReadonlyCell({ column, index }: { column: Column, index: number }) { const cell = this.__getReadonlyCell(column); const cellEl = this.__getCellByColumnIndex(index); cellEl.outerHTML = cell; this.cellViewsByKey[column.key]?.destroy(); delete this.cellViewsByKey[column.key]; if (this.getOption('isTree') && index === 0) { this.insertFirstCellHtml(true); } this.__updateValidationErrorForColumn({ column, index }); }, __handleDblClick() { if (!MobileService.isMobile) { this.__isDoubleClicked = true; this.gridEventAggregator.trigger('row:pointer:down', this.model); this.gridEventAggregator.trigger('dblclick', this.model); } }, __handleTouchEnd() { if (MobileService.isMobile) { if (this.isTouchMoveEvent) { this.isTouchMoveEvent = false; return; } this.gridEventAggregator.trigger('row:pointer:down', this.model); } }, __handleTouchCancel() { this.isTouchMoveEvent = false; }, __handleTouchMove() { this.isTouchMoveEvent = true; }, __handleSelection() { this.el.classList.add(classes.selected); if (this.gridEventAggregator.pointedCell !== undefined) { this.__selectPointed(this.gridEventAggregator.pointedCell); } }, __handleDeselection() { this.el.classList.remove(classes.selected); this.__deselectPointed(); }, __toggleCollapse(event: MouseEvent) { this.updateCollapsed(this.model); if (this.model.collapsed === undefined ? false : !this.model.collapsed) { this.model.collapse(); } else { this.model.expand(); } this.trigger('toggle:collapse', this.model); event.stopPropagation(); }, __onModelChecked() { this.internalCheck = true; if (this.model.children?.length) { this.model.children.forEach((model: GridItemModel) => model.check()); } this.internalCheck = false; this.__updateParentChecked(); }, __onModelUnchecked() { this.internalCheck = true; if (this.model.children?.length) { this.model.children.forEach((model: GridItemModel) => model.uncheck()); } this.internalCheck = false; this.__updateParentChecked(); }, __updateParentChecked() { if (this.internalCheck) { return; } const parentModel = this.model.parentModel; if (parentModel) { let checkedChildren = 0; parentModel.children.forEach((child: GridItemModel) => { if (child.checked) { checkedChildren++; } }); if (checkedChildren === 0) { parentModel.uncheck(); } else if (parentModel.children.length === checkedChildren) { parentModel.check(); } else { parentModel.checkSome(); } } }, __deselectPointed() { if (this.lastPointedIndex > -1) { const isColumnEditable = this.__isColumnEditable(this.lastPointedIndex); if (isColumnEditable) { const column = this.getOption('columns')[this.lastPointedIndex]; this.cellViewsByKey[column.key]?.blur?.(); this.__insertReadonlyCell({ column, index: this.lastPointedIndex }); } const lastPointedEl = this.__getCellByColumnIndex(this.lastPointedIndex); lastPointedEl?.classList.remove(classes.cellFocused); delete this.lastPointedIndex; delete this.lastFocusEditor; } }, __selectPointed(columnIndex: number, focusEditor: boolean = this.lastFocusEditor) { if (this.lastPointedIndex === columnIndex && this.lastFocusEditor === focusEditor) { return; } if (this.lastPointedIndex > -1 && this.lastPointedIndex !== columnIndex) { this.__deselectPointed(); } const isColumnEditable = this.__isColumnEditable(columnIndex); if (isColumnEditable) { const column = this.getOption('columns')[columnIndex]; if (focusEditor) { const cell = this.__getEditableCell(column); this.__insertEditableCell({ column, index: columnIndex, CellView: cell }); this.cellViewsByKey[column.key]?.focus?.(); } else { this.__insertReadonlyCell({ column, index: columnIndex }) } } this.gridEventAggregator.pointedCell = columnIndex; const pointedEl = this.__getCellByColumnIndex(columnIndex); if (!focusEditor || !isColumnEditable) { pointedEl?.focus(); } if (this.gridEventAggregator.isEditable) { pointedEl?.classList.add(classes.cellFocused); } this.lastPointedIndex = columnIndex; this.lastFocusEditor = focusEditor; }, __someFocused(nodeList: NodeList) { const someFunction = (node: Node) => document.activeElement === node || node.contains(document.activeElement); return Array.prototype.some.call(nodeList, someFunction); }, __handleEnter(e: KeyboardEvent) { this.__selectPointed(this.gridEventAggregator.pointedCell, true); }, __handleExit(e: KeyboardEvent) { this.__selectPointed(this.gridEventAggregator.pointedCell, false); }, __getFocusedColumnIndex(e: MouseEvent): number { const elIndex = [...this.el.children].findIndex((cell: Element) => cell.contains(<Node>e.target)); return this.options.showCheckbox ? elIndex - 1 : elIndex; }, __blink() { this.el.classList.add(classes.hover__transition); this.el.classList.add(classes.hover); setTimeout(() => this.el.classList.remove(classes.hover), config.TRANSITION_DELAY); setTimeout(() => this.el.classList.remove(classes.hover__transition), config.TRANSITION_DELAY * 2); // TODO: scrollIntoViewIfNeeded, IE, top? if (this.el.getBoundingClientRect().bottom > window.innerHeight) { this.el.scrollIntoView(false); } }, __updateState(model: GridItemModel, checkedState: 'checked' | 'unchecked' | 'checkedSome' | null) { let state = checkedState; if (!state) { if (this.model.checked) { state = 'checked'; } else if (this.model.checked === null) { state = 'checkedSome'; } } let innerHTML const checkbox = this.ui.checkbox.get(0); switch (state) { case 'checked': innerHTML = '<i class="fas fa-check"></i>'; this.el.classList.add(classes.rowChecked); this.el.classList.remove(classes.rowCheckedSome); break; case 'checkedSome': innerHTML = '<i class="fas fa-square"></i>'; this.el.classList.add(classes.rowChecked, classes.rowCheckedSome); break; case 'unchecked': default: innerHTML = ''; this.el.classList.remove(classes.rowChecked, classes.rowCheckedSome); break; } if (checkbox) { checkbox.innerHTML = innerHTML; } }, __setCellHidden({ column, index, isHidden } : { column: Column, index: number, isHidden: boolean }) { if (this.cellConfigs[column.key].isHidden === isHidden) { return; } const isTree = this.getOption('isTree'); this.cellConfigs[column.key].isHidden = isHidden; const oldCellView = this.cellViewsByKey[column.key]; this.__insertReadonlyCell({ column, index }); if (oldCellView) { oldCellView.destroy(); } }, __renderCell({ column, index, CellView }: { column: Column, index: number, CellView: Marionette.View<any> }) { const cellView = new CellView({ class: `${classes.cell} ${column.customClass || ''} ${column.columnClass || ''} ${this.__getDropdownClass(column)}`, tagName: 'td', schema: column, model: this.model, key: column.key }); cellView.el.setAttribute('tabindex', -1); cellView.render(); this.cellViewsByKey[column.key] = cellView; return cellView; }, __getDropdownClass(column: Column): string { switch (column.dataType || column.type) { case objectPropertyTypes.INSTANCE: case objectPropertyTypes.DOCUMENT: case objectPropertyTypes.ACCOUNT: case objectPropertyTypes.ENUM: case objectPropertyTypes.IMAGE: return classes.dropdownRoot; default: return ''; } }, __onValidated() { this.__updateValidationErrors({ onValidated: true}); }, __updateValidationErrors({ onValidated } : { onValidated?: boolean } = {}) { if (!onValidated && !this.model.validationError) { return; } this.options.columns.forEach((column: Column, index: number) => this.__updateValidationErrorForColumn({ column, index })); }, __updateValidationErrorForColumn({ column, index }: { column: Column, index: number }) { const cellEl = this.__getCellByColumnIndex(index); const columnError = this.model.validationError?.[column.key]; if (columnError) { const oldErrorButton = cellEl.querySelector(`.${classes.errorButton}`); if (oldErrorButton) { cellEl.removeChild(oldErrorButton); } let severityPart; if (Array.isArray(columnError) && columnError.every(error => error.severity?.toLowerCase() === validationSeverityTypes.WARNING)) { cellEl.classList.add(validationSeverityClasses.WARNING); severityPart = validationSeverityClasses.WARNING; } else { cellEl.classList.add(validationSeverityClasses.ERROR); severityPart = validationSeverityClasses.ERROR; } const errorButton = `<i class="${classes.errorButton} form-label__${severityPart}-button popout__action-btn"></i>`; cellEl.insertAdjacentHTML('beforeend', errorButton); } else { cellEl.classList.remove(validationSeverityClasses.ERROR); cellEl.classList.remove(validationSeverityClasses.WARNING); const errorEl = cellEl.querySelector(`.${classes.errorButton}`); if (errorEl) { errorEl.parentElement?.removeChild(errorEl); } } }, __showErrorsForColumn({ column, index } : { column: Column, index: number }) { if (this.errorShownIndex === index) { return; } const element = this.__getCellByColumnIndex(index); const errors = this.model.validationError[column.key]; this.errorCollection ? this.errorCollection.reset(errors) : (this.errorCollection = new Backbone.Collection(errors)); this.errorPopout = dropdown.factory.createPopout({ buttonView: Marionette.View, buttonModel: new Backbone.Model({ errorCollection: this.errorCollection }), panelView: ErrorsPanelView, panelViewOptions: { collection: this.errorCollection }, popoutFlow: 'right', element, autoOpen: false }); this.errorPopout.open(); this.errorPopout.on('close', () => delete this.errorShownIndex); this.errorShownIndex = index; } });