Implemented remove operation

Summary: Implemented `remove`, which, for a typical data source should not be needed. But that would be famous last words and wanted to prevent painting ourselves in a corner, so implemented it. Also because part of the logic is need for the `shift` operation (see next diff), which is much more important.

Reviewed By: priteshrnandgaonkar

Differential Revision: D26883672

fbshipit-source-id: 0dbfcdd3d5a16c4a2d53b0272000d183c67d0034
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent a610c821d3
commit 564d440b4a
4 changed files with 165 additions and 21 deletions

View File

@@ -38,8 +38,13 @@ type UpdateEvent<T> = {
oldVisible: boolean;
index: number;
};
type RemoveEvent<T> = {
type: 'remove';
entry: Entry<T>;
index: number;
};
type DataEvent<T> = AppendEvent<T> | UpdateEvent<T>;
type DataEvent<T> = AppendEvent<T> | UpdateEvent<T> | RemoveEvent<T>;
type Entry<T> = {
value: T;
@@ -259,6 +264,48 @@ export class DataSource<
});
}
/**
* @param index
*
* Warning: this operation can be O(n) if a key is set
*/
remove(index: number) {
if (index < 0 || index >= this._records.length) {
throw new Error('Out of bounds: ' + index);
}
const entry = this._records.splice(index, 1)[0];
if (this.keyAttribute) {
const key = this.getKey(entry.value);
this._recordsById.delete(key);
this.idToIndex.delete(key);
// Optimization: this is O(n)! Should be done as an async job
this.idToIndex.forEach((keyIndex, key) => {
if (keyIndex > index) this.idToIndex.set(key, keyIndex - 1);
});
}
this.emitDataEvent({
type: 'remove',
index,
entry,
});
}
/**
* Removes the item with the given key from this dataSource.
* Returns false if no record with the given key was found
*
* Warning: this operation can be O(n) if a key is set
*/
removeByKey(keyValue: KEY_TYPE): boolean {
this.assertKeySet();
const index = this.idToIndex.get(keyValue);
if (index === undefined) {
return false;
}
this.remove(index);
return true;
}
/**
* Removes the first N entries.
* @param amount
@@ -347,13 +394,13 @@ export class DataSource<
);
}
emitDataEvent(event: DataEvent<T>) {
private emitDataEvent(event: DataEvent<T>) {
this.dataUpdateQueue.push(event);
// TODO: schedule
this.processEvents();
}
normalizeIndex(viewIndex: number): number {
private normalizeIndex(viewIndex: number): number {
return this.reverse ? this.output.length - 1 - viewIndex : viewIndex;
}
@@ -365,7 +412,7 @@ export class DataSource<
return this.output[this.normalizeIndex(viewIndex)];
}
notifyItemUpdated(viewIndex: number) {
private notifyItemUpdated(viewIndex: number) {
viewIndex = this.normalizeIndex(viewIndex);
if (
!this.outputChangeListener ||
@@ -380,7 +427,7 @@ export class DataSource<
});
}
notifyItemShift(index: number, delta: number) {
private notifyItemShift(index: number, delta: number) {
if (!this.outputChangeListener) {
return;
}
@@ -403,19 +450,19 @@ export class DataSource<
});
}
notifyReset(count: number) {
private notifyReset(count: number) {
this.outputChangeListener?.({
type: 'reset',
newCount: count,
});
}
processEvents() {
private processEvents() {
const events = this.dataUpdateQueue.splice(0);
events.forEach(this.processEvent);
}
processEvent = (event: DataEvent<T>) => {
private processEvent = (event: DataEvent<T>) => {
const {entry} = event;
const {output, sortBy, filter} = this;
switch (event.type) {
@@ -475,12 +522,29 @@ export class DataSource<
}
break;
}
case 'remove': {
// filter active, and not visible? short circuilt
if (!entry.visible) {
return;
}
// no sorting, no filter?
if (!sortBy && !filter) {
output.splice(event.index, 1);
this.notifyItemShift(event.index, -1);
} else {
// sorting or filter is active, find the actual location
const existingIndex = this.getSortedIndex(entry, event.entry.value);
output.splice(existingIndex, 1);
this.notifyItemShift(existingIndex, -1);
}
break;
}
default:
throw new Error('unknown event type');
}
};
rebuildOutput() {
private rebuildOutput() {
const {sortBy, filter, sortHelper} = this;
// copy base array or run filter (with side effecty update of visible)
// TODO: pending on the size, should we batch this in smaller steps? (and maybe merely reuse append)

View File

@@ -45,6 +45,9 @@ test('can create a datasource', () => {
ds.update(1, submitBug);
expect(ds.records[1]).toBe(submitBug);
ds.remove(0);
expect(ds.records[0]).toBe(submitBug);
});
test('can create a keyed datasource', () => {
@@ -87,6 +90,14 @@ test('can create a keyed datasource', () => {
ds.upsert(trash);
expect(ds.records[2]).toBe(trash);
expect(ds.recordsById.get('trash')).toBe(trash);
// delete by key
expect(ds.records).toEqual([eatCookie, newBug, trash]);
expect(ds.removeByKey('bug')).toBe(true);
expect(ds.records).toEqual([eatCookie, trash]);
expect(ds.indexOfKey('bug')).toBe(-1);
expect(ds.indexOfKey('cookie')).toBe(0);
expect(ds.indexOfKey('trash')).toBe(1);
});
test('throws on invalid keys', () => {
@@ -99,6 +110,14 @@ test('throws on invalid keys', () => {
}).toThrow(`Duplicate key: 'cookie'`);
});
test('removing invalid keys', () => {
const ds = createDataSource<Todo>([eatCookie], 'id');
expect(ds.removeByKey('trash')).toBe(false);
expect(() => {
ds.remove(1);
}).toThrowError('Out of bounds');
});
test('sorting works', () => {
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
ds.setSortBy((todo) => todo.title);
@@ -168,6 +187,10 @@ test('sorting preserves insertion order with equal keys', () => {
ds.update(4, b3r);
expect(ds.records).toEqual([b1, c, b2r, a, b3r, b4]);
expect(unwrap(ds.output)).toEqual([a, b3r, b1, b2r, b4, c]);
ds.remove(3);
expect(ds.records).toEqual([b1, c, b2r, b3r, b4]);
expect(unwrap(ds.output)).toEqual([b3r, b1, b2r, b4, c]);
});
test('filter + sort', () => {
@@ -209,11 +232,13 @@ test('filter + sort', () => {
ds.update(2, submitBug);
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]);
ds.remove(3); // a
ds.remove(3); // b
expect(unwrap(ds.output)).toEqual([newCoffee, newCookie, submitBug]);
ds.setFilter(undefined);
expect(unwrap(ds.output)).toEqual([
newCoffee,
a,
b,
drinkCoffee,
newCookie,
submitBug,
@@ -225,8 +250,6 @@ test('filter + sort', () => {
newCookie,
drinkCoffee,
submitBug,
a,
b,
newCoffee,
]);
});
@@ -407,6 +430,9 @@ test('reverse with sorting', () => {
};
ds.update(4, b3r);
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]);
ds.remove(4);
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, a]);
});
test('reset', () => {
@@ -443,8 +469,9 @@ test('clear', () => {
function testEvents<T>(
initial: T[],
op: (ds: DataSource<T, any, any>) => void,
key?: keyof T,
): any[] {
const ds = createDataSource<T>(initial);
const ds = createDataSource<T>(initial, key);
const events: any[] = [];
ds.setOutputChangeListener((e) => events.push(e));
op(ds);
@@ -543,18 +570,22 @@ test('it emits the right events - reversed view change with filter', () => {
ds.setReversed(true);
ds.setFilter((x) => ['a', 'b'].includes(x));
// [b, a]
ds.update(0, 'x');
ds.update(0, 'x'); // x b
// [b, ]
expect(ds.getItem(0)).toEqual('b');
expect(ds.output.length).toBe(1);
ds.append('y');
ds.append('y'); // x b y
// [b, ]
ds.append('c');
ds.append('c'); // x b y c
// [b, ]
ds.append('a');
ds.append('a'); // x b y c a
// [b, a]
ds.append('a');
ds.append('a'); // x b y c a a
// [b, a, a] // N.b. the new a is in the *middle*
ds.remove(2); // x b c a a
// no effect
ds.remove(4); // this removes the second a in input, so the first a in the outpat!
// [b, a]
}),
).toEqual([
{newCount: 2, type: 'reset'},
@@ -563,5 +594,47 @@ test('it emits the right events - reversed view change with filter', () => {
{index: 1, delta: -1, location: 'in', newCount: 1, type: 'shift'}, // remove a
{index: 1, delta: 1, location: 'in', newCount: 2, type: 'shift'},
{index: 1, delta: 1, location: 'in', newCount: 3, type: 'shift'},
{index: 1, delta: -1, location: 'in', newCount: 2, type: 'shift'},
]);
});
test('basic remove', () => {
const completedBug = {id: 'bug', title: 'fixed bug', done: true};
expect(
testEvents(
[drinkCoffee, eatCookie, submitBug],
(ds) => {
ds.setWindow(0, 100);
ds.remove(0);
expect(ds.getOutput()).toEqual([eatCookie, submitBug]);
expect(ds.recordsById.get('bug')).toBe(submitBug);
expect(ds.recordsById.get('coffee')).toBeUndefined();
expect(ds.recordsById.get('cookie')).toBe(eatCookie);
ds.upsert(completedBug);
ds.removeByKey('cookie');
expect(ds.getOutput()).toEqual([completedBug]);
expect(ds.recordsById.get('bug')).toBe(completedBug);
},
'id',
),
).toEqual([
{
type: 'shift',
newCount: 2,
location: 'in',
index: 0,
delta: -1,
},
{
type: 'update',
index: 1,
},
{
type: 'shift',
index: 0,
location: 'in',
newCount: 1,
delta: -1,
},
]);
});

View File

@@ -14,6 +14,5 @@
],
"exclude": [
"node_modules",
"**/__tests__/*"
]
}

View File

@@ -2252,7 +2252,15 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@26", "@types/jest@26.x", "@types/jest@^26", "@types/jest@^26.0.20":
"@types/jest@26", "@types/jest@26.x", "@types/jest@^26":
version "26.0.15"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe"
integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog==
dependencies:
jest-diff "^26.0.0"
pretty-format "^26.0.0"
"@types/jest@^26.0.20":
version "26.0.20"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.20.tgz#cd2f2702ecf69e86b586e1f5223a60e454056307"
integrity sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA==