@platform/ui.datagrid
Version:
Isolated tabular DataGrid.
508 lines (507 loc) • 20 kB
JavaScript
import { Subject } from 'rxjs';
import { debounceTime, filter, map, share, takeUntil } from 'rxjs/operators';
import { commands } from '../../commands';
import { coord, defaultValue, MemoryCache, R, toSelectionValues, util, value as valueUtil, } from '../../common';
import { DEFAULT } from '../../common/constants';
import { keyboard } from '../../keyboard';
import { Cell } from '../Cell';
import { calc } from './Grid.calc';
export class Grid {
constructor(args) {
this._ = {
id: '',
totalColumns: -1,
totalRows: -1,
defaults: undefined,
keyBindings: undefined,
cache: MemoryCache.create(),
table: undefined,
dispose$: new Subject(),
events$: new Subject(),
redraw$: new Subject(),
isReady: false,
isEditing: false,
ns: DEFAULT.NS,
cells: {},
columns: {},
rows: {},
lastSelection: undefined,
calc: undefined,
refs: undefined,
};
this.dispose$ = this._.dispose$.pipe(share());
this.events$ = this._.events$.pipe(takeUntil(this.dispose$), share());
this.keyboard$ = this._.events$.pipe(filter((e) => e.type === 'GRID/keydown'), map((e) => e.payload), share());
this.getValueSync = (key) => {
const cell = this.data.cells[key];
return cell && typeof cell.value === 'string' ? cell.value : undefined;
};
this.refsTable = coord.refs.table({
getKeys: async () => Object.keys(this.data.cells),
getValue: async (key) => this.getValueSync(key),
});
this.command = (args) => {
const payload = {
command: args.command,
grid: this,
selection: this.selection,
props: args.props || {},
isCancelled: false,
cancel: () => {
payload.isCancelled = true;
if (args.cancel) {
args.cancel();
}
},
};
this.fire({ type: 'GRID/command', payload });
return this;
};
this.fire = (e) => {
this._.events$.next(e);
return this;
};
this._init(args);
}
static create(args) {
return new Grid(args);
}
static defaults(input) {
const partial = input || {};
return {
ns: defaultValue(partial.ns, DEFAULT.NS),
totalColumns: defaultValue(partial.totalColumns, DEFAULT.TOTAL_COLUMNS),
totalRows: defaultValue(partial.totalRows, DEFAULT.TOTAL_ROWS),
columnWidth: defaultValue(partial.columnWidth, DEFAULT.COLUMN.WIDTH),
columnWidthMin: defaultValue(partial.columnWidthMin, DEFAULT.COLUMN.WIDTH_MIN),
rowHeight: defaultValue(partial.rowHeight, DEFAULT.ROW.HEIGHT),
rowHeightMin: defaultValue(partial.rowHeightMin, DEFAULT.ROW.HEIGHT_MIN),
};
}
static toDataArray(args) {
return Array.from({ length: args.totalRows }).map((v, row) => Array.from({ length: args.totalColumns }).map((v, column) => {
return args.cells[Cell.toKey({ row, column })];
}));
}
static toNs(input) {
const ns = input === undefined ? DEFAULT.NS : typeof input === 'string' ? { id: input } : input;
ns.id = ns.id.trim().replace(/^ns\:/, '');
return ns;
}
static isDefaultValue(args) {
const { kind, value } = args;
const defaults = Grid.defaults(args.defaults);
return util.isDefaultGridValue({ defaults, kind, value });
}
_init(args) {
const defaults = (this._.defaults = Grid.defaults(args.defaults));
this._.totalColumns = defaultValue(args.totalColumns, defaults.totalColumns);
this._.totalRows = defaultValue(args.totalRows, defaults.totalRows);
this._.ns = Grid.toNs(args.ns);
this._.cells = args.cells || {};
this._.columns = args.columns || {};
this._.rows = args.rows || {};
this._.calc = calc({ grid: this, getFunc: args.getFunc });
this.events$
.pipe(filter((e) => e.type === 'GRID/ready'))
.subscribe(() => (this._.isReady = true));
this._.keyBindings = R.uniqBy(R.prop('command'), [
...(args.keyBindings || []),
...DEFAULT.KEY_BINDINGS,
]);
const { table } = args;
if (table) {
this.initialize({ table });
}
}
initialize(args) {
const { table } = args;
this._.table = table;
this._.id = `grid/${table.guid.replace(/^ht_/, '')}`;
keyboard.init({ grid: this });
commands.init({ grid: this, fire: this.fire });
this._.redraw$
.pipe(takeUntil(this.dispose$), debounceTime(0))
.subscribe((e) => this.fire({ type: 'GRID/redraw', payload: {} }));
this.events$
.pipe(filter((e) => e.type === 'GRID/EDITOR/begin'))
.subscribe(() => (this._.isEditing = true));
const editEnd$ = this.events$.pipe(filter((e) => e.type === 'GRID/EDITOR/end'), map((e) => e.payload));
editEnd$.subscribe(() => (this._.isEditing = false));
editEnd$.pipe(filter((e) => e.isChanged)).subscribe((e) => {
const key = e.cell.key;
const value = e.value.to;
const props = Object.assign(Object.assign({}, this.cell(key).data.props), { value: undefined });
const cell = { value, props };
this.changeCells({ [key]: cell }, { source: 'EDIT' });
});
editEnd$
.pipe(filter((e) => !e.isCancelled), filter((e) => e.cell.key === this.selection.cell))
.subscribe((e) => {
const below = e.cell.siblings.bottom;
if (below) {
this.select({ cell: below });
}
});
const selection$ = this.events$.pipe(filter((e) => e.type === 'GRID/selection'), map((e) => e.payload));
selection$
.pipe(filter((e) => Boolean(e.to.cell)))
.subscribe((e) => (this._.lastSelection = e.to));
selection$
.pipe(debounceTime(0), filter((e) => !Boolean(e.from.cell) && Boolean(e.to.cell)))
.subscribe((e) => this.fire({ type: 'GRID/focus', payload: { grid: this } }));
selection$
.pipe(debounceTime(0), filter((e) => Boolean(e.from.cell) && !Boolean(e.to.cell)))
.subscribe((e) => this.fire({ type: 'GRID/blur', payload: { grid: this } }));
this.events$
.pipe(filter((e) => (this._.isReady = true)), filter((e) => e.type === 'GRID/cells/change'), map((e) => e.payload))
.subscribe(async (e) => {
const cells = e.changes.map((change) => change.cell.key);
await this.calc.update({ cells });
});
return this;
}
get id() {
return this._.id;
}
get totalColumns() {
return this._.totalColumns;
}
get totalRows() {
return this._.totalRows;
}
get defaults() {
return this._.defaults;
}
get keyBindings() {
return this._.keyBindings;
}
get isInitialized() {
return Boolean(this._.table);
}
get isDisposed() {
return this._.table.isDestroyed || this._.dispose$.isStopped;
}
get isReady() {
return this.isDisposed ? false : this._.isReady;
}
get isEditing() {
return this._.isEditing;
}
get data() {
const { ns, cells, columns, rows } = this._;
const files = {};
return { ns, cells, columns, rows, files };
}
setCells(cells) {
cells = Object.assign({}, cells);
const totalColumns = this.totalColumns;
const totalRows = this.totalRows;
const data = Grid.toDataArray({ cells, totalColumns, totalRows });
this._.cells = cells;
this._.table.loadData(data);
}
get calc() {
return this._.calc;
}
get selection() {
const toKey = (coord) => Cell.toKey({ row: coord.row, column: coord.col });
const last = this._.table.getSelectedRangeLast();
const cell = last ? toKey(last.highlight) : undefined;
const selectedRanges = this._.table.getSelectedRange() || [];
let ranges = selectedRanges.map((item) => `${toKey(item.from)}:${toKey(item.to)}`);
ranges = ranges.length === 1 && ranges[0] === `${cell}:${cell}` ? [] : ranges;
let all = false;
if (ranges.length > 0) {
const min = { row: -1, col: -1 };
const max = { row: -1, col: -1 };
selectedRanges.forEach((range) => {
const { from, to } = range;
min.row = min.row === -1 || from.row < min.row ? from.row : min.row;
min.col = min.col === -1 || from.col < min.col ? from.col : min.col;
max.row = max.row === -1 || to.row > max.row ? to.row : max.row;
max.col = max.col === -1 || to.col > max.col ? to.col : max.col;
});
if (min.row === 0 &&
min.col === 0 &&
max.row === this.totalRows - 1 &&
max.col === this.totalColumns - 1) {
all = true;
}
}
if (ranges.length > 0) {
const totalColumns = this.totalColumns;
const totalRows = this.totalRows;
const union = coord.range.union(ranges).formated({ totalColumns, totalRows });
ranges = union.ranges.map((range) => range.key);
if (cell) {
ranges = ranges.filter((range) => range !== `${cell}:${cell}`);
}
ranges = R.uniq(ranges);
}
let result = { cell, ranges };
result = all ? Object.assign(Object.assign({}, result), { all }) : result;
return result;
}
get selectionValues() {
const cells = this.data.cells;
const selection = this.selection;
return toSelectionValues({ cells, selection });
}
dispose() {
const { table, dispose$ } = this._;
if (!table.isDestroyed) {
table.destroy();
}
dispose$.next();
dispose$.complete();
}
mergeCells(args) {
const { cells } = args;
const list = args.init
? []
: this._.table.getSettings().mergeCells || [];
const map = list.reduce((acc, next) => {
const key = coord.cell.toKey(next.col, next.row);
acc[key] = next;
return acc;
}, {});
Object.keys(cells).map((key) => {
const item = cells[key];
if (item) {
const props = item.props || {};
const merge = props.merge;
if (merge) {
const rowspan = Math.max(1, defaultValue(merge.rowspan, 1));
const colspan = Math.max(1, defaultValue(merge.colspan, 1));
const { row, column: col } = coord.cell.fromKey(key);
map[key] = { row, col, rowspan, colspan };
}
}
});
const mergeCells = Object.keys(map).map((key) => map[key]);
this._.table.updateSettings({ mergeCells }, false);
return this;
}
changeCells(cells, options = {}) {
const done = () => this;
const format = (key, to) => {
if (Cell.isEmpty(to)) {
return undefined;
}
if (to) {
to = Object.assign(Object.assign({}, to), { hash: util.gridCellHash(this, key, to) });
if (Cell.isEmptyProps(to.props)) {
delete to.props;
}
}
return to;
};
if (options.init && cells && Object.keys(cells).length === 0) {
this.setCells({});
return done();
}
if (cells) {
const current = Object.assign({}, (options.init ? {} : this.data.cells));
cells = Object.assign({}, cells);
Object.keys(cells)
.filter((key) => !coord.cell.isCell(key))
.forEach((key) => delete cells[key]);
const formatted = Object.keys(cells).reduce((acc, key) => {
acc[key] = {
from: format(key, current[key]),
to: format(key, cells[key]),
};
return acc;
}, {});
const changes = Object.keys(cells).map((key) => {
const cell = this.cell(key);
const { from, to } = formatted[key];
return Cell.changeEvent({ cell, from, to });
});
const isChanged = changes.some((e) => e.isChanged);
if (!isChanged) {
return done();
}
if (!options.silent) {
const payload = {
source: defaultValue(options.source, 'EDIT'),
changes,
get isCancelled() {
return changes.some((change) => change.isCancelled);
},
cancel() {
changes.forEach((change) => change.cancel());
},
};
this.fire({ type: 'GRID/cells/change', payload });
changes
.filter((change) => change.isModified)
.forEach((change) => (cells[change.cell.key] = change.value.to));
changes
.filter((change) => change.isCancelled)
.forEach((change) => (cells[change.cell.key] = change.value.from));
}
const mergeChanges = {};
const updates = Object.assign(Object.assign({}, current), Object.keys(formatted).reduce((acc, key) => {
acc[key] = formatted[key].to;
return acc;
}, {}));
Object.keys(formatted).forEach((key) => {
const { from, to } = formatted[key];
if (Cell.isEmpty(to)) {
delete updates[key];
return;
}
const isMergeChanged = !R.equals(((from || {}).props || {}).merge, ((to || {}).props || {}).merge);
if (isMergeChanged) {
mergeChanges[key] = to;
}
});
if (Object.keys(mergeChanges).length > 0) {
this.mergeCells({ cells: mergeChanges });
}
this.setCells(updates);
}
return done();
}
changeColumns(columns, options = {}) {
const { source = 'UPDATE' } = options;
const from = Object.assign({}, this._.columns);
const to = Object.assign({}, from);
let changes = [];
Object.keys(columns).forEach((key) => {
const prev = from[key] || { props: { grid: { width: -1 } } };
const next = columns[key] || { props: { grid: { width: this.defaults.columnWidth } } };
const nextProps = next.props || {};
const isDefault = nextProps.grid && nextProps.grid.width === this.defaults.columnWidth;
if (isDefault) {
delete to[key];
}
else {
to[key] = next;
}
if (!R.equals(prev, next)) {
changes = [...changes, { column: key, source, from: prev, to: next }];
}
});
this._.columns = to;
if (!R.equals(from, to)) {
this.fire({ type: 'GRID/columns/change', payload: { from, to, changes } });
}
return this;
}
changeRows(rows, options = {}) {
const { source = 'UPDATE' } = options;
const from = Object.assign({}, this._.rows);
const to = Object.assign({}, from);
let changes = [];
Object.keys(rows).forEach((key) => {
const prev = from[key] || { props: { grid: { height: -1 } } };
const next = rows[key] || { props: { grid: { height: this.defaults.rowHeight } } };
const nextProps = next.props || {};
const isDefault = nextProps.grid && nextProps.grid.height === this.defaults.rowHeight;
if (isDefault) {
delete to[key];
}
else {
to[key] = next;
}
if (!R.equals(prev, next)) {
const row = coord.cell.fromKey(key).row;
changes = [...changes, { row, source, from: prev, to: next }];
}
});
this._.rows = to;
if (!R.equals(from, to)) {
this.fire({
type: 'GRID/rows/change',
payload: { from, to, changes },
});
}
return this;
}
cell(key) {
const args = typeof key === 'string' ? Cell.fromKey(key) : key;
const { row, column } = args;
if (row < 0 || column < 0) {
let msg = `Cell does not exist at row:${row}, column:${column}.`;
msg = typeof key === 'string' ? `${msg} key: "${key}"` : msg;
throw new Error(msg);
}
const ns = this.data.ns.id;
const cacheKey = `${ns}:cell/${column}:${row}`;
return this._.cache.get(cacheKey, () => {
const table = this._.table;
return Cell.create({ ns, table, row, column });
});
}
scrollTo(args) {
const { row, column } = this.toPosition(args.cell);
const { snapToBottom = false, snapToRight = false } = args;
this._.table.scrollViewportTo(row, column, snapToBottom, snapToRight);
return this;
}
select(args) {
const totalColumns = this.totalColumns;
const totalRows = this.totalRows;
const table = this._.table;
const scrollToCell = valueUtil.defaultValue(args.scrollToCell, true);
const ranges = (args.ranges || [])
.map((range) => Cell.toRangePositions({ range, totalColumns, totalRows }))
.map(({ start, end }) => {
return [start.row, start.column, end.row, end.column];
});
const pos = this.toPosition(args.cell);
const current = [pos.row, pos.column, pos.row, pos.column];
const selection = [...ranges, current];
table.selectCells(selection, scrollToCell);
return this;
}
deselect() {
this._.table.deselectCell();
return this;
}
focus() {
const last = this._.lastSelection;
const cell = (last && last.cell) || 'A1';
const ranges = (last && last.ranges) || [];
this.select({ cell, ranges });
return this;
}
blur() {
this.fire({ type: 'GRID/blur', payload: { grid: this } });
this.deselect();
return this;
}
redraw() {
this._.redraw$.next();
return this;
}
toPosition(ref) {
const pos = Cell.toPosition(ref);
const row = R.clamp(0, this.totalRows - 1, pos.row);
const column = R.clamp(0, this.totalColumns - 1, pos.column);
return { row, column };
}
updateHashes(options = {}) {
const data = this.data;
const cells = Object.assign({}, data.cells);
let isChanged = false;
Object.keys(cells).forEach((key) => {
const cell = cells[key];
if (cell) {
let hash = cell.hash;
if (!hash || options.force) {
hash = util.gridCellHash(this, key, cell);
cells[key] = Object.assign(Object.assign({}, cell), { hash });
isChanged = true;
}
}
});
if (isChanged) {
this.setCells(cells);
}
return this;
}
}