ghost-telemetry-frontend/src/components/List/List.tsx
Uncle Fatso e63dad2106
initial commit for public repo
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2024-12-23 15:01:54 +03:00

206 lines
5.6 KiB
TypeScript

import * as React from 'react';
import ReactGA from "react-ga4";
import { Types, Maybe } from '../../common';
import { Filter } from '../';
import { State as AppState, Update as AppUpdate, Node } from '../../state';
import { Row, THead } from './';
import { Persistent, PersistentSet } from '../../persist';
import { viewport } from '../../utils';
const HEADER = 148;
const TH_HEIGHT = 35;
const TR_HEIGHT = 31;
const ROW_MARGIN = 5;
import './List.css';
interface ListProps {
appState: Readonly<AppState>;
appUpdate: AppUpdate;
pins: PersistentSet<Types.NodeName>;
sortBy: Persistent<Maybe<number>>;
}
// Helper for readability, used as `key` prop for each `Row`
// of the `List`, so that we can maximize re-using DOM elements.
type Key = number;
export class List extends React.Component<ListProps> {
public state = {
filter: null,
viewportHeight: viewport().height,
};
private listStart = 0;
private listEnd = 0;
private relativeTop = -1;
private nextKey: Key = 0;
private previousKeys = new Map<Types.NodeId, Key>();
public componentDidMount() {
ReactGA.send({ hitType: "pageview", page: "/list" });
this.onScroll();
window.addEventListener('resize', this.onResize);
window.addEventListener('scroll', this.onScroll);
}
public componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
window.removeEventListener('scroll', this.onScroll);
}
public render() {
const { pins, sortBy, appState } = this.props;
const { selectedColumns } = appState;
const { filter } = this.state;
let nodes = appState.nodes.sorted();
if (filter != null) {
nodes = nodes.filter(filter);
if (nodes.length === 0) {
return (
<React.Fragment>
<div className="List List-no-nodes">
¯\_()_/¯
<br />
Nothing matches
</div>
<Filter onChange={this.onFilterChange} />
</React.Fragment>
);
}
// With filter present, we can no longer guarantee that focus corresponds
// to rendering view, so we put the whole list in focus
appState.nodes.setFocus(0, nodes.length);
} else {
appState.nodes.setFocus(this.listStart, this.listEnd);
}
const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
const top = this.listStart * TR_HEIGHT;
nodes = nodes.slice(this.listStart, this.listEnd);
const keys = this.recalculateKeys(nodes);
return (
<div className="List-container">
<div className="List" style={{ height }}>
<table className="List--table">
<THead columns={selectedColumns} sortBy={sortBy} />
<tbody>
<tr className="List-padding" style={{ height: `${top}px` }} />
{nodes.map((node, i) => (
<Row
key={keys[i]}
node={node}
pins={pins}
columns={selectedColumns}
/>
))}
</tbody>
</table>
</div>
<Filter onChange={this.onFilterChange} />
</div>
);
}
// Get an array of keys for each `Node` in viewport in order.
//
// * If a `Node` was previously rendered, it will keep its `Key`.
//
// * If a `Node` is new to the viewport, it will get a `Key` of
// another `Node` that was removed from the viewport, or a new one.
private recalculateKeys(nodes: Array<Node>): Array<Key> {
// First we find all keys for `Node`s which didn't change from
// last render.
const keptKeys: Array<Maybe<Key>> = nodes.map(({ id }) => {
const key = this.previousKeys.get(id);
if (key != null) {
this.previousKeys.delete(id);
}
return key;
});
// Array of all unused keys
const unusedKeys = Array.from(this.previousKeys.values());
let search = 0;
// Clear the map so we can set new values
this.previousKeys.clear();
// Filling in blanks and re-populate previousKeys
return keptKeys.map((key: Maybe<Key>, i) => {
const id = nodes[i].id;
// `Node` was previously in viewport
if (key != null) {
this.previousKeys.set(id, key);
return key;
}
// Recycle the next unused key
if (search < unusedKeys.length) {
const unused = unusedKeys[search++];
this.previousKeys.set(id, unused);
return unused;
}
// No unused keys left, generate a new key
const newKey = this.nextKey++;
this.previousKeys.set(id, newKey);
return newKey;
});
}
private onScroll = () => {
const relativeTop = divisibleBy(
window.scrollY - (HEADER + TR_HEIGHT),
TR_HEIGHT * ROW_MARGIN
);
if (this.relativeTop === relativeTop) {
return;
}
this.relativeTop = relativeTop;
const { viewportHeight } = this.state;
const top = Math.max(relativeTop, 0);
const height =
relativeTop < 0 ? viewportHeight + relativeTop : viewportHeight;
const listStart = Math.max(((top / TR_HEIGHT) | 0) - ROW_MARGIN, 0);
const listEnd = listStart + ROW_MARGIN * 2 + Math.ceil(height / TR_HEIGHT);
if (listStart !== this.listStart || listEnd !== this.listEnd) {
this.listStart = listStart;
this.listEnd = listEnd;
this.props.appUpdate({});
}
};
private onResize = () => {
const viewportHeight = viewport().height;
this.setState({ viewportHeight });
};
private onFilterChange = (filter: Maybe<(node: Node) => boolean>) => {
this.setState({ filter });
};
}
function divisibleBy(n: number, dividor: number): number {
return n - (n % dividor);
}