mailtrain/client/src/lib/tree.js

371 lines
No EOL
12 KiB
JavaScript

'use strict';
import React, {Component} from 'react';
import ReactDOMServer
from 'react-dom/server';
import {withTranslation} from './i18n';
import PropTypes
from 'prop-types';
import jQuery
from 'jquery';
import '../../static/jquery/jquery-ui-1.12.1.min.js';
import '../../static/fancytree/jquery.fancytree-all.min.js';
import '../../static/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.scss';
import axios
from './axios';
import {withPageHelpers} from './page'
import {
withAsyncErrorHandler,
withErrorHandling
} from './error-handling';
import styles
from "./styles.scss";
import {getUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
const TreeSelectMode = {
NONE: 0,
SINGLE: 1,
MULTI: 2
};
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers
], ['refresh'])
class TreeTable extends Component {
constructor(props) {
super(props);
this.state = {
treeData: []
};
if (props.data) {
this.state.treeData = props.data;
}
// Select Mode simply cannot be changed later. This is just to make sure we avoid inconsistencies if someone changes it anyway.
this.selectMode = this.props.selectMode;
}
static defaultProps = {
selectMode: TreeSelectMode.NONE
}
refresh() {
if (this.tree) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
this.updateSelection();
}
}
@withAsyncErrorHandler
async loadData(dataUrl) {
const response = await axios.get(getUrl(dataUrl));
const treeData = response.data;
for (const root of treeData) {
root.expanded = true;
for (const child of root.children) {
child.expanded = true;
}
}
this.setState({
treeData
});
}
static propTypes = {
dataUrl: PropTypes.string,
data: PropTypes.array,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
onSelectionChangedAsync: PropTypes.func,
actions: PropTypes.func,
withHeader: PropTypes.bool,
withDescription: PropTypes.bool,
noTable: PropTypes.bool,
withIcons: PropTypes.bool,
className: PropTypes.string
}
componentWillReceiveProps(nextProps) {
if (nextProps.data) {
this.setState({
treeData: nextProps.data
});
} else if (nextProps.dataUrl && this.props.dataUrl !== nextProps.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData(next.props.dataUrl);
}
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.selection !== nextProps.selection || this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
}
// XSS protection
sanitizeTreeData(unsafeData) {
const data = [];
for (const unsafeEntry of unsafeData) {
const entry = Object.assign({}, unsafeEntry);
entry.unsanitizedTitle = entry.title;
entry.title = ReactDOMServer.renderToStaticMarkup(<div>{entry.title}</div>);
entry.description = ReactDOMServer.renderToStaticMarkup(<div>{entry.description}</div>);
if (entry.children) {
entry.children = this.sanitizeTreeData(entry.children);
}
data.push(entry);
}
return data;
}
componentDidMount() {
if (!this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData(this.props.dataUrl);
}
let createNodeFn;
createNodeFn = (event, data) => {
const node = data.node;
const tdList = jQuery(node.tr).find(">td");
let tdIdx = 1;
if (this.props.withDescription) {
const descHtml = node.data.description; // This was already sanitized in sanitizeTreeData when the data was loaded
tdList.eq(tdIdx).html(descHtml);
tdIdx += 1;
}
if (this.props.actions) {
const linksContainer = jQuery(`<span class="${styles.actionLinks}"/>`);
const actions = this.props.actions(node);
for (const action of actions) {
if (action.action) {
const html = ReactDOMServer.renderToStaticMarkup(<a href="">{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); action.action(this) });
linksContainer.append(elem);
} else if (action.link) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.link}>{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
linksContainer.append(elem);
} else if (action.href) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.href}>{action.label}</a>);
const elem = jQuery(html);
linksContainer.append(elem);
} else {
const html = ReactDOMServer.renderToStaticMarkup(<span>{action.label}</span>);
const elem = jQuery(html);
linksContainer.append(elem);
}
}
tdList.eq(tdIdx).html(linksContainer);
tdIdx += 1;
}
};
const treeOpts = {
extensions: ['glyph'],
glyph: {
map: {
expanderClosed: 'fas fa-angle-right',
expanderLazy: 'fas fa-angle-right', // glyphicon-plus-sign
expanderOpen: 'fas fa-angle-down', // glyphicon-collapse-down
checkbox: 'fas fa-square',
checkboxSelected: 'fas fa-check-square',
folder: 'fas fa-folder',
folderOpen: 'fas fa-folder-open',
doc: 'fas fa-file',
docOpen: 'fas fa-file'
}
},
selectMode: (this.selectMode === TreeSelectMode.MULTI ? 2 : 1),
icon: !!this.props.withIcons,
autoScroll: true,
scrollParent: jQuery(this.domTableContainer),
source: this.sanitizeTreeData(this.state.treeData),
toggleEffect: false,
createNode: createNodeFn,
checkbox: this.selectMode === TreeSelectMode.MULTI,
activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null),
};
if (!this.props.noTable) {
treeOpts.extensions.push('table');
treeOpts.table = {
nodeColumnIdx: 0
};
}
this.tree = jQuery(this.domTable).fancytree(treeOpts).fancytree("getTree");
this.updateSelection();
}
componentDidUpdate(prevProps, prevState) {
if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) {
if (this.state.treeData != prevState.treeData) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
}
this.updateSelection();
}
}
updateSelection() {
const tree = this.tree;
if (this.selectMode === TreeSelectMode.MULTI) {
const selectSet = new Set(this.props.selection.map(key => this.stringifyKey(key)));
tree.enableUpdate(false);
tree.visit(node => node.setSelected(selectSet.has(node.key)));
tree.enableUpdate(true);
} else if (this.selectMode === TreeSelectMode.SINGLE) {
this.tree.activateKey(this.stringifyKey(this.props.selection));
}
}
@withAsyncErrorHandler
async onSelectionChanged(sel) {
if (this.props.onSelectionChangedAsync) {
await this.props.onSelectionChangedAsync(sel);
}
}
stringifyKey(key) {
if (key !== null && key !== undefined) {
return key.toString();
} else {
return key;
}
}
destringifyKey(key) {
if (/^(\-|\+)?([0-9]+|Infinity)$/.test(key)) {
return Number(key);
} else {
return key;
}
}
// Single-select
onActivate(event, data) {
const selection = this.destringifyKey(this.tree.getActiveNode().key);
if (selection !== this.props.selection) {
// noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(selection);
}
}
// Multi-select
onSelect(event, data) {
const newSel = this.tree.getSelectedNodes().map(node => this.destringifyKey(node.key)).sort();
const oldSel = this.props.selection;
let updated = false;
const length = oldSel.length;
if (length === newSel.length) {
for (let i = 0; i < length; i++) {
if (oldSel[i] !== newSel[i]) {
updated = true;
break;
}
}
} else {
updated = true;
}
if (updated) {
// noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(selection);
}
}
render() {
const t = this.props.t;
const props = this.props;
const actions = props.actions;
const withHeader = props.withHeader;
const withDescription = props.withDescription;
let containerClass = 'mt-treetable-container ' + (this.props.className || '');
if (this.selectMode === TreeSelectMode.NONE) {
containerClass += ' mt-treetable-inactivable';
} else {
if (!props.noTable) {
containerClass += ' table-hover';
}
}
if (!this.withHeader) {
containerClass += ' mt-treetable-noheader';
}
// FIXME: style={{ height: '100px', overflow: 'auto'}}
if (props.noTable) {
return (
<div className={containerClass} ref={(domElem) => { this.domTableContainer = domElem; }} >
<div ref={(domElem) => { this.domTable = domElem; }}>
</div>
</div>
);
} else {
let tableClass = 'table table-striped table-condensed';
if (this.selectMode !== TreeSelectMode.NONE) {
tableClass += ' table-hover';
}
return (
<div className={containerClass} ref={(domElem) => { this.domTableContainer = domElem; }} >
<table ref={(domElem) => { this.domTable = domElem; }} className={tableClass}>
{props.withHeader &&
<thead>
<tr>
<th className="mt-treetable-title">{t('name')}</th>
{withDescription && <th>{t('description')}</th>}
{actions && <th></th>}
</tr>
</thead>
}
<tbody>
<tr>
<td></td>
{withDescription && <td></td>}
{actions && <td></td>}
</tr>
</tbody>
</table>
</div>
);
}
}
}
export {
TreeTable,
TreeSelectMode
}