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:
committed by
Facebook GitHub Bot
parent
a610c821d3
commit
564d440b4a
@@ -38,8 +38,13 @@ type UpdateEvent<T> = {
|
|||||||
oldVisible: boolean;
|
oldVisible: boolean;
|
||||||
index: number;
|
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> = {
|
type Entry<T> = {
|
||||||
value: 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.
|
* Removes the first N entries.
|
||||||
* @param amount
|
* @param amount
|
||||||
@@ -347,13 +394,13 @@ export class DataSource<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitDataEvent(event: DataEvent<T>) {
|
private emitDataEvent(event: DataEvent<T>) {
|
||||||
this.dataUpdateQueue.push(event);
|
this.dataUpdateQueue.push(event);
|
||||||
// TODO: schedule
|
// TODO: schedule
|
||||||
this.processEvents();
|
this.processEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +412,7 @@ export class DataSource<
|
|||||||
return this.output[this.normalizeIndex(viewIndex)];
|
return this.output[this.normalizeIndex(viewIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyItemUpdated(viewIndex: number) {
|
private notifyItemUpdated(viewIndex: number) {
|
||||||
viewIndex = this.normalizeIndex(viewIndex);
|
viewIndex = this.normalizeIndex(viewIndex);
|
||||||
if (
|
if (
|
||||||
!this.outputChangeListener ||
|
!this.outputChangeListener ||
|
||||||
@@ -380,7 +427,7 @@ export class DataSource<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyItemShift(index: number, delta: number) {
|
private notifyItemShift(index: number, delta: number) {
|
||||||
if (!this.outputChangeListener) {
|
if (!this.outputChangeListener) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,19 +450,19 @@ export class DataSource<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyReset(count: number) {
|
private notifyReset(count: number) {
|
||||||
this.outputChangeListener?.({
|
this.outputChangeListener?.({
|
||||||
type: 'reset',
|
type: 'reset',
|
||||||
newCount: count,
|
newCount: count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processEvents() {
|
private processEvents() {
|
||||||
const events = this.dataUpdateQueue.splice(0);
|
const events = this.dataUpdateQueue.splice(0);
|
||||||
events.forEach(this.processEvent);
|
events.forEach(this.processEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
processEvent = (event: DataEvent<T>) => {
|
private processEvent = (event: DataEvent<T>) => {
|
||||||
const {entry} = event;
|
const {entry} = event;
|
||||||
const {output, sortBy, filter} = this;
|
const {output, sortBy, filter} = this;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -475,12 +522,29 @@ export class DataSource<
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
throw new Error('unknown event type');
|
throw new Error('unknown event type');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
rebuildOutput() {
|
private rebuildOutput() {
|
||||||
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)
|
||||||
// TODO: pending on the size, should we batch this in smaller steps? (and maybe merely reuse append)
|
// TODO: pending on the size, should we batch this in smaller steps? (and maybe merely reuse append)
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ test('can create a datasource', () => {
|
|||||||
|
|
||||||
ds.update(1, submitBug);
|
ds.update(1, submitBug);
|
||||||
expect(ds.records[1]).toBe(submitBug);
|
expect(ds.records[1]).toBe(submitBug);
|
||||||
|
|
||||||
|
ds.remove(0);
|
||||||
|
expect(ds.records[0]).toBe(submitBug);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can create a keyed datasource', () => {
|
test('can create a keyed datasource', () => {
|
||||||
@@ -87,6 +90,14 @@ test('can create a keyed datasource', () => {
|
|||||||
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.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', () => {
|
test('throws on invalid keys', () => {
|
||||||
@@ -99,6 +110,14 @@ test('throws on invalid keys', () => {
|
|||||||
}).toThrow(`Duplicate key: 'cookie'`);
|
}).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', () => {
|
test('sorting works', () => {
|
||||||
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
|
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
|
||||||
ds.setSortBy((todo) => todo.title);
|
ds.setSortBy((todo) => todo.title);
|
||||||
@@ -168,6 +187,10 @@ test('sorting preserves insertion order with equal keys', () => {
|
|||||||
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(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', () => {
|
test('filter + sort', () => {
|
||||||
@@ -209,11 +232,13 @@ test('filter + sort', () => {
|
|||||||
ds.update(2, submitBug);
|
ds.update(2, submitBug);
|
||||||
expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, 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);
|
ds.setFilter(undefined);
|
||||||
expect(unwrap(ds.output)).toEqual([
|
expect(unwrap(ds.output)).toEqual([
|
||||||
newCoffee,
|
newCoffee,
|
||||||
a,
|
|
||||||
b,
|
|
||||||
drinkCoffee,
|
drinkCoffee,
|
||||||
newCookie,
|
newCookie,
|
||||||
submitBug,
|
submitBug,
|
||||||
@@ -225,8 +250,6 @@ test('filter + sort', () => {
|
|||||||
newCookie,
|
newCookie,
|
||||||
drinkCoffee,
|
drinkCoffee,
|
||||||
submitBug,
|
submitBug,
|
||||||
a,
|
|
||||||
b,
|
|
||||||
newCoffee,
|
newCoffee,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -407,6 +430,9 @@ test('reverse with sorting', () => {
|
|||||||
};
|
};
|
||||||
ds.update(4, b3r);
|
ds.update(4, b3r);
|
||||||
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]);
|
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]);
|
||||||
|
|
||||||
|
ds.remove(4);
|
||||||
|
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, a]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reset', () => {
|
test('reset', () => {
|
||||||
@@ -443,8 +469,9 @@ test('clear', () => {
|
|||||||
function testEvents<T>(
|
function testEvents<T>(
|
||||||
initial: T[],
|
initial: T[],
|
||||||
op: (ds: DataSource<T, any, any>) => void,
|
op: (ds: DataSource<T, any, any>) => void,
|
||||||
|
key?: keyof T,
|
||||||
): any[] {
|
): any[] {
|
||||||
const ds = createDataSource<T>(initial);
|
const ds = createDataSource<T>(initial, key);
|
||||||
const events: any[] = [];
|
const events: any[] = [];
|
||||||
ds.setOutputChangeListener((e) => events.push(e));
|
ds.setOutputChangeListener((e) => events.push(e));
|
||||||
op(ds);
|
op(ds);
|
||||||
@@ -543,18 +570,22 @@ test('it emits the right events - reversed view change with filter', () => {
|
|||||||
ds.setReversed(true);
|
ds.setReversed(true);
|
||||||
ds.setFilter((x) => ['a', 'b'].includes(x));
|
ds.setFilter((x) => ['a', 'b'].includes(x));
|
||||||
// [b, a]
|
// [b, a]
|
||||||
ds.update(0, 'x');
|
ds.update(0, 'x'); // x b
|
||||||
// [b, ]
|
// [b, ]
|
||||||
expect(ds.getItem(0)).toEqual('b');
|
expect(ds.getItem(0)).toEqual('b');
|
||||||
expect(ds.output.length).toBe(1);
|
expect(ds.output.length).toBe(1);
|
||||||
ds.append('y');
|
ds.append('y'); // x b y
|
||||||
// [b, ]
|
// [b, ]
|
||||||
ds.append('c');
|
ds.append('c'); // x b y c
|
||||||
// [b, ]
|
// [b, ]
|
||||||
ds.append('a');
|
ds.append('a'); // x b y c a
|
||||||
// [b, 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*
|
// [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([
|
).toEqual([
|
||||||
{newCount: 2, type: 'reset'},
|
{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: 1, type: 'shift'}, // remove a
|
||||||
{index: 1, delta: 1, location: 'in', newCount: 2, type: 'shift'},
|
{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: 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,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,5 @@
|
|||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"**/__tests__/*"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2252,7 +2252,15 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/istanbul-lib-report" "*"
|
"@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"
|
version "26.0.20"
|
||||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.20.tgz#cd2f2702ecf69e86b586e1f5223a60e454056307"
|
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.20.tgz#cd2f2702ecf69e86b586e1f5223a60e454056307"
|
||||||
integrity sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA==
|
integrity sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA==
|
||||||
|
|||||||
Reference in New Issue
Block a user