UNPKG

react-native

Version:

A framework for building native apps using React

695 lines (634 loc) • 26.8 kB
// @flow import invariant from 'invariant'; import type { FlattenedStack } from './StackRegistry'; // expander ID definitions const FIELD_EXPANDER_ID_MIN = 0x0000; const FIELD_EXPANDER_ID_MAX = 0x7fff; const STACK_EXPANDER_ID_MIN = 0x8000; const STACK_EXPANDER_ID_MAX = 0xffff; // used for row.expander which reference state.activeExpanders (with frame index masked in) const INVALID_ACTIVE_EXPANDER = -1; const ACTIVE_EXPANDER_MASK = 0xffff; const ACTIVE_EXPANDER_FRAME_SHIFT = 16; // aggregator ID definitions const AGGREGATOR_ID_MAX = 0xffff; // active aggragators can have sort order changed in the reference const ACTIVE_AGGREGATOR_MASK = 0xffff; const ACTIVE_AGGREGATOR_ASC_BIT = 0x10000; // tree node state definitions const NODE_EXPANDED_BIT = 0x0001; // this row is expanded const NODE_REAGGREGATE_BIT = 0x0002; // children need aggregates const NODE_REORDER_BIT = 0x0004; // children need to be sorted const NODE_REPOSITION_BIT = 0x0008; // children need position const NODE_INDENT_SHIFT = 16; function _calleeFrameIdGetter(stack: FlattenedStack, depth: number): number { return stack[depth]; } function _callerFrameIdGetter(stack: FlattenedStack, depth: number): number { return stack[stack.length - depth - 1]; } function _createStackComparers( stackGetter: StackGetter, frameIdGetter: FrameIdGetter, maxStackDepth: number): Array<Comparer<number>> { const comparers = new Array(maxStackDepth); for (let depth = 0; depth < maxStackDepth; depth++) { const captureDepth = depth; // NB: to capture depth per loop iteration comparers[depth] = function calleeStackComparer(rowA: number, rowB: number): number { const a = stackGetter(rowA); const b = stackGetter(rowB); // NB: we put the stacks that are too short at the top, // so they can be grouped into the '<exclusive>' bucket if (a.length <= captureDepth && b.length <= captureDepth) { return 0; } else if (a.length <= captureDepth) { return -1; } else if (b.length <= captureDepth) { return 1; } return frameIdGetter(a, captureDepth) - frameIdGetter(b, captureDepth); }; } return comparers; } function _createTreeNode( parent: Row | null, label: string, indices: Int32Array, expander: number): Row { const indent = parent === null ? 0 : (parent.state >>> NODE_INDENT_SHIFT) + 1; // eslint-disable-line no-bitwise, max-len const state = NODE_REPOSITION_BIT | // eslint-disable-line no-bitwise NODE_REAGGREGATE_BIT | NODE_REORDER_BIT | (indent << NODE_INDENT_SHIFT); // eslint-disable-line no-bitwise return { parent, // null if root children: null, // array of children nodes label, // string to show in UI indices, // row indices under this node aggregates: null, // result of aggregate on indices expander, // index into state.activeExpanders top: 0, // y position of top row (in rows) height: 1, // number of rows including children state, // see NODE_* definitions above }; } const NO_SORT_ORDER: Comparer<*> = (): number => 0; type Comparer<T> = (a: T, b: T) => number; type Aggregator = { name: string, // name for column aggregator: (indexes: Int32Array) => number, // index array -> aggregate value formatter: (value: number) => string, // aggregate value -> display string sorter: Comparer<number>, // compare two aggregate values } type FieldExpander = { name: string, comparer: Comparer<number>, getter: (rowIndex: number) => any, formatter: (value: any) => string, } type StackGetter = (rowIndex: number) => FlattenedStack; // (row) => [frameId int] type FrameIdGetter = (stack: FlattenedStack, depth: number) => number; // (stack,depth) -> frame id export type FrameGetter = (id: number) => any; // (frameId int) => frame obj export type FrameFormatter = (frame: any) => string; // (frame obj) => display string type StackExpander = { name: string, // display name of expander comparers: Array<Comparer<number>>, // depth -> comparer stackGetter: StackGetter, frameIdGetter: FrameIdGetter, frameGetter: FrameGetter, frameFormatter: FrameFormatter, } export type Row = { top: number, height: number, state: number, parent: Row | null, indices: Int32Array, aggregates: Array<number> | null, children: Array<Row> | null, expander: number, label: string, } type State = { fieldExpanders: Array<FieldExpander>, // tree expanders that expand on simple values stackExpanders: Array<StackExpander>, // tree expanders that expand stacks activeExpanders: Array<number>, // index into field or stack expanders, hierarchy of tree aggregators: Array<Aggregator>, // all available aggregators, might not be used activeAggregators: Array<number>, // index into aggregators, to actually compute sorter: Comparer<*>, root: Row, } export default class AggrowExpander { // eslint-disable-line no-unused-vars indices: Int32Array; state: State; constructor(numRows: number) { this.indices = new Int32Array(numRows); for (let i = 0; i < numRows; i++) { this.indices[i] = i; } this.state = { fieldExpanders: [], stackExpanders: [], activeExpanders: [], aggregators: [], activeAggregators: [], sorter: NO_SORT_ORDER, root: _createTreeNode(null, '<root>', this.indices, INVALID_ACTIVE_EXPANDER), }; } _evaluateAggregate(row: Row) { const activeAggregators = this.state.activeAggregators; const aggregates = new Array(activeAggregators.length); for (let j = 0; j < activeAggregators.length; j++) { const aggregator = this.state.aggregators[activeAggregators[j]]; aggregates[j] = aggregator.aggregator(row.indices); } row.aggregates = aggregates; // eslint-disable-line no-param-reassign row.state |= NODE_REAGGREGATE_BIT; // eslint-disable-line no-bitwise, no-param-reassign } _evaluateAggregates(row: Row) { if ((row.state & NODE_EXPANDED_BIT) !== 0) { // eslint-disable-line no-bitwise const children = row.children; invariant(children, 'Expected non-null children'); for (let i = 0; i < children.length; i++) { this._evaluateAggregate(children[i]); } row.state |= NODE_REORDER_BIT; // eslint-disable-line no-bitwise, no-param-reassign } row.state ^= NODE_REAGGREGATE_BIT; // eslint-disable-line no-bitwise, no-param-reassign } _evaluateOrder(row: Row) { if ((row.state & NODE_EXPANDED_BIT) !== 0) { // eslint-disable-line no-bitwise const children = row.children; invariant(children, 'Expected non-null children'); for (let i = 0; i < children.length; i++) { const child = children[i]; child.state |= NODE_REORDER_BIT; // eslint-disable-line no-bitwise } children.sort(this.state.sorter); row.state |= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign } row.state ^= NODE_REORDER_BIT; // eslint-disable-line no-bitwise, no-param-reassign } _evaluatePosition(row: Row) { // eslint-disable-line class-methods-use-this if ((row.state & NODE_EXPANDED_BIT) !== 0) { // eslint-disable-line no-bitwise const children = row.children; invariant(children, 'Expected a children array'); let childTop = row.top + 1; for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.top !== childTop) { child.top = childTop; child.state |= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise } childTop += child.height; } } row.state ^= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign } _getRowsImpl(row: Row, top: number, height: number, result: Array<Row | null>) { if ((row.state & NODE_REAGGREGATE_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluateAggregates(row); } if ((row.state & NODE_REORDER_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluateOrder(row); } if ((row.state & NODE_REPOSITION_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluatePosition(row); } if (row.top >= top && row.top < top + height) { invariant( result[row.top - top] === null, `getRows put more than one row at position ${row.top} into result`); result[row.top - top] = row; // eslint-disable-line no-param-reassign } if ((row.state & NODE_EXPANDED_BIT) !== 0) { // eslint-disable-line no-bitwise const children = row.children; invariant(children, 'Expected non-null children'); for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.top < top + height && top < child.top + child.height) { this._getRowsImpl(child, top, height, result); } } } } _updateHeight(row: Row | null, heightChange: number) { // eslint-disable-line class-methods-use-this, max-len while (row !== null) { row.height += heightChange; // eslint-disable-line no-param-reassign row.state |= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign row = row.parent; // eslint-disable-line no-param-reassign } } _addChildrenWithFieldExpander(row: Row, expander: FieldExpander, nextActiveIndex: number) { // eslint-disable-line class-methods-use-this, max-len const rowIndices = row.indices; const comparer = expander.comparer; const formatter = expander.formatter; const getter = expander.getter; rowIndices.sort(comparer); let begin = 0; let end = 1; row.children = []; // eslint-disable-line no-param-reassign while (end < rowIndices.length) { if (comparer(rowIndices[begin], rowIndices[end]) !== 0) { invariant(row.children, 'Expected a children array'); row.children.push(_createTreeNode( row, `${expander.name}: ${formatter(getter(rowIndices[begin]))}`, rowIndices.subarray(begin, end), nextActiveIndex)); begin = end; } end += 1; } row.children.push(_createTreeNode( row, `${expander.name}: ${formatter(getter(rowIndices[begin]))}`, rowIndices.subarray(begin, end), nextActiveIndex)); } _addChildrenWithStackExpander( // eslint-disable-line class-methods-use-this row: Row, expander: StackExpander, activeIndex: number, depth: number, nextActiveIndex: number) { const rowIndices = row.indices; const stackGetter = expander.stackGetter; const frameIdGetter = expander.frameIdGetter; const frameGetter = expander.frameGetter; const frameFormatter = expander.frameFormatter; const comparer = expander.comparers[depth]; const expandNextFrame = activeIndex | ((depth + 1) << ACTIVE_EXPANDER_FRAME_SHIFT); // eslint-disable-line no-bitwise, max-len rowIndices.sort(comparer); let columnName = ''; if (depth === 0) { columnName = `${expander.name}: `; } // put all the too-short stacks under <exclusive> let begin = 0; let beginStack = null; row.children = []; // eslint-disable-line no-param-reassign while (begin < rowIndices.length) { beginStack = stackGetter(rowIndices[begin]); if (beginStack.length > depth) { break; } begin += 1; } invariant(beginStack !== null, 'Expected beginStack at this point'); if (begin > 0) { row.children.push(_createTreeNode( row, `${columnName}<exclusive>`, rowIndices.subarray(0, begin), nextActiveIndex)); } // aggregate the rest under frames if (begin < rowIndices.length) { let end = begin + 1; while (end < rowIndices.length) { const endStack = stackGetter(rowIndices[end]); if (frameIdGetter(beginStack, depth) !== frameIdGetter(endStack, depth)) { invariant(row.children, 'Expected a children array'); row.children.push(_createTreeNode( row, columnName + frameFormatter(frameGetter(frameIdGetter(beginStack, depth))), rowIndices.subarray(begin, end), expandNextFrame)); begin = end; beginStack = endStack; } end += 1; } row.children.push(_createTreeNode( row, columnName + frameFormatter(frameGetter(frameIdGetter(beginStack, depth))), rowIndices.subarray(begin, end), expandNextFrame)); } } _contractRow(row: Row) { invariant( (row.state & NODE_EXPANDED_BIT) !== 0, // eslint-disable-line no-bitwise 'Cannot contract row; already contracted!'); row.state ^= NODE_EXPANDED_BIT; // eslint-disable-line no-bitwise, no-param-reassign const heightChange = 1 - row.height; this._updateHeight(row, heightChange); } _pruneExpanders(row: Row, oldExpander: number, newExpander: number) { row.state |= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign if (row.expander === oldExpander) { row.state |= NODE_REAGGREGATE_BIT | NODE_REORDER_BIT | NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign, max-len if ((row.state & NODE_EXPANDED_BIT) !== 0) { // eslint-disable-line no-bitwise this._contractRow(row); } row.children = null; // eslint-disable-line no-param-reassign row.expander = newExpander; // eslint-disable-line no-param-reassign } else { row.state |= NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign const children = row.children; if (children != null) { for (let i = 0; i < children.length; i++) { const child = children[i]; this._pruneExpanders(child, oldExpander, newExpander); } } } } addFieldExpander( name: string, comparer: Comparer<number>, getter: (rowIndex: number) => any, formatter: (value: any) => string): number { invariant( FIELD_EXPANDER_ID_MIN + this.state.fieldExpanders.length < FIELD_EXPANDER_ID_MAX, 'too many field expanders!'); this.state.fieldExpanders.push({ name, comparer, getter, formatter }); return FIELD_EXPANDER_ID_MIN + this.state.fieldExpanders.length - 1; } addStackExpander( name: string, maxStackDepth: number, stackGetter: StackGetter, frameGetter: FrameGetter, frameFormatter: FrameFormatter, reverse: boolean): number { invariant( STACK_EXPANDER_ID_MIN + this.state.fieldExpanders.length < STACK_EXPANDER_ID_MAX, 'Too many stack expanders!'); const idGetter = reverse ? _callerFrameIdGetter : _calleeFrameIdGetter; this.state.stackExpanders.push({ name, stackGetter, comparers: _createStackComparers(stackGetter, idGetter, maxStackDepth), frameIdGetter: idGetter, frameGetter, frameFormatter, }); return STACK_EXPANDER_ID_MIN + this.state.stackExpanders.length - 1; } getExpanders(): Array<number> { const expanders = []; for (let i = 0; i < this.state.fieldExpanders.length; i++) { expanders.push(FIELD_EXPANDER_ID_MIN + i); } for (let i = 0; i < this.state.stackExpanders.length; i++) { expanders.push(STACK_EXPANDER_ID_MIN + i); } return expanders; } getExpanderName(id: number): string { if (id >= FIELD_EXPANDER_ID_MIN && id <= FIELD_EXPANDER_ID_MAX) { return this.state.fieldExpanders[id - FIELD_EXPANDER_ID_MIN].name; } else if (id >= STACK_EXPANDER_ID_MIN && id <= STACK_EXPANDER_ID_MAX) { return this.state.stackExpanders[id - STACK_EXPANDER_ID_MIN].name; } throw new Error(`Unknown expander ID ${id.toString()}`); } setActiveExpanders(ids: Array<number>) { for (let i = 0; i < ids.length; i++) { const id = ids[i]; if (id >= FIELD_EXPANDER_ID_MIN && id <= FIELD_EXPANDER_ID_MAX) { invariant( id - FIELD_EXPANDER_ID_MIN < this.state.fieldExpanders.length, `field expander for id ${id.toString()} does not exist!`); } else if (id >= STACK_EXPANDER_ID_MIN && id <= STACK_EXPANDER_ID_MAX) { invariant(id - STACK_EXPANDER_ID_MIN < this.state.stackExpanders.length, `stack expander for id ${id.toString()} does not exist!`); } } for (let i = 0; i < ids.length; i++) { if (this.state.activeExpanders.length <= i) { this._pruneExpanders(this.state.root, INVALID_ACTIVE_EXPANDER, i); break; } else if (ids[i] !== this.state.activeExpanders[i]) { this._pruneExpanders(this.state.root, i, i); break; } } // TODO: if ids is prefix of activeExpanders, we need to make an expander invalid this.state.activeExpanders = ids.slice(); } getActiveExpanders(): Array<number> { return this.state.activeExpanders.slice(); } addAggregator( name: string, aggregator: (indexes: Int32Array) => number, formatter: (value: number) => string, sorter: Comparer<number>): number { invariant(this.state.aggregators.length < AGGREGATOR_ID_MAX, 'too many aggregators!'); this.state.aggregators.push({ name, aggregator, formatter, sorter }); return this.state.aggregators.length - 1; } getAggregators(): Array<number> { const aggregators = []; for (let i = 0; i < this.state.aggregators.length; i++) { aggregators.push(i); } return aggregators; } getAggregatorName(id: number): string { return this.state.aggregators[id & ACTIVE_AGGREGATOR_MASK].name; // eslint-disable-line no-bitwise, max-len } setActiveAggregators(ids: Array<number>) { for (let i = 0; i < ids.length; i++) { const id = ids[i] & ACTIVE_AGGREGATOR_MASK; // eslint-disable-line no-bitwise invariant( id >= 0 && id < this.state.aggregators.length, `aggregator id ${id.toString()} not valid`); } this.state.activeAggregators = ids.slice(); // NB: evaluate root here because dirty bit is for children // so someone has to start with root, and it might as well be right away this._evaluateAggregate(this.state.root); let sorter = NO_SORT_ORDER; for (let i = ids.length - 1; i >= 0; i--) { const ascending = (ids[i] & ACTIVE_AGGREGATOR_ASC_BIT) !== 0; // eslint-disable-line no-bitwise, max-len const id = ids[i] & ACTIVE_AGGREGATOR_MASK; // eslint-disable-line no-bitwise const comparer = this.state.aggregators[id].sorter; const captureSorter = sorter; const captureIndex = i; sorter = (a: Row, b: Row): number => { invariant(a.aggregates && b.aggregates, 'Expected aggregates.'); const c = comparer(a.aggregates[captureIndex], b.aggregates[captureIndex]); if (c === 0) { return captureSorter(a, b); } return ascending ? -c : c; }; } this.state.sorter = sorter; // eslint-disable-line no-param-reassign this.state.root.state |= NODE_REORDER_BIT; // eslint-disable-line no-bitwise, no-param-reassign } getActiveAggregators(): Array<number> { return this.state.activeAggregators.slice(); } getRows(top: number, height: number): Array<Row | null> { const result = new Array(height); for (let i = 0; i < height; i++) { result[i] = null; } this._getRowsImpl(this.state.root, top, height, result); return result; } _findRowImpl(fromRow: number, predicate: (row: Row) => boolean, row: Row): number { if (row.top > fromRow && predicate(row)) { return row.top; // this row is a match! } // remember how to clean up after ourselves so we only expand as little as possible const contractChildren = this.canExpand(row); const cleanUpChildren = row.children === null; if (contractChildren) { this.expand(row); } // evaluate position so we search in the correct order if ((row.state & NODE_REAGGREGATE_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluateAggregates(row); } if ((row.state & NODE_REORDER_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluateOrder(row); } if ((row.state & NODE_REPOSITION_BIT) !== 0) { // eslint-disable-line no-bitwise this._evaluatePosition(row); } // TODO: encapsulate row state management somewhere so logic can be shared with _getRowsImpl // search in children const children = row.children; if (children !== null) { for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.top + child.height > fromRow) { const find = this._findRowImpl(fromRow, predicate, child); if (find >= 0) { return find; } } } } // clean up to leave the tree how it was if we didn't find anything // this also saves memory if (contractChildren) { this.contract(row); } if (cleanUpChildren) { row.children = null; } return -1; } // findRow - find the first row that matches a predicate // parameters // predicate: returns true when row is found // fromRow: start search from after this row index (negative for start at beginning) // returns: index of first row that matches, -1 if no match found findRow(predicate: (row: Row) => boolean, fromRow: ?number): number { return this._findRowImpl(!fromRow ? -1 : fromRow, predicate, this.state.root); } getRowLabel(row: Row): string { // eslint-disable-line class-methods-use-this return row.label; } getRowIndent(row: Row): number { // eslint-disable-line class-methods-use-this return row.state >>> NODE_INDENT_SHIFT; // eslint-disable-line no-bitwise } getRowExpanderIndex(row: Row): number { // eslint-disable-line class-methods-use-this if (row.parent) { return row.parent.expander & ACTIVE_EXPANDER_MASK; // eslint-disable-line no-bitwise } return -1; } getRowExpansionPath(row: Row | null): Array<any> { const path = []; invariant(row, 'Expected non-null row here'); const index = row.indices[0]; row = row.parent; // eslint-disable-line no-param-reassign while (row) { const exIndex = row.expander & ACTIVE_EXPANDER_MASK; // eslint-disable-line no-bitwise const exId = this.state.activeExpanders[exIndex]; if (exId >= FIELD_EXPANDER_ID_MIN && exId < FIELD_EXPANDER_ID_MIN + this.state.fieldExpanders.length) { const expander = this.state.fieldExpanders[exId - FIELD_EXPANDER_ID_MIN]; // eslint-disable-line no-bitwise, max-len path.push(expander.getter(index)); row = row.parent; // eslint-disable-line no-param-reassign } else if (exId >= STACK_EXPANDER_ID_MIN && exId < STACK_EXPANDER_ID_MIN + this.state.stackExpanders.length) { const expander = this.state.stackExpanders[exId - STACK_EXPANDER_ID_MIN]; const stackGetter = expander.stackGetter; const frameIdGetter = expander.frameIdGetter; const frameGetter = expander.frameGetter; const stack = []; while (row && (row.expander & ACTIVE_EXPANDER_MASK) === exIndex) { // eslint-disable-line no-bitwise, max-len const depth = row.expander >>> ACTIVE_EXPANDER_FRAME_SHIFT; // eslint-disable-line no-bitwise, max-len const rowStack = stackGetter(index); if (depth >= rowStack.length) { stack.push('<exclusive>'); } else { stack.push(frameGetter(frameIdGetter(rowStack, depth))); } row = row.parent; // eslint-disable-line no-param-reassign } path.push(stack.reverse()); } } return path.reverse(); } getRowAggregate(row: Row, index: number): string { const aggregator = this.state.aggregators[this.state.activeAggregators[index]]; invariant(row.aggregates, 'Expected aggregates'); return aggregator.formatter(row.aggregates[index]); } getHeight(): number { return this.state.root.height; } canExpand(row: Row): boolean { // eslint-disable-line class-methods-use-this return (row.state & NODE_EXPANDED_BIT) === 0 && (row.expander !== INVALID_ACTIVE_EXPANDER); // eslint-disable-line no-bitwise, max-len } canContract(row: Row): boolean { // eslint-disable-line class-methods-use-this return (row.state & NODE_EXPANDED_BIT) !== 0; // eslint-disable-line no-bitwise } expand(row: Row) { invariant( (row.state & NODE_EXPANDED_BIT) === 0, // eslint-disable-line no-bitwise 'can not expand row, already expanded'); invariant(row.height === 1, `unexpanded row has height ${row.height.toString()} != 1`); if (row.children === null) { // first expand, generate children const activeIndex = row.expander & ACTIVE_EXPANDER_MASK; // eslint-disable-line no-bitwise let nextActiveIndex = activeIndex + 1; // NB: if next is stack, frame is 0 if (nextActiveIndex >= this.state.activeExpanders.length) { nextActiveIndex = INVALID_ACTIVE_EXPANDER; } invariant( activeIndex < this.state.activeExpanders.length, `invalid active expander index ${activeIndex.toString()}`); const exId = this.state.activeExpanders[activeIndex]; if (exId >= FIELD_EXPANDER_ID_MIN && exId < FIELD_EXPANDER_ID_MIN + this.state.fieldExpanders.length) { const expander = this.state.fieldExpanders[exId - FIELD_EXPANDER_ID_MIN]; this._addChildrenWithFieldExpander(row, expander, nextActiveIndex); } else if (exId >= STACK_EXPANDER_ID_MIN && exId < STACK_EXPANDER_ID_MIN + this.state.stackExpanders.length) { const depth = row.expander >>> ACTIVE_EXPANDER_FRAME_SHIFT; // eslint-disable-line no-bitwise, max-len const expander = this.state.stackExpanders[exId - STACK_EXPANDER_ID_MIN]; this._addChildrenWithStackExpander(row, expander, activeIndex, depth, nextActiveIndex); } else { throw new Error(`state.activeIndex ${activeIndex} has invalid expander${exId}`); } } row.state |= NODE_EXPANDED_BIT | NODE_REAGGREGATE_BIT | NODE_REORDER_BIT | NODE_REPOSITION_BIT; // eslint-disable-line no-bitwise, no-param-reassign, max-len let heightChange = 0; invariant(row.children, 'Expected a children array'); for (let i = 0; i < row.children.length; i++) { heightChange += row.children[i].height; } this._updateHeight(row, heightChange); // if children only contains one node, then expand it as well invariant(row.children, 'Expected a children array'); if (row.children.length === 1 && this.canExpand(row.children[0])) { this.expand(row.children[0]); } } contract(row: Row) { this._contractRow(row); } }