Split DataSource & DataSourceView

Summary:
This diff is primarily cosmetic, just pushing code around to make the API more intuitive. Most importantly, DataSource was split into DataSource and DataSourceView classes, the latter being accessible through `datasource.view`.

The benefit of this is two fold:
1. Conceptually it is much clearer now which operations operate on the _source_ records, and which ones on the derived _view_.
2. This will make it easier in the future to support multiple views to be based on a single data source.

This refactoring also nicely found 2 cases where datasource logic and view logic were mixed.

The only semantic change in this diff is that both DataSource and DataSourceView are now iterable, so that one can do a `for (const record of ds)` / `for (const record of ds.view)`

Reviewed By: nikoant

Differential Revision: D26976838

fbshipit-source-id: 3726e92b3c6ee3417dc66cbbe6e288797eecf70e
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent d73f6578a7
commit 602152665b
7 changed files with 611 additions and 466 deletions

View File

@@ -577,7 +577,7 @@ test('plugins can serialize dataSources', () => {
}, },
); );
expect(instance.ds.records).toEqual([4, 5]); expect(instance.ds.records()).toEqual([4, 5]);
instance.ds.shift(1); instance.ds.shift(1);
instance.ds.append(6); instance.ds.append(6);
expect(exportState()).toEqual({ expect(exportState()).toEqual({

View File

@@ -95,132 +95,93 @@ export class DataSource<
> implements Persistable { > implements Persistable {
private nextId = 0; private nextId = 0;
private _records: Entry<T>[] = []; private _records: Entry<T>[] = [];
private _recordsById: Map<KEY_TYPE, T> = new Map(); private _recordsById: Map<KEY_TYPE, T> = new Map();
private keyAttribute: undefined | keyof T; private keyAttribute: undefined | keyof T;
private idToIndex: Map<KEY_TYPE, number> = new Map(); private idToIndex: Map<KEY_TYPE, number> = new Map();
// if we shift the window, we increase shiftOffset, rather than remapping all values
// if we shift the window, we increase shiftOffset to correct idToIndex results, rather than remapping all values
private shiftOffset = 0; private shiftOffset = 0;
limit = defaultLimit;
private sortBy: undefined | ((a: T) => Primitive);
private reverse: boolean = false;
private filter?: (value: T) => boolean;
private dataUpdateQueue: DataEvent<T>[] = [];
windowStart = 0;
windowEnd = 0;
private outputChangeListener?: (change: OutputChange) => void;
/** /**
* Exposed for testing. * The maximum amount of records this DataSource can have
* This is the base view data, that is filtered and sorted, but not reversed or windowed
*/ */
output: Entry<T>[] = []; public limit = defaultLimit;
/** /**
* Returns a defensive copy of the stored records. * The default view on this data source. A view applies
* This is a O(n) operation! Prefer using .size and .get instead! * sorting, filtering and windowing to get more constrained output.
*
* Additional views can created through the fork method.
*/ */
get records(): readonly T[] { public readonly view: DataSourceView<T>;
return this._records.map(unwrap);
}
serialize() {
return this.records;
}
deserialize(value: any[]) {
this.clear();
value.forEach((record) => {
this.append(record);
});
}
/**
* returns a direct reference to the stored records as lookup map,
* based on the key attribute set.
* The colletion should be treated as readonly and mutable (it might change over time).
* Create a defensive copy if needed.
*/
get recordsById(): ReadonlyMap<KEY_TYPE, T> {
this.assertKeySet();
return this._recordsById;
}
constructor(keyAttribute: KEY | undefined) { constructor(keyAttribute: KEY | undefined) {
this.keyAttribute = keyAttribute; this.keyAttribute = keyAttribute;
this.setSortBy(undefined); this.view = new DataSourceView<T>(this);
} }
public get size() { public get size() {
return this._records.length; return this._records.length;
} }
public getRecord(index: number): T {
return this._records[index]?.value;
}
public get outputSize() {
return this.output.length;
}
/** /**
* Returns a defensive copy of the current output. * Returns a defensive copy of the stored records.
* Sort, filter, reverse and are applied. * This is a O(n) operation! Prefer using .size and .get instead if only a subset is needed.
* Start and end behave like slice, and default to the currently active window.
*/ */
public getOutput( public records(): readonly T[] {
start = this.windowStart, return this._records.map(unwrap);
end = this.windowEnd,
): readonly T[] {
if (this.reverse) {
return this.output
.slice(this.output.length - end, this.output.length - start)
.reverse()
.map((e) => e.value);
} else {
return this.output.slice(start, end).map((e) => e.value);
}
} }
private assertKeySet() { public get(index: number) {
if (!this.keyAttribute) { return unwrap(this._records[index]);
throw new Error(
'No key has been set. Records cannot be looked up by key',
);
}
} }
private getKey(value: T): KEY_TYPE; public getById(key: KEY_TYPE) {
private getKey(value: any): any {
this.assertKeySet(); this.assertKeySet();
const key = value[this.keyAttribute!]; return this._recordsById.get(key);
if ((typeof key === 'string' || typeof key === 'number') && key !== '') {
return key;
} }
throw new Error(`Invalid key value: '${key}'`);
public keys(): IterableIterator<KEY_TYPE> {
this.assertKeySet();
return this._recordsById.keys();
}
public entries(): IterableIterator<[KEY_TYPE, T]> {
this.assertKeySet();
return this._recordsById.entries();
}
public [Symbol.iterator](): IterableIterator<T> {
const self = this;
let offset = 0;
return {
next() {
offset++;
if (offset > self.size) {
return {done: true, value: undefined};
} else {
return {
value: self._records[offset - 1].value,
};
}
},
[Symbol.iterator]() {
return this;
},
};
} }
/** /**
* Returns the index of a specific key in the *source* set * Returns the index of a specific key in the *records* set.
* Returns -1 if the record wansn't found
*/ */
indexOfKey(key: KEY_TYPE): number { public getIndexOfKey(key: KEY_TYPE): number {
this.assertKeySet(); this.assertKeySet();
const stored = this.idToIndex.get(key); const stored = this.idToIndex.get(key);
return stored === undefined ? -1 : stored + this.shiftOffset; return stored === undefined ? -1 : stored + this.shiftOffset;
} }
private storeIndexOfKey(key: KEY_TYPE, index: number) { public append(value: T) {
// de-normalize the index, so that on later look ups its corrected again
this.idToIndex.set(key, index - this.shiftOffset);
}
append(value: T) {
if (this._records.length >= this.limit) { if (this._records.length >= this.limit) {
// we're full! let's free up some space // we're full! let's free up some space
this.shift(Math.ceil(this.limit * dropFactor)); this.shift(Math.ceil(this.limit * dropFactor));
@@ -236,7 +197,8 @@ export class DataSource<
const entry = { const entry = {
value, value,
id: ++this.nextId, id: ++this.nextId,
visible: this.filter ? this.filter(value) : true, // once we have multiple views, the following fields should be stored per view
visible: true,
approxIndex: -1, approxIndex: -1,
}; };
this._records.push(entry); this._records.push(entry);
@@ -250,11 +212,11 @@ export class DataSource<
* Updates or adds a record. Returns `true` if the record already existed. * Updates or adds a record. Returns `true` if the record already existed.
* Can only be used if a key is used. * Can only be used if a key is used.
*/ */
upsert(value: T): boolean { public upsert(value: T): boolean {
this.assertKeySet(); this.assertKeySet();
const key = this.getKey(value); const key = this.getKey(value);
if (this.idToIndex.has(key)) { if (this.idToIndex.has(key)) {
this.update(this.indexOfKey(key), value); this.update(this.getIndexOfKey(key), value);
return true; return true;
} else { } else {
this.append(value); this.append(value);
@@ -266,7 +228,7 @@ export class DataSource<
* Replaces an item in the base data collection. * Replaces an item in the base data collection.
* Note that the index is based on the insertion order, and not based on the current view * Note that the index is based on the insertion order, and not based on the current view
*/ */
update(index: number, value: T) { public update(index: number, value: T) {
const entry = this._records[index]; const entry = this._records[index];
const oldValue = entry.value; const oldValue = entry.value;
if (value === oldValue) { if (value === oldValue) {
@@ -274,11 +236,16 @@ export class DataSource<
} }
const oldVisible = entry.visible; const oldVisible = entry.visible;
entry.value = value; entry.value = value;
entry.visible = this.filter ? this.filter(value) : true;
if (this.keyAttribute) { if (this.keyAttribute) {
const key = this.getKey(value); const key = this.getKey(value);
const currentKey = this.getKey(oldValue); const currentKey = this.getKey(oldValue);
if (currentKey !== key) { if (currentKey !== key) {
const existingIndex = this.getIndexOfKey(key);
if (existingIndex !== -1 && existingIndex !== index) {
throw new Error(
`Trying to insert duplicate key '${key}', which already exist in the collection`,
);
}
this._recordsById.delete(currentKey); this._recordsById.delete(currentKey);
this.idToIndex.delete(currentKey); this.idToIndex.delete(currentKey);
} }
@@ -299,7 +266,7 @@ export class DataSource<
* *
* Warning: this operation can be O(n) if a key is set * Warning: this operation can be O(n) if a key is set
*/ */
remove(index: number) { public delete(index: number) {
if (index < 0 || index >= this._records.length) { if (index < 0 || index >= this._records.length) {
throw new Error('Out of bounds: ' + index); throw new Error('Out of bounds: ' + index);
} }
@@ -332,13 +299,13 @@ export class DataSource<
* *
* Warning: this operation can be O(n) if a key is set * Warning: this operation can be O(n) if a key is set
*/ */
removeByKey(keyValue: KEY_TYPE): boolean { public deleteByKey(keyValue: KEY_TYPE): boolean {
this.assertKeySet(); this.assertKeySet();
const index = this.indexOfKey(keyValue); const index = this.getIndexOfKey(keyValue);
if (index === -1) { if (index === -1) {
return false; return false;
} }
this.remove(index); this.delete(index);
return true; return true;
} }
@@ -346,7 +313,7 @@ export class DataSource<
* Removes the first N entries. * Removes the first N entries.
* @param amount * @param amount
*/ */
shift(amount: number) { public shift(amount: number) {
amount = Math.min(amount, this._records.length); amount = Math.min(amount, this._records.length);
if (amount === this._records.length) { if (amount === this._records.length) {
this.clear(); this.clear();
@@ -365,7 +332,7 @@ export class DataSource<
} }
if ( if (
this.sortBy && this.view.isSorted &&
removed.length > 10 && removed.length > 10 &&
removed.length > shiftRebuildTreshold * this._records.length removed.length > shiftRebuildTreshold * this._records.length
) { ) {
@@ -373,7 +340,7 @@ export class DataSource<
// let's fallback to the async processing of all data instead // let's fallback to the async processing of all data instead
// MWE: there is a risk here that rebuilding is too blocking, as this might happen // MWE: there is a risk here that rebuilding is too blocking, as this might happen
// in background when new data arrives, and not explicitly on a user interaction // in background when new data arrives, and not explicitly on a user interaction
this.rebuildOutput(); this.view.rebuild();
} else { } else {
this.emitDataEvent({ this.emitDataEvent({
type: 'shift', type: 'shift',
@@ -383,13 +350,182 @@ export class DataSource<
} }
} }
setWindow(start: number, end: number) { /**
* The clear operation removes any records stored, but will keep the current view preferences such as sorting and filtering
*/
public clear() {
this._records = [];
this._recordsById = new Map();
this.shiftOffset = 0;
this.idToIndex = new Map();
this.view.rebuild();
}
/**
* Returns a fork of this dataSource, that shares the source data with this dataSource,
* but has it's own FSRW pipeline, to allow multiple views on the same data
*/
public fork(): DataSourceView<T> {
throw new Error(
'Not implemented. Please contact oncall if this feature is needed',
);
}
private assertKeySet() {
if (!this.keyAttribute) {
throw new Error(
'No key has been set. Records cannot be looked up by key',
);
}
}
private getKey(value: T): KEY_TYPE;
private getKey(value: any): any {
this.assertKeySet();
const key = value[this.keyAttribute!];
if ((typeof key === 'string' || typeof key === 'number') && key !== '') {
return key;
}
throw new Error(`Invalid key value: '${key}'`);
}
private storeIndexOfKey(key: KEY_TYPE, index: number) {
// de-normalize the index, so that on later look ups its corrected again
this.idToIndex.set(key, index - this.shiftOffset);
}
private emitDataEvent(event: DataEvent<T>) {
// Optimization: potentially we could schedule this to happen async,
// using a queue,
// or only if there is an active view (although that could leak memory)
this.view.processEvent(event);
}
/**
* @private
*/
serialize(): readonly T[] {
return this.records();
}
/**
* @private
*/
deserialize(value: any[]) {
this.clear();
value.forEach((record) => {
this.append(record);
});
}
}
type CreateDataSourceOptions<T, K extends keyof T> = {
/**
* If a key is set, the given field of the records is assumed to be unique,
* and it's value can be used to perform lookups and upserts.
*/
key?: K;
/**
* The maximum amount of records that this DataSource will store.
* If the limit is exceeded, the oldest records will automatically be dropped to make place for the new ones
*/
limit?: number;
/**
* Should this state persist when exporting a plugin?
* If set, the dataSource will be saved / loaded under the key provided
*/
persist?: string;
};
export function createDataSource<T, KEY extends keyof T = any>(
initialSet: T[],
options: CreateDataSourceOptions<T, KEY>,
): DataSource<T, KEY, ExtractKeyType<T, KEY>>;
export function createDataSource<T>(
initialSet?: T[],
): DataSource<T, never, never>;
export function createDataSource<T, KEY extends keyof T>(
initialSet: T[] = [],
options?: CreateDataSourceOptions<T, KEY>,
): DataSource<T, any, any> {
const ds = new DataSource<T, KEY>(options?.key);
if (options?.limit !== undefined) {
ds.limit = options.limit;
}
registerStorageAtom(options?.persist, ds);
initialSet.forEach((value) => ds.append(value));
return ds;
}
function unwrap<T>(entry: Entry<T>): T {
return entry?.value;
}
class DataSourceView<T> {
public readonly datasource: DataSource<T>;
private sortBy: undefined | ((a: T) => Primitive) = undefined;
private reverse: boolean = false;
private filter?: (value: T) => boolean = undefined;
/**
* @readonly
*/
public windowStart = 0;
/**
* @readonly
*/
public windowEnd = 0;
private outputChangeListener?: (change: OutputChange) => void;
/**
* This is the base view data, that is filtered and sorted, but not reversed or windowed
*/
private _output: Entry<T>[] = [];
constructor(datasource: DataSource<T, any, any>) {
this.datasource = datasource;
}
public get size() {
return this._output.length;
}
public get isSorted() {
return !!this.sortBy;
}
public get isFiltered() {
return !!this.filter;
}
public get isReversed() {
return this.reverse;
}
/**
* Returns a defensive copy of the current output.
* Sort, filter, reverse and are applied.
* Start and end behave like slice, and default to the currently active window.
*/
public output(start = this.windowStart, end = this.windowEnd): readonly T[] {
if (this.reverse) {
return this._output
.slice(this._output.length - end, this._output.length - start)
.reverse()
.map((e) => e.value);
} else {
return this._output.slice(start, end).map((e) => e.value);
}
}
public setWindow(start: number, end: number) {
this.windowStart = start; this.windowStart = start;
this.windowEnd = end; this.windowEnd = end;
} }
setOutputChangeListener( public setListener(
listener: typeof DataSource['prototype']['outputChangeListener'], listener: typeof DataSourceView['prototype']['outputChangeListener'],
) { ) {
if (this.outputChangeListener && listener) { if (this.outputChangeListener && listener) {
console.warn('outputChangeListener already set'); console.warn('outputChangeListener already set');
@@ -397,7 +533,7 @@ export class DataSource<
this.outputChangeListener = listener; this.outputChangeListener = listener;
} }
setSortBy(sortBy: undefined | keyof T | ((a: T) => Primitive)) { public setSortBy(sortBy: undefined | keyof T | ((a: T) => Primitive)) {
if (this.sortBy === sortBy) { if (this.sortBy === sortBy) {
return; return;
} }
@@ -411,42 +547,27 @@ export class DataSource<
}); });
} }
this.sortBy = sortBy as any; this.sortBy = sortBy as any;
this.rebuildOutput(); this.rebuild();
} }
setFilter(filter: undefined | ((value: T) => boolean)) { public setFilter(filter: undefined | ((value: T) => boolean)) {
if (this.filter !== filter) { if (this.filter !== filter) {
this.filter = filter; this.filter = filter;
this.rebuildOutput(); this.rebuild();
} }
} }
toggleReversed() { public toggleReversed() {
this.setReversed(!this.reverse); this.setReversed(!this.reverse);
} }
setReversed(reverse: boolean) { public setReversed(reverse: boolean) {
if (this.reverse !== reverse) { if (this.reverse !== reverse) {
this.reverse = reverse; this.reverse = reverse;
this.notifyReset(this.output.length); this.notifyReset(this._output.length);
} }
} }
/**
* The clear operation removes any records stored, but will keep the current view preferences such as sorting and filtering
*/
clear() {
this.windowStart = 0;
this.windowEnd = 0;
this._records = [];
this._recordsById = new Map();
this.shiftOffset = 0;
this.idToIndex = new Map();
this.dataUpdateQueue = [];
this.output = [];
this.notifyReset(0);
}
/** /**
* The reset operation resets any view preferences such as sorting and filtering, but keeps the current set of records. * The reset operation resets any view preferences such as sorting and filtering, but keeps the current set of records.
*/ */
@@ -454,35 +575,37 @@ export class DataSource<
this.sortBy = undefined; this.sortBy = undefined;
this.reverse = false; this.reverse = false;
this.filter = undefined; this.filter = undefined;
this.rebuildOutput(); this.windowStart = 0;
} this.windowEnd = 0;
this.rebuild();
/**
* Returns a fork of this dataSource, that shares the source data with this dataSource,
* but has it's own FSRW pipeline, to allow multiple views on the same data
*/
fork(): DataSource<T> {
throw new Error(
'Not implemented. Please contact oncall if this feature is needed',
);
}
private emitDataEvent(event: DataEvent<T>) {
this.dataUpdateQueue.push(event);
// TODO: schedule
this.processEvents();
} }
private normalizeIndex(viewIndex: number): number { private normalizeIndex(viewIndex: number): number {
return this.reverse ? this.output.length - 1 - viewIndex : viewIndex; return this.reverse ? this._output.length - 1 - viewIndex : viewIndex;
} }
getItem(viewIndex: number): T { public get(viewIndex: number): T {
return this.getEntry(viewIndex)?.value; return this._output[this.normalizeIndex(viewIndex)]?.value;
} }
getEntry(viewIndex: number): Entry<T> { public [Symbol.iterator](): IterableIterator<T> {
return this.output[this.normalizeIndex(viewIndex)]; const self = this;
let offset = this.windowStart;
return {
next() {
offset++;
if (offset > self.windowEnd || offset > self.size) {
return {done: true, value: undefined};
} else {
return {
value: self.get(offset - 1),
};
}
},
[Symbol.iterator]() {
return this;
},
};
} }
private notifyItemUpdated(viewIndex: number) { private notifyItemUpdated(viewIndex: number) {
@@ -508,12 +631,12 @@ export class DataSource<
if (this.reverse && delta < 0) { if (this.reverse && delta < 0) {
viewIndex -= delta; // we need to correct for normalize already using the new length after applying this change viewIndex -= delta; // we need to correct for normalize already using the new length after applying this change
} }
// TODO: for 'before' shifts, should the window be adjusted automatically? // Idea: we could add an option to automatically shift the window for before events.
this.outputChangeListener({ this.outputChangeListener({
type: 'shift', type: 'shift',
delta, delta,
index: viewIndex, index: viewIndex,
newCount: this.output.length, newCount: this._output.length,
location: location:
viewIndex < this.windowStart viewIndex < this.windowStart
? 'before' ? 'before'
@@ -530,16 +653,15 @@ export class DataSource<
}); });
} }
private processEvents() { /**
const events = this.dataUpdateQueue.splice(0); * @private
events.forEach(this.processEvent); */
} processEvent(event: DataEvent<T>) {
const {_output: output, sortBy, filter} = this;
private processEvent = (event: DataEvent<T>) => {
const {output, sortBy, filter} = this;
switch (event.type) { switch (event.type) {
case 'append': { case 'append': {
const {entry} = event; const {entry} = event;
entry.visible = filter ? filter(entry.value) : true;
if (!entry.visible) { if (!entry.visible) {
// not in filter? skip this entry // not in filter? skip this entry
return; return;
@@ -556,6 +678,7 @@ export class DataSource<
} }
case 'update': { case 'update': {
const {entry} = event; const {entry} = event;
entry.visible = filter ? filter(entry.value) : true;
// short circuit; no view active so update straight away // short circuit; no view active so update straight away
if (!filter && !sortBy) { if (!filter && !sortBy) {
output[event.index].approxIndex = event.index; output[event.index].approxIndex = event.index;
@@ -624,10 +747,10 @@ export class DataSource<
default: default:
throw new Error('unknown event type'); throw new Error('unknown event type');
} }
}; }
private processRemoveEvent(index: number, entry: Entry<T>) { private processRemoveEvent(index: number, entry: Entry<T>) {
const {output, sortBy, filter} = this; const {_output: output, sortBy, filter} = this;
// filter active, and not visible? short circuilt // filter active, and not visible? short circuilt
if (!entry.visible) { if (!entry.visible) {
@@ -645,7 +768,11 @@ export class DataSource<
} }
} }
private rebuildOutput() { /**
* Rebuilds the entire view. Typically there should be no need to call this manually
* @private
*/
rebuild() {
// Pending on the size, should we batch this in smaller non-blocking steps, // Pending on the size, should we batch this in smaller non-blocking steps,
// which we update in a double-buffering mechanism, report progress, and swap out when done? // which we update in a double-buffering mechanism, report progress, and swap out when done?
// //
@@ -654,12 +781,14 @@ export class DataSource<
// See also comment below // See also comment below
const {sortBy, filter, sortHelper} = this; const {sortBy, filter, sortHelper} = this;
// copy base array or run filter (with side effecty update of visible) // copy base array or run filter (with side effecty update of visible)
// @ts-ignore prevent making _record public
const records: Entry<T>[] = this.datasource._records;
let output = filter let output = filter
? this._records.filter((entry) => { ? records.filter((entry) => {
entry.visible = filter(entry.value); entry.visible = filter(entry.value);
return entry.visible; return entry.visible;
}) })
: this._records.slice(); : records.slice();
if (sortBy) { if (sortBy) {
// Pending on the size, should we batch this in smaller steps? // Pending on the size, should we batch this in smaller steps?
// The following sorthing method can be taskified, however, // The following sorthing method can be taskified, however,
@@ -674,7 +803,7 @@ export class DataSource<
output = lodashSort(output, sortHelper); // uses array.sort under the hood output = lodashSort(output, sortHelper); // uses array.sort under the hood
} }
this.output = output; this._output = output;
this.notifyReset(output.length); this.notifyReset(output.length);
} }
@@ -682,7 +811,7 @@ export class DataSource<
this.sortBy ? this.sortBy(a.value) : a.id; this.sortBy ? this.sortBy(a.value) : a.id;
private getSortedIndex(entry: Entry<T>, oldValue: T) { private getSortedIndex(entry: Entry<T>, oldValue: T) {
const {output} = this; const {_output: output} = this;
if (output[entry.approxIndex] === entry) { if (output[entry.approxIndex] === entry) {
// yay! // yay!
return entry.approxIndex; return entry.approxIndex;
@@ -712,54 +841,12 @@ export class DataSource<
private insertSorted(entry: Entry<T>) { private insertSorted(entry: Entry<T>) {
// apply sorting // apply sorting
const insertionIndex = sortedLastIndexBy( const insertionIndex = sortedLastIndexBy(
this.output, this._output,
entry, entry,
this.sortHelper, this.sortHelper,
); );
entry.approxIndex = insertionIndex; entry.approxIndex = insertionIndex;
this.output.splice(insertionIndex, 0, entry); this._output.splice(insertionIndex, 0, entry);
this.notifyItemShift(insertionIndex, 1); this.notifyItemShift(insertionIndex, 1);
} }
} }
type CreateDataSourceOptions<T, K extends keyof T> = {
/**
* If a key is set, the given field of the records is assumed to be unique,
* and it's value can be used to perform lookups and upserts.
*/
key?: K;
/**
* The maximum amount of records that this DataSource will store.
* If the limit is exceeded, the oldest records will automatically be dropped to make place for the new ones
*/
limit?: number;
/**
* Should this state persist when exporting a plugin?
* If set, the dataSource will be saved / loaded under the key provided
*/
persist?: string;
};
export function createDataSource<T, KEY extends keyof T = any>(
initialSet: T[],
options: CreateDataSourceOptions<T, KEY>,
): DataSource<T, KEY, ExtractKeyType<T, KEY>>;
export function createDataSource<T>(
initialSet?: T[],
): DataSource<T, never, never>;
export function createDataSource<T, KEY extends keyof T>(
initialSet: T[] = [],
options?: CreateDataSourceOptions<T, KEY>,
): DataSource<T, any, any> {
const ds = new DataSource<T, KEY>(options?.key);
if (options?.limit !== undefined) {
ds.limit = options.limit;
}
registerStorageAtom(options?.persist, ds);
initialSet.forEach((value) => ds.append(value));
return ds;
}
function unwrap<T>(entry: Entry<T>): T {
return entry.value;
}

View File

@@ -34,43 +34,52 @@ function unwrap<T>(array: readonly {value: T}[]): readonly T[] {
return array.map((entry) => entry.value); return array.map((entry) => entry.value);
} }
function rawOutput<T>(ds: DataSource<T>): readonly T[] {
// @ts-ignore
const output = ds.view._output;
return unwrap(output);
}
test('can create a datasource', () => { test('can create a datasource', () => {
const ds = createDataSource<Todo>([eatCookie]); const ds = createDataSource<Todo>([eatCookie]);
expect(ds.records).toEqual([eatCookie]); expect(ds.records()).toEqual([eatCookie]);
ds.append(drinkCoffee); ds.append(drinkCoffee);
expect(ds.records).toEqual([eatCookie, drinkCoffee]); expect(ds.records()).toEqual([eatCookie, drinkCoffee]);
expect(() => ds.recordsById).toThrow(/Records cannot be looked up by key/); // @ts-ignore
expect(() => ds.getById('stuff')).toThrow(
/Records cannot be looked up by key/,
);
ds.update(1, submitBug); ds.update(1, submitBug);
expect(ds.records[1]).toBe(submitBug); expect(ds.records()[1]).toBe(submitBug);
ds.remove(0); ds.delete(0);
expect(ds.records[0]).toBe(submitBug); expect(ds.records()[0]).toBe(submitBug);
}); });
test('can create a keyed datasource', () => { test('can create a keyed datasource', () => {
const ds = createDataSource<Todo>([eatCookie], {key: 'id'}); const ds = createDataSource<Todo>([eatCookie], {key: 'id'});
expect(ds.records).toEqual([eatCookie]); expect(ds.records()).toEqual([eatCookie]);
ds.append(drinkCoffee); ds.append(drinkCoffee);
expect(ds.records).toEqual([eatCookie, drinkCoffee]); expect(ds.records()).toEqual([eatCookie, drinkCoffee]);
expect(ds.recordsById.get('bug')).toBe(undefined); expect(ds.getById('bug')).toBe(undefined);
expect(ds.recordsById.get('cookie')).toBe(eatCookie); expect(ds.getById('cookie')).toBe(eatCookie);
expect(ds.recordsById.get('coffee')).toBe(drinkCoffee); expect(ds.getById('coffee')).toBe(drinkCoffee);
expect(ds.indexOfKey('bug')).toBe(-1); expect(ds.getIndexOfKey('bug')).toBe(-1);
expect(ds.indexOfKey('cookie')).toBe(0); expect(ds.getIndexOfKey('cookie')).toBe(0);
expect(ds.indexOfKey('coffee')).toBe(1); expect(ds.getIndexOfKey('coffee')).toBe(1);
ds.update(1, submitBug); ds.update(1, submitBug);
expect(ds.records[1]).toBe(submitBug); expect(ds.records()[1]).toBe(submitBug);
expect(ds.recordsById.get('coffee')).toBe(undefined); expect(ds.getById('coffee')).toBe(undefined);
expect(ds.recordsById.get('bug')).toBe(submitBug); expect(ds.getById('bug')).toBe(submitBug);
expect(ds.indexOfKey('bug')).toBe(1); expect(ds.getIndexOfKey('bug')).toBe(1);
expect(ds.indexOfKey('cookie')).toBe(0); expect(ds.getIndexOfKey('cookie')).toBe(0);
expect(ds.indexOfKey('coffee')).toBe(-1); expect(ds.getIndexOfKey('coffee')).toBe(-1);
// upsert existing // upsert existing
const newBug = { const newBug = {
@@ -79,8 +88,8 @@ test('can create a keyed datasource', () => {
done: true, done: true,
}; };
ds.upsert(newBug); ds.upsert(newBug);
expect(ds.records[1]).toBe(newBug); expect(ds.records()[1]).toBe(newBug);
expect(ds.recordsById.get('bug')).toBe(newBug); expect(ds.getById('bug')).toBe(newBug);
// upsert new // upsert new
const trash = { const trash = {
@@ -88,16 +97,16 @@ test('can create a keyed datasource', () => {
title: 'take trash out', title: 'take trash out',
}; };
ds.upsert(trash); ds.upsert(trash);
expect(ds.records[2]).toBe(trash); expect(ds.records()[2]).toBe(trash);
expect(ds.recordsById.get('trash')).toBe(trash); expect(ds.getById('trash')).toBe(trash);
// delete by key // delete by key
expect(ds.records).toEqual([eatCookie, newBug, trash]); expect(ds.records()).toEqual([eatCookie, newBug, trash]);
expect(ds.removeByKey('bug')).toBe(true); expect(ds.deleteByKey('bug')).toBe(true);
expect(ds.records).toEqual([eatCookie, trash]); expect(ds.records()).toEqual([eatCookie, trash]);
expect(ds.indexOfKey('bug')).toBe(-1); expect(ds.getIndexOfKey('bug')).toBe(-1);
expect(ds.indexOfKey('cookie')).toBe(0); expect(ds.getIndexOfKey('cookie')).toBe(0);
expect(ds.indexOfKey('trash')).toBe(1); expect(ds.getIndexOfKey('trash')).toBe(1);
}); });
test('throws on invalid keys', () => { test('throws on invalid keys', () => {
@@ -110,32 +119,41 @@ test('throws on invalid keys', () => {
}).toThrow(`Duplicate key: 'cookie'`); }).toThrow(`Duplicate key: 'cookie'`);
}); });
test('throws on update causing duplicate key', () => {
const ds = createDataSource<Todo>([eatCookie, submitBug], {key: 'id'});
expect(() => {
ds.update(0, {id: 'bug', title: 'oops'});
}).toThrow(
`Trying to insert duplicate key 'bug', which already exist in the collection`,
);
});
test('removing invalid keys', () => { test('removing invalid keys', () => {
const ds = createDataSource<Todo>([eatCookie], {key: 'id'}); const ds = createDataSource<Todo>([eatCookie], {key: 'id'});
expect(ds.removeByKey('trash')).toBe(false); expect(ds.deleteByKey('trash')).toBe(false);
expect(() => { expect(() => {
ds.remove(1); ds.delete(1);
}).toThrowError('Out of bounds'); }).toThrowError('Out of bounds');
}); });
test('sorting works', () => { test('sorting works', () => {
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]); const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
ds.setSortBy((todo) => todo.title); ds.view.setSortBy((todo) => todo.title);
expect(unwrap(ds.output)).toEqual([drinkCoffee, eatCookie]); expect(rawOutput(ds)).toEqual([drinkCoffee, eatCookie]);
ds.setSortBy(undefined); ds.view.setSortBy(undefined);
ds.setSortBy(undefined); ds.view.setSortBy(undefined);
expect(unwrap(ds.output)).toEqual([eatCookie, drinkCoffee]); expect(rawOutput(ds)).toEqual([eatCookie, drinkCoffee]);
ds.setSortBy((todo) => todo.title); ds.view.setSortBy((todo) => todo.title);
expect(unwrap(ds.output)).toEqual([drinkCoffee, eatCookie]); expect(rawOutput(ds)).toEqual([drinkCoffee, eatCookie]);
const aleph = { const aleph = {
id: 'd', id: 'd',
title: 'aleph', title: 'aleph',
}; };
ds.append(aleph); ds.append(aleph);
expect(ds.records).toEqual([eatCookie, drinkCoffee, aleph]); expect(ds.records()).toEqual([eatCookie, drinkCoffee, aleph]);
expect(unwrap(ds.output)).toEqual([aleph, drinkCoffee, eatCookie]); expect(rawOutput(ds)).toEqual([aleph, drinkCoffee, eatCookie]);
}); });
test('sorting preserves insertion order with equal keys', () => { test('sorting preserves insertion order with equal keys', () => {
@@ -151,15 +169,15 @@ test('sorting preserves insertion order with equal keys', () => {
const c = {$: 'c', name: 'c'}; const c = {$: 'c', name: 'c'};
const ds = createDataSource<N>([]); const ds = createDataSource<N>([]);
ds.setSortBy('$'); ds.view.setSortBy('$');
ds.append(b1); ds.append(b1);
ds.append(c); ds.append(c);
ds.append(b2); ds.append(b2);
ds.append(a); ds.append(a);
ds.append(b3); ds.append(b3);
expect(ds.records).toEqual([b1, c, b2, a, b3]); expect(ds.records()).toEqual([b1, c, b2, a, b3]);
expect(unwrap(ds.output)).toEqual([a, b1, b2, b3, c]); expect(rawOutput(ds)).toEqual([a, b1, b2, b3, c]);
// if we append a new item with existig item, it should end up in the end // if we append a new item with existig item, it should end up in the end
const b4 = { const b4 = {
@@ -167,8 +185,8 @@ test('sorting preserves insertion order with equal keys', () => {
name: 'b4', name: 'b4',
}; };
ds.append(b4); ds.append(b4);
expect(ds.records).toEqual([b1, c, b2, a, b3, b4]); expect(ds.records()).toEqual([b1, c, b2, a, b3, b4]);
expect(unwrap(ds.output)).toEqual([a, b1, b2, b3, b4, c]); expect(rawOutput(ds)).toEqual([a, b1, b2, b3, b4, c]);
// if we replace the middle item, it should end up in the middle // if we replace the middle item, it should end up in the middle
const b2r = { const b2r = {
@@ -176,8 +194,8 @@ test('sorting preserves insertion order with equal keys', () => {
name: 'b2replacement', name: 'b2replacement',
}; };
ds.update(2, b2r); ds.update(2, b2r);
expect(ds.records).toEqual([b1, c, b2r, a, b3, b4]); expect(ds.records()).toEqual([b1, c, b2r, a, b3, b4]);
expect(unwrap(ds.output)).toEqual([a, b1, b2r, b3, b4, c]); expect(rawOutput(ds)).toEqual([a, b1, b2r, b3, b4, c]);
// if we replace something with a different sort value, it should be sorted properly, and the old should disappear // if we replace something with a different sort value, it should be sorted properly, and the old should disappear
const b3r = { const b3r = {
@@ -185,29 +203,29 @@ test('sorting preserves insertion order with equal keys', () => {
name: 'b3replacement', name: 'b3replacement',
}; };
ds.update(4, b3r); ds.update(4, b3r);
expect(ds.records).toEqual([b1, c, b2r, a, b3r, b4]); expect(ds.records()).toEqual([b1, c, b2r, a, b3r, b4]);
expect(unwrap(ds.output)).toEqual([a, b3r, b1, b2r, b4, c]); expect(rawOutput(ds)).toEqual([a, b3r, b1, b2r, b4, c]);
ds.remove(3); ds.delete(3);
expect(ds.records).toEqual([b1, c, b2r, b3r, b4]); expect(ds.records()).toEqual([b1, c, b2r, b3r, b4]);
expect(unwrap(ds.output)).toEqual([b3r, b1, b2r, b4, c]); expect(rawOutput(ds)).toEqual([b3r, b1, b2r, b4, c]);
}); });
test('filter + sort', () => { test('filter + sort', () => {
const ds = createDataSource<Todo>([eatCookie, drinkCoffee, submitBug]); const ds = createDataSource<Todo>([eatCookie, drinkCoffee, submitBug]);
ds.setFilter((t) => t.title.indexOf('c') === -1); ds.view.setFilter((t) => t.title.indexOf('c') === -1);
ds.setSortBy('title'); ds.view.setSortBy('title');
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
// append with and without filter // append with and without filter
const a = {id: 'a', title: 'does have that letter: c'}; const a = {id: 'a', title: 'does have that letter: c'};
const b = {id: 'b', title: 'doesnt have that letter'}; const b = {id: 'b', title: 'doesnt have that letter'};
ds.append(a); ds.append(a);
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
ds.append(b); ds.append(b);
expect(unwrap(ds.output)).toEqual([b, submitBug]); expect(rawOutput(ds)).toEqual([b, submitBug]);
// filter in // filter in
const newCookie = { const newCookie = {
@@ -215,7 +233,7 @@ test('filter + sort', () => {
title: 'eat a ookie', title: 'eat a ookie',
}; };
ds.update(0, newCookie); ds.update(0, newCookie);
expect(unwrap(ds.output)).toEqual([b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([b, newCookie, submitBug]);
// update -> filter in // update -> filter in
const newCoffee = { const newCoffee = {
@@ -223,35 +241,25 @@ test('filter + sort', () => {
title: 'better drink tea', title: 'better drink tea',
}; };
ds.append(newCoffee); ds.append(newCoffee);
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]);
// update -> filter out // update -> filter out
ds.update(2, {id: 'bug', title: 'bug has c!'}); ds.update(2, {id: 'bug', title: 'bug has c!'});
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie]);
ds.update(2, submitBug); ds.update(2, submitBug);
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]);
ds.remove(3); // a ds.delete(3); // a
ds.remove(3); // b ds.delete(3); // b
expect(unwrap(ds.output)).toEqual([newCoffee, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, newCookie, submitBug]);
ds.setFilter(undefined); ds.view.setFilter(undefined);
expect(unwrap(ds.output)).toEqual([ expect(rawOutput(ds)).toEqual([newCoffee, drinkCoffee, newCookie, submitBug]);
newCoffee,
drinkCoffee,
newCookie,
submitBug,
]);
ds.setSortBy(undefined); ds.view.setSortBy(undefined);
// key insertion order // key insertion order
expect(unwrap(ds.output)).toEqual([ expect(rawOutput(ds)).toEqual([newCookie, drinkCoffee, submitBug, newCoffee]);
newCookie,
drinkCoffee,
submitBug,
newCoffee,
]);
}); });
test('filter + sort + index', () => { test('filter + sort + index', () => {
@@ -259,18 +267,18 @@ test('filter + sort + index', () => {
key: 'id', key: 'id',
}); });
ds.setFilter((t) => t.title.indexOf('c') === -1); ds.view.setFilter((t) => t.title.indexOf('c') === -1);
ds.setSortBy('title'); ds.view.setSortBy('title');
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
// append with and without filter // append with and without filter
const a = {id: 'a', title: 'does have that letter: c'}; const a = {id: 'a', title: 'does have that letter: c'};
const b = {id: 'b', title: 'doesnt have that letter'}; const b = {id: 'b', title: 'doesnt have that letter'};
ds.append(a); ds.append(a);
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
ds.append(b); ds.append(b);
expect(unwrap(ds.output)).toEqual([b, submitBug]); expect(rawOutput(ds)).toEqual([b, submitBug]);
// filter in // filter in
const newCookie = { const newCookie = {
@@ -278,7 +286,7 @@ test('filter + sort + index', () => {
title: 'eat a ookie', title: 'eat a ookie',
}; };
ds.update(0, newCookie); ds.update(0, newCookie);
expect(unwrap(ds.output)).toEqual([b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([b, newCookie, submitBug]);
// update -> filter in // update -> filter in
const newCoffee = { const newCoffee = {
@@ -286,24 +294,24 @@ test('filter + sort + index', () => {
title: 'better drink tea', title: 'better drink tea',
}; };
ds.upsert(newCoffee); ds.upsert(newCoffee);
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]);
// update -> filter out // update -> filter out
ds.update(2, {id: 'bug', title: 'bug has c!'}); ds.update(2, {id: 'bug', title: 'bug has c!'});
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie]);
ds.update(2, submitBug); ds.update(2, submitBug);
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]);
ds.setFilter(undefined); ds.view.setFilter(undefined);
expect(unwrap(ds.output)).toEqual([newCoffee, a, b, newCookie, submitBug]); expect(rawOutput(ds)).toEqual([newCoffee, a, b, newCookie, submitBug]);
ds.setSortBy(undefined); ds.view.setSortBy(undefined);
// key insertion order // key insertion order
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]); expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, a, b]);
// verify getOutput // verify getOutput
expect(unwrap(ds.output.slice(1, 3))).toEqual([newCoffee, submitBug]); expect(rawOutput(ds).slice(1, 3)).toEqual([newCoffee, submitBug]);
expect(ds.getOutput(1, 3)).toEqual([newCoffee, submitBug]); expect(ds.view.output(1, 3)).toEqual([newCoffee, submitBug]);
}); });
test('filter', () => { test('filter', () => {
@@ -311,16 +319,16 @@ test('filter', () => {
key: 'id', key: 'id',
}); });
ds.setFilter((t) => t.title.indexOf('c') === -1); ds.view.setFilter((t) => t.title.indexOf('c') === -1);
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
// append with and without filter // append with and without filter
const a = {id: 'a', title: 'does have that letter: c'}; const a = {id: 'a', title: 'does have that letter: c'};
const b = {id: 'b', title: 'doesnt have that letter'}; const b = {id: 'b', title: 'doesnt have that letter'};
ds.append(a); ds.append(a);
expect(unwrap(ds.output)).toEqual([submitBug]); expect(rawOutput(ds)).toEqual([submitBug]);
ds.append(b); ds.append(b);
expect(unwrap(ds.output)).toEqual([submitBug, b]); expect(rawOutput(ds)).toEqual([submitBug, b]);
// filter in // filter in
const newCookie = { const newCookie = {
@@ -328,7 +336,7 @@ test('filter', () => {
title: 'eat a ookie', title: 'eat a ookie',
}; };
ds.update(0, newCookie); ds.update(0, newCookie);
expect(unwrap(ds.output)).toEqual([newCookie, submitBug, b]); expect(rawOutput(ds)).toEqual([newCookie, submitBug, b]);
// update -> filter in // update -> filter in
const newCoffee = { const newCoffee = {
@@ -336,48 +344,48 @@ test('filter', () => {
title: 'better drink tea', title: 'better drink tea',
}; };
ds.upsert(newCoffee); ds.upsert(newCoffee);
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, b]); expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, b]);
// update -> filter out // update -> filter out
ds.update(2, {id: 'bug', title: 'bug has c!'}); ds.update(2, {id: 'bug', title: 'bug has c!'});
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, b]); expect(rawOutput(ds)).toEqual([newCookie, newCoffee, b]);
ds.update(2, submitBug); ds.update(2, submitBug);
ds.setFilter(undefined); ds.view.setFilter(undefined);
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]); expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, a, b]);
}); });
test('reverse without sorting', () => { test('reverse without sorting', () => {
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]); const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
expect(ds.getOutput()).toEqual([eatCookie, drinkCoffee]); expect(ds.view.output()).toEqual([eatCookie, drinkCoffee]);
ds.toggleReversed(); ds.view.toggleReversed();
expect(ds.getOutput(1, 2)).toEqual([eatCookie]); expect(ds.view.output(1, 2)).toEqual([eatCookie]);
expect(ds.getOutput(0, 1)).toEqual([drinkCoffee]); expect(ds.view.output(0, 1)).toEqual([drinkCoffee]);
expect(ds.getOutput(0, 2)).toEqual([drinkCoffee, eatCookie]); expect(ds.view.output(0, 2)).toEqual([drinkCoffee, eatCookie]);
expect(ds.getOutput()).toEqual([drinkCoffee, eatCookie]); expect(ds.view.output()).toEqual([drinkCoffee, eatCookie]);
ds.append(submitBug); ds.append(submitBug);
expect(ds.records).toEqual([eatCookie, drinkCoffee, submitBug]); expect(ds.records()).toEqual([eatCookie, drinkCoffee, submitBug]);
expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, eatCookie]); expect(ds.view.output()).toEqual([submitBug, drinkCoffee, eatCookie]);
const x = {id: 'x', title: 'x'}; const x = {id: 'x', title: 'x'};
ds.update(0, x); ds.update(0, x);
expect(ds.records).toEqual([x, drinkCoffee, submitBug]); expect(ds.records()).toEqual([x, drinkCoffee, submitBug]);
expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, x]); expect(ds.view.output()).toEqual([submitBug, drinkCoffee, x]);
const y = {id: 'y', title: 'y'}; const y = {id: 'y', title: 'y'};
const z = {id: 'z', title: 'z'}; const z = {id: 'z', title: 'z'};
ds.update(2, z); ds.update(2, z);
ds.update(1, y); ds.update(1, y);
expect(ds.records).toEqual([x, y, z]); expect(ds.records()).toEqual([x, y, z]);
expect(ds.getOutput()).toEqual([z, y, x]); expect(ds.view.output()).toEqual([z, y, x]);
ds.setReversed(false); ds.view.setReversed(false);
expect(ds.getOutput()).toEqual([x, y, z]); expect(ds.view.output()).toEqual([x, y, z]);
}); });
test('reverse with sorting', () => { test('reverse with sorting', () => {
@@ -393,23 +401,23 @@ test('reverse with sorting', () => {
const c = {$: 'c', name: 'c'}; const c = {$: 'c', name: 'c'};
const ds = createDataSource<N>([]); const ds = createDataSource<N>([]);
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.setReversed(true); ds.view.setReversed(true);
ds.append(b1); ds.append(b1);
ds.append(c); ds.append(c);
expect(ds.getOutput()).toEqual([c, b1]); expect(ds.view.output()).toEqual([c, b1]);
ds.setSortBy('$'); ds.view.setSortBy('$');
expect(ds.getOutput()).toEqual([c, b1]); expect(ds.view.output()).toEqual([c, b1]);
ds.append(b2); ds.append(b2);
expect(ds.getOutput()).toEqual([c, b2, b1]); expect(ds.view.output()).toEqual([c, b2, b1]);
ds.append(a); ds.append(a);
expect(ds.getOutput()).toEqual([c, b2, b1, a]); expect(ds.view.output()).toEqual([c, b2, b1, a]);
ds.append(b3); ds.append(b3);
expect(ds.getOutput()).toEqual([c, b3, b2, b1, a]); expect(ds.view.output()).toEqual([c, b3, b2, b1, a]);
// if we append a new item with existig item, it should end up in the end // if we append a new item with existig item, it should end up in the end
const b4 = { const b4 = {
@@ -417,7 +425,7 @@ test('reverse with sorting', () => {
name: 'b4', name: 'b4',
}; };
ds.append(b4); ds.append(b4);
expect(ds.getOutput()).toEqual([c, b4, b3, b2, b1, a]); expect(ds.view.output()).toEqual([c, b4, b3, b2, b1, a]);
// if we replace the middle item, it should end up in the middle // if we replace the middle item, it should end up in the middle
const b2r = { const b2r = {
@@ -425,7 +433,7 @@ test('reverse with sorting', () => {
name: 'b2replacement', name: 'b2replacement',
}; };
ds.update(2, b2r); ds.update(2, b2r);
expect(ds.getOutput()).toEqual([c, b4, b3, b2r, b1, a]); expect(ds.view.output()).toEqual([c, b4, b3, b2r, b1, a]);
// if we replace something with a different sort value, it should be sorted properly, and the old should disappear // if we replace something with a different sort value, it should be sorted properly, and the old should disappear
const b3r = { const b3r = {
@@ -433,45 +441,45 @@ test('reverse with sorting', () => {
name: 'b3replacement', name: 'b3replacement',
}; };
ds.update(4, b3r); ds.update(4, b3r);
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]); expect(ds.view.output()).toEqual([c, b4, b2r, b1, b3r, a]);
ds.remove(4); ds.delete(4);
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, a]); expect(ds.view.output()).toEqual([c, b4, b2r, b1, a]);
}); });
test('reset', () => { test('reset', () => {
const ds = createDataSource<Todo>([submitBug, drinkCoffee, eatCookie], { const ds = createDataSource<Todo>([submitBug, drinkCoffee, eatCookie], {
key: 'id', key: 'id',
}); });
ds.setSortBy('title'); ds.view.setSortBy('title');
ds.setFilter((v) => v.id !== 'cookie'); ds.view.setFilter((v) => v.id !== 'cookie');
expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]);
expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']);
ds.reset(); ds.view.reset();
expect(unwrap(ds.output)).toEqual([submitBug, drinkCoffee, eatCookie]); expect(rawOutput(ds)).toEqual([submitBug, drinkCoffee, eatCookie]);
expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']);
}); });
test('clear', () => { test('clear', () => {
const ds = createDataSource<Todo>([submitBug, drinkCoffee, eatCookie], { const ds = createDataSource<Todo>([submitBug, drinkCoffee, eatCookie], {
key: 'id', key: 'id',
}); });
ds.setSortBy('title'); ds.view.setSortBy('title');
ds.setFilter((v) => v.id !== 'cookie'); ds.view.setFilter((v) => v.id !== 'cookie');
expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]);
expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']);
ds.clear(); ds.clear();
expect(unwrap(ds.output)).toEqual([]); expect(rawOutput(ds)).toEqual([]);
expect([...ds.recordsById.keys()]).toEqual([]); expect([...ds.keys()]).toEqual([]);
ds.append(eatCookie); ds.append(eatCookie);
ds.append(drinkCoffee); ds.append(drinkCoffee);
ds.append(submitBug); ds.append(submitBug);
expect([...ds.recordsById.keys()]).toEqual(['cookie', 'coffee', 'bug']); expect([...ds.keys()]).toEqual(['cookie', 'coffee', 'bug']);
// resets in the same ordering as view preferences were preserved // resets in the same ordering as view preferences were preserved
expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]);
}); });
function testEvents<T>( function testEvents<T>(
@@ -481,9 +489,9 @@ function testEvents<T>(
): any[] { ): any[] {
const ds = createDataSource<T>(initial, {key}); const ds = createDataSource<T>(initial, {key});
const events: any[] = []; const events: any[] = [];
ds.setOutputChangeListener((e) => events.push(e)); ds.view.setListener((e) => events.push(e));
op(ds); op(ds);
ds.setOutputChangeListener(undefined); ds.view.setListener(undefined);
return events; return events;
} }
@@ -507,7 +515,7 @@ test('it emits the right events - zero window', () => {
test('it emits the right events - small window', () => { test('it emits the right events - small window', () => {
expect( expect(
testEvents(['a', 'b'], (ds) => { testEvents(['a', 'b'], (ds) => {
ds.setWindow(0, 3); ds.view.setWindow(0, 3);
ds.append('c'); ds.append('c');
ds.update(1, 'x'); ds.update(1, 'x');
}), }),
@@ -520,13 +528,13 @@ test('it emits the right events - small window', () => {
test('it emits the right events - view change', () => { test('it emits the right events - view change', () => {
expect( expect(
testEvents(['a', 'b'], (ds) => { testEvents(['a', 'b'], (ds) => {
ds.setWindow(1, 2); ds.view.setWindow(1, 2);
ds.setSortBy((x) => x); ds.view.setSortBy((x) => x);
// a, [b] // a, [b]
ds.update(0, 'x'); ds.update(0, 'x');
// b, [x] // b, [x]
expect(ds.getItem(0)).toEqual('b'); expect(ds.view.get(0)).toEqual('b');
expect(ds.getItem(1)).toEqual('x'); expect(ds.view.get(1)).toEqual('x');
ds.append('y'); ds.append('y');
// b, [x], y // b, [x], y
ds.append('c'); ds.append('c');
@@ -544,14 +552,14 @@ test('it emits the right events - view change', () => {
test('it emits the right events - reversed view change', () => { test('it emits the right events - reversed view change', () => {
expect( expect(
testEvents(['a', 'b'], (ds) => { testEvents(['a', 'b'], (ds) => {
ds.setWindow(1, 2); ds.view.setWindow(1, 2);
ds.setSortBy((x) => x); ds.view.setSortBy((x) => x);
ds.setReversed(true); ds.view.setReversed(true);
// b, [a] // b, [a]
ds.update(0, 'x'); ds.update(0, 'x');
// x, [b] // x, [b]
expect(ds.getItem(0)).toEqual('x'); expect(ds.view.get(0)).toEqual('x');
expect(ds.getItem(1)).toEqual('b'); expect(ds.view.get(1)).toEqual('b');
ds.append('y'); ds.append('y');
// y, [x], b // y, [x], b
ds.append('c'); ds.append('c');
@@ -573,15 +581,15 @@ test('it emits the right events - reversed view change', () => {
test('it emits the right events - reversed view change with filter', () => { test('it emits the right events - reversed view change with filter', () => {
expect( expect(
testEvents(['a', 'b'], (ds) => { testEvents(['a', 'b'], (ds) => {
ds.setWindow(0, 2); ds.view.setWindow(0, 2);
ds.setSortBy((x) => x); ds.view.setSortBy((x) => x);
ds.setReversed(true); ds.view.setReversed(true);
ds.setFilter((x) => ['a', 'b'].includes(x)); ds.view.setFilter((x) => ['a', 'b'].includes(x));
// [b, a] // [b, a]
ds.update(0, 'x'); // x b ds.update(0, 'x'); // x b
// [b, ] // [b, ]
expect(ds.getItem(0)).toEqual('b'); expect(ds.view.get(0)).toEqual('b');
expect(ds.output.length).toBe(1); expect(rawOutput(ds).length).toBe(1);
ds.append('y'); // x b y ds.append('y'); // x b y
// [b, ] // [b, ]
ds.append('c'); // x b y c ds.append('c'); // x b y c
@@ -590,9 +598,9 @@ test('it emits the right events - reversed view change with filter', () => {
// [b, a] // [b, a]
ds.append('a'); // x b y c a a ds.append('a'); // x b y c a a
// [b, a, a] // N.b. the new a is in the *middle* // [b, a, a] // N.b. the new a is in the *middle*
ds.remove(2); // x b c a a ds.delete(2); // x b c a a
// no effect // no effect
ds.remove(4); // this removes the second a in input, so the first a in the outpat! ds.delete(4); // this removes the second a in input, so the first a in the outpat!
// [b, a] // [b, a]
}), }),
).toEqual([ ).toEqual([
@@ -612,16 +620,16 @@ test('basic remove', () => {
testEvents( testEvents(
[drinkCoffee, eatCookie, submitBug], [drinkCoffee, eatCookie, submitBug],
(ds) => { (ds) => {
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.remove(0); ds.delete(0);
expect(ds.getOutput()).toEqual([eatCookie, submitBug]); expect(ds.view.output()).toEqual([eatCookie, submitBug]);
expect(ds.recordsById.get('bug')).toBe(submitBug); expect(ds.getById('bug')).toBe(submitBug);
expect(ds.recordsById.get('coffee')).toBeUndefined(); expect(ds.getById('coffee')).toBeUndefined();
expect(ds.recordsById.get('cookie')).toBe(eatCookie); expect(ds.getById('cookie')).toBe(eatCookie);
ds.upsert(completedBug); ds.upsert(completedBug);
ds.removeByKey('cookie'); ds.deleteByKey('cookie');
expect(ds.getOutput()).toEqual([completedBug]); expect(ds.view.output()).toEqual([completedBug]);
expect(ds.recordsById.get('bug')).toBe(completedBug); expect(ds.getById('bug')).toBe(completedBug);
}, },
'id', 'id',
), ),
@@ -653,16 +661,16 @@ test('basic shift', () => {
testEvents( testEvents(
[drinkCoffee, eatCookie, submitBug], [drinkCoffee, eatCookie, submitBug],
(ds) => { (ds) => {
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.shift(2); ds.shift(2);
expect(ds.getOutput()).toEqual([submitBug]); expect(ds.view.output()).toEqual([submitBug]);
expect(ds.recordsById.get('bug')).toBe(submitBug); expect(ds.getById('bug')).toBe(submitBug);
expect(ds.recordsById.get('coffee')).toBeUndefined(); expect(ds.getById('coffee')).toBeUndefined();
expect(ds.indexOfKey('bug')).toBe(0); expect(ds.getIndexOfKey('bug')).toBe(0);
expect(ds.indexOfKey('coffee')).toBe(-1); expect(ds.getIndexOfKey('coffee')).toBe(-1);
ds.upsert(completedBug); ds.upsert(completedBug);
expect(ds.getOutput()).toEqual([completedBug]); expect(ds.view.output()).toEqual([completedBug]);
expect(ds.recordsById.get('bug')).toBe(completedBug); expect(ds.getById('bug')).toBe(completedBug);
}, },
'id', 'id',
), ),
@@ -684,11 +692,11 @@ test('basic shift', () => {
test('sorted shift', () => { test('sorted shift', () => {
expect( expect(
testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => { testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => {
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.setSortBy((v) => v); ds.view.setSortBy((v) => v);
expect(ds.getOutput()).toEqual(['a', 'b', 'c', 'd', 'e']); expect(ds.view.output()).toEqual(['a', 'b', 'c', 'd', 'e']);
ds.shift(4); ds.shift(4);
expect(ds.getOutput()).toEqual(['d']); expect(ds.view.output()).toEqual(['d']);
ds.shift(1); // optimizes to reset ds.shift(1); // optimizes to reset
}), }),
).toEqual([ ).toEqual([
@@ -704,11 +712,11 @@ test('sorted shift', () => {
test('filtered shift', () => { test('filtered shift', () => {
expect( expect(
testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => { testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => {
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.setFilter((v) => v !== 'b' && v !== 'e'); ds.view.setFilter((v) => v !== 'b' && v !== 'e');
expect(ds.getOutput()).toEqual(['c', 'a', 'd']); expect(ds.view.output()).toEqual(['c', 'a', 'd']);
ds.shift(4); ds.shift(4);
expect(ds.getOutput()).toEqual(['d']); expect(ds.view.output()).toEqual(['d']);
}), }),
).toEqual([ ).toEqual([
{newCount: 3, type: 'reset'}, // filter {newCount: 3, type: 'reset'}, // filter
@@ -724,16 +732,16 @@ test('remove after shift works correctly', () => {
testEvents( testEvents(
[eatCookie, drinkCoffee, submitBug, a, b], [eatCookie, drinkCoffee, submitBug, a, b],
(ds) => { (ds) => {
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.shift(2); ds.shift(2);
ds.removeByKey('b'); ds.deleteByKey('b');
ds.removeByKey('bug'); ds.deleteByKey('bug');
expect(ds.getOutput()).toEqual([a]); expect(ds.view.output()).toEqual([a]);
expect(ds.indexOfKey('cookie')).toBe(-1); expect(ds.getIndexOfKey('cookie')).toBe(-1);
expect(ds.indexOfKey('coffee')).toBe(-1); expect(ds.getIndexOfKey('coffee')).toBe(-1);
expect(ds.indexOfKey('bug')).toBe(-1); expect(ds.getIndexOfKey('bug')).toBe(-1);
expect(ds.indexOfKey('a')).toBe(0); expect(ds.getIndexOfKey('a')).toBe(0);
expect(ds.indexOfKey('b')).toBe(-1); expect(ds.getIndexOfKey('b')).toBe(-1);
}, },
'id', 'id',
), ),
@@ -764,7 +772,7 @@ test('remove after shift works correctly', () => {
test('respects limit', () => { test('respects limit', () => {
const grab = (): [length: number, first: number, last: number] => { const grab = (): [length: number, first: number, last: number] => {
const output = ds.getOutput(); const output = ds.view.output();
return [output.length, output[0], output[output.length - 1]]; return [output.length, output[0], output[output.length - 1]];
}; };
@@ -772,7 +780,7 @@ test('respects limit', () => {
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
{limit: 20}, {limit: 20},
); );
ds.setWindow(0, 100); ds.view.setWindow(0, 100);
ds.append(19); ds.append(19);
ds.append(20); ds.append(20);
@@ -783,7 +791,7 @@ test('respects limit', () => {
ds.append(22); ds.append(22);
expect(grab()).toEqual([20, 3, 22]); expect(grab()).toEqual([20, 3, 22]);
ds.remove(0); ds.delete(0);
expect(grab()).toEqual([19, 4, 22]); expect(grab()).toEqual([19, 4, 22]);
ds.append(23); ds.append(23);
@@ -791,3 +799,51 @@ test('respects limit', () => {
ds.append(24); ds.append(24);
expect(grab()).toEqual([19, 6, 24]); expect(grab()).toEqual([19, 6, 24]);
}); });
test('DataSource can iterate', () => {
const ds = createDataSource([eatCookie, drinkCoffee], {key: 'id'});
expect([...ds]).toEqual([eatCookie, drinkCoffee]);
expect(Array.from(ds.keys())).toEqual(['cookie', 'coffee']);
expect(Array.from(ds.entries())).toEqual([
['cookie', eatCookie],
['coffee', drinkCoffee],
]);
const seen: Todo[] = [];
for (const todo of ds) {
seen.push(todo);
}
expect(seen).toEqual([eatCookie, drinkCoffee]);
ds.append(submitBug);
expect([...ds]).toEqual([eatCookie, drinkCoffee, submitBug]);
ds.clear();
expect([...ds]).toEqual([]);
ds.append(submitBug);
expect([...ds]).toEqual([submitBug]);
});
test('DataSource.view can iterate', () => {
const ds = createDataSource([eatCookie, drinkCoffee, submitBug, eatCookie]);
ds.view.setSortBy('id');
// bug coffee cookie cookie
ds.view.toggleReversed();
// cookie cookie coffee bug
ds.view.setWindow(1, 3);
// cookie coffee
expect(ds.view.output()).toEqual([eatCookie, drinkCoffee]);
expect([...ds.view]).toEqual([eatCookie, drinkCoffee]);
ds.view.reset();
// default window is empty!
expect([...ds.view]).toEqual([]);
ds.view.setWindow(0, 100);
expect([...ds.view]).toEqual([eatCookie, drinkCoffee, submitBug, eatCookie]);
ds.clear();
expect([...ds.view]).toEqual([]);
});

View File

@@ -41,7 +41,7 @@ type DataSourceish = DataSource<Todo> & FakeDataSource<Todo>;
test.skip('run perf test', () => { test.skip('run perf test', () => {
if (!global.gc) { if (!global.gc) {
console.warn( console.warn(
'Warning: garbage collector not available, skipping this test', 'Warning: garbage collector not available, skipping this test. Make sure to start the test suite using `yarn watch`',
); );
return; return;
} }
@@ -73,10 +73,10 @@ test.skip('run perf test', () => {
}; };
Object.entries(datasources).forEach(([name, ds]) => { Object.entries(datasources).forEach(([name, ds]) => {
ds.setWindow(0, 1000000); ds.view.setWindow(0, 1000000);
if (name.includes('sorted')) { if (name.includes('sorted')) {
ds.setFilter(defaultFilter); ds.view.setFilter(defaultFilter);
ds.setSortBy('title'); ds.view.setSortBy('title');
} }
}); });
@@ -90,7 +90,7 @@ test.skip('run perf test', () => {
// to 'render' we need to know the end result (this mimics a lazy evaluation of filter / sort) // to 'render' we need to know the end result (this mimics a lazy evaluation of filter / sort)
// note that this skews the test a bit in favor of fake data source, // note that this skews the test a bit in favor of fake data source,
// as DataSource would *always* keep things sorted/ filtered, but doing that would explode the test for append / update :) // as DataSource would *always* keep things sorted/ filtered, but doing that would explode the test for append / update :)
ds.buildOutput(); ds.view.buildOutput();
} }
// global.gc?.(); // to cleanup our createdmess as part of the measurement // global.gc?.(); // to cleanup our createdmess as part of the measurement
const duration = Date.now() - start; const duration = Date.now() - start;
@@ -119,7 +119,7 @@ test.skip('run perf test', () => {
}); });
measure('remove', (ds) => { measure('remove', (ds) => {
ds.remove(99); ds.delete(99);
}); });
measure('shift', (ds) => { measure('shift', (ds) => {
@@ -127,11 +127,11 @@ test.skip('run perf test', () => {
}); });
measure('change sorting', (ds) => { measure('change sorting', (ds) => {
ds.setSortBy('id'); ds.view.setSortBy('id');
}); });
measure('change filter', (ds) => { measure('change filter', (ds) => {
ds.setFilter((t) => t.title.includes('23')); // 23 does not occur in original text ds.view.setFilter((t) => t.title.includes('23')); // 23 does not occur in original text
}); });
const sum: any = {}; const sum: any = {};
@@ -159,22 +159,23 @@ class FakeDataSource<T> {
constructor(initial: T[]) { constructor(initial: T[]) {
this.data = initial; this.data = initial;
this.buildOutput(); this.view.buildOutput();
} }
setWindow(_start: number, _end: number) { view = {
setWindow: (_start: number, _end: number) => {
// noop // noop
} },
setFilter(filter: (t: T) => boolean) { setFilter: (filter: (t: T) => boolean) => {
this.filterFn = filter; this.filterFn = filter;
} },
setSortBy(k: keyof T) { setSortBy: (k: keyof T) => {
this.sortAttr = k; this.sortAttr = k;
} },
buildOutput() { buildOutput: () => {
const filtered = this.filterFn const filtered = this.filterFn
? this.data.filter(this.filterFn) ? this.data.filter(this.filterFn)
: this.data; : this.data;
@@ -189,7 +190,8 @@ class FakeDataSource<T> {
) )
: filtered; : filtered;
this.output = sorted; this.output = sorted;
} },
};
append(v: T) { append(v: T) {
this.data = [...this.data, v]; this.data = [...this.data, v];

View File

@@ -96,7 +96,7 @@ export const DataSourceRenderer: <T extends object, C>(
const parentRef = React.useRef<null | HTMLDivElement>(null); const parentRef = React.useRef<null | HTMLDivElement>(null);
const virtualizer = useVirtual({ const virtualizer = useVirtual({
size: dataSource.output.length, size: dataSource.view.size,
parentRef, parentRef,
useObserver: _testHeight useObserver: _testHeight
? () => ({height: _testHeight, width: 1000}) ? () => ({height: _testHeight, width: 1000})
@@ -148,7 +148,7 @@ export const DataSourceRenderer: <T extends object, C>(
} }
} }
dataSource.setOutputChangeListener((event) => { dataSource.view.setListener((event) => {
switch (event.type) { switch (event.type) {
case 'reset': case 'reset':
rerender(UpdatePrio.HIGH, true); rerender(UpdatePrio.HIGH, true);
@@ -171,7 +171,7 @@ export const DataSourceRenderer: <T extends object, C>(
return () => { return () => {
unmounted = true; unmounted = true;
dataSource.setOutputChangeListener(undefined); dataSource.view.setListener(undefined);
}; };
}, },
[dataSource, setForceUpdate, useFixedRowHeight, _testHeight], [dataSource, setForceUpdate, useFixedRowHeight, _testHeight],
@@ -185,15 +185,15 @@ export const DataSourceRenderer: <T extends object, C>(
useLayoutEffect(function updateWindow() { useLayoutEffect(function updateWindow() {
const start = virtualizer.virtualItems[0]?.index ?? 0; const start = virtualizer.virtualItems[0]?.index ?? 0;
const end = start + virtualizer.virtualItems.length; const end = start + virtualizer.virtualItems.length;
if (start !== dataSource.windowStart && !followOutput.current) { if (start !== dataSource.view.windowStart && !followOutput.current) {
onRangeChange?.( onRangeChange?.(
start, start,
end, end,
dataSource.output.length, dataSource.view.size,
parentRef.current?.scrollTop ?? 0, parentRef.current?.scrollTop ?? 0,
); );
} }
dataSource.setWindow(start, end); dataSource.view.setWindow(start, end);
}); });
/** /**
@@ -223,7 +223,7 @@ export const DataSourceRenderer: <T extends object, C>(
useLayoutEffect(function scrollToEnd() { useLayoutEffect(function scrollToEnd() {
if (followOutput.current) { if (followOutput.current) {
virtualizer.scrollToIndex( virtualizer.scrollToIndex(
dataSource.output.length - 1, dataSource.view.size - 1,
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/ /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
{ {
align: 'end', align: 'end',
@@ -255,7 +255,7 @@ export const DataSourceRenderer: <T extends object, C>(
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
tabIndex={0}> tabIndex={0}>
{virtualizer.virtualItems.map((virtualRow) => { {virtualizer.virtualItems.map((virtualRow) => {
const entry = dataSource.getEntry(virtualRow.index); const value = dataSource.view.get(virtualRow.index);
// the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always. // the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/} // Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
return ( return (
@@ -270,7 +270,7 @@ export const DataSourceRenderer: <T extends object, C>(
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}> ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
{itemRenderer(entry.value, virtualRow.index, context)} {itemRenderer(value, virtualRow.index, context)}
</div> </div>
); );
})} })}

View File

@@ -197,7 +197,7 @@ export function DataTable<T extends object>(
(e: React.KeyboardEvent<any>) => { (e: React.KeyboardEvent<any>) => {
let handled = true; let handled = true;
const shiftPressed = e.shiftKey; const shiftPressed = e.shiftKey;
const outputSize = dataSource.output.length; const outputSize = dataSource.view.size;
const windowSize = virtualizerRef.current!.virtualItems.length; const windowSize = virtualizerRef.current!.virtualItems.length;
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
@@ -244,7 +244,7 @@ export function DataTable<T extends object>(
useEffect( useEffect(
function updateFilter() { function updateFilter() {
dataSource.setFilter( dataSource.view.setFilter(
computeDataTableFilter(state.searchValue, state.columns), computeDataTableFilter(state.searchValue, state.columns),
); );
}, },
@@ -257,11 +257,11 @@ export function DataTable<T extends object>(
useEffect( useEffect(
function updateSorting() { function updateSorting() {
if (state.sorting === undefined) { if (state.sorting === undefined) {
dataSource.setSortBy(undefined); dataSource.view.setSortBy(undefined);
dataSource.setReversed(false); dataSource.view.setReversed(false);
} else { } else {
dataSource.setSortBy(state.sorting.key); dataSource.view.setSortBy(state.sorting.key);
dataSource.setReversed(state.sorting.direction === 'desc'); dataSource.view.setReversed(state.sorting.direction === 'desc');
} }
}, },
[dataSource, state.sorting], [dataSource, state.sorting],
@@ -342,7 +342,7 @@ export function DataTable<T extends object>(
savePreferences(stateRef.current, lastOffset.current); savePreferences(stateRef.current, lastOffset.current);
// if the component unmounts, we reset the SFRW pipeline to // if the component unmounts, we reset the SFRW pipeline to
// avoid wasting resources in the background // avoid wasting resources in the background
dataSource.reset(); dataSource.view.reset();
// clean ref // clean ref
if (props.tableManagerRef) { if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = undefined; (props.tableManagerRef as MutableRefObject<any>).current = undefined;

View File

@@ -342,7 +342,7 @@ export function getSelectedItem<T>(
): T | undefined { ): T | undefined {
return selection.current < 0 return selection.current < 0
? undefined ? undefined
: dataSource.getItem(selection.current); : dataSource.view.get(selection.current);
} }
export function getSelectedItems<T>( export function getSelectedItems<T>(
@@ -351,7 +351,7 @@ export function getSelectedItems<T>(
): T[] { ): T[] {
return [...selection.items] return [...selection.items]
.sort() .sort()
.map((i) => dataSource.getItem(i)) .map((i) => dataSource.view.get(i))
.filter(Boolean) as any[]; .filter(Boolean) as any[];
} }