Introduce subscribing to output changes
Summary: This diff introduces the possibility to subscribe to the `output` set of the datasource. It emits three possible event: `reset`, `update`, `shift`. Reviewed By: jknoxville Differential Revision: D26100104 fbshipit-source-id: b5fac2289206fab9fb8a437b96ab84034a8b5832
This commit is contained in:
committed by
Facebook GitHub Bot
parent
50a8bc91ff
commit
5b76a0c717
@@ -18,11 +18,14 @@ import {
|
||||
// TODO: separate views from datasource to be able to support multiple transformation simultanously
|
||||
// TODO: expose interface with public members only
|
||||
// TODO: replace forEach with faster for loops
|
||||
// TODO: delete & unset operation
|
||||
// TODO: support listener for input events?
|
||||
|
||||
type ExtractKeyType<
|
||||
T extends object,
|
||||
KEY extends keyof T
|
||||
> = T[KEY] extends string ? string : T[KEY] extends number ? number : never;
|
||||
type ExtractKeyType<T, KEY extends keyof T> = T[KEY] extends string
|
||||
? string
|
||||
: T[KEY] extends number
|
||||
? number
|
||||
: never;
|
||||
|
||||
type AppendEvent<T> = {
|
||||
type: 'append';
|
||||
@@ -47,8 +50,28 @@ type Entry<T> = {
|
||||
|
||||
type Primitive = number | string | boolean | null | undefined;
|
||||
|
||||
class DataSource<
|
||||
T extends object,
|
||||
type OutputChange =
|
||||
| {
|
||||
type: 'shift';
|
||||
index: number;
|
||||
location: 'before' | 'in' | 'after'; // relative to current window
|
||||
delta: number;
|
||||
newCount: number;
|
||||
}
|
||||
| {
|
||||
// an item, inside the current window, was changed
|
||||
type: 'update';
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
// something big and awesome happened. Drop earlier updates to the floor and start again
|
||||
// like: clear, filter or sorting change, etc
|
||||
type: 'reset';
|
||||
newCount: number;
|
||||
};
|
||||
|
||||
export class DataSource<
|
||||
T,
|
||||
KEY extends keyof T,
|
||||
KEY_TYPE extends string | number | never = ExtractKeyType<T, KEY>
|
||||
> {
|
||||
@@ -67,6 +90,11 @@ class DataSource<
|
||||
|
||||
private dataUpdateQueue: DataEvent<T>[] = [];
|
||||
|
||||
private windowStart = 0;
|
||||
private windowEnd = 0;
|
||||
|
||||
private outputChangeListener?: (change: OutputChange) => void;
|
||||
|
||||
// TODO:
|
||||
// private viewRecords: T[] = [];
|
||||
// private nextViewRecords: T[] = []; // for double buffering
|
||||
@@ -105,6 +133,25 @@ class DataSource<
|
||||
this.setSortBy(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a defensive copy of the current output.
|
||||
* Sort, filter, reverse and window are applied, but windowing isn't.
|
||||
* Start and end behave like slice, and default to the current window
|
||||
*/
|
||||
public getOutput(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private assertKeySet() {
|
||||
if (!this.keyAttribute) {
|
||||
throw new Error(
|
||||
@@ -212,6 +259,17 @@ class DataSource<
|
||||
throw new Error('Not Implemented');
|
||||
}
|
||||
|
||||
setWindow(start: number, end: number) {
|
||||
this.windowStart = start;
|
||||
this.windowEnd = end;
|
||||
}
|
||||
|
||||
setOutputChangeListener(
|
||||
listener: typeof DataSource['prototype']['outputChangeListener'],
|
||||
) {
|
||||
this.outputChangeListener = listener;
|
||||
}
|
||||
|
||||
setSortBy(sortBy: undefined | keyof T | ((a: T) => Primitive)) {
|
||||
if (this.sortBy === sortBy) {
|
||||
return;
|
||||
@@ -268,6 +326,52 @@ class DataSource<
|
||||
this.processEvents();
|
||||
}
|
||||
|
||||
normalizeIndex(viewIndex: number): number {
|
||||
return this.reverse ? this.output.length - 1 - viewIndex : viewIndex;
|
||||
}
|
||||
|
||||
getItem(viewIndex: number) {
|
||||
return this.output[this.normalizeIndex(viewIndex)].value;
|
||||
}
|
||||
|
||||
notifyItemUpdated(viewIndex: number) {
|
||||
viewIndex = this.normalizeIndex(viewIndex);
|
||||
if (
|
||||
!this.outputChangeListener ||
|
||||
viewIndex < this.windowStart ||
|
||||
viewIndex >= this.windowEnd
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.outputChangeListener({
|
||||
type: 'update',
|
||||
index: viewIndex,
|
||||
});
|
||||
}
|
||||
|
||||
notifyItemShift(viewIndex: number, delta: number) {
|
||||
if (!this.outputChangeListener) {
|
||||
return;
|
||||
}
|
||||
viewIndex = this.normalizeIndex(viewIndex);
|
||||
if (this.reverse && delta < 0) {
|
||||
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?
|
||||
this.outputChangeListener({
|
||||
type: 'shift',
|
||||
delta,
|
||||
index: viewIndex,
|
||||
newCount: this.output.length,
|
||||
location:
|
||||
viewIndex < this.windowStart
|
||||
? 'before'
|
||||
: viewIndex >= this.windowEnd
|
||||
? 'after'
|
||||
: 'in',
|
||||
});
|
||||
}
|
||||
|
||||
processEvents() {
|
||||
const events = this.dataUpdateQueue.splice(0);
|
||||
events.forEach(this.processEvent);
|
||||
@@ -287,7 +391,7 @@ class DataSource<
|
||||
// no sorting? insert at the end, or beginning
|
||||
entry.approxIndex = output.length;
|
||||
output.push(entry);
|
||||
// TODO: shift window if following the end or beginning
|
||||
this.notifyItemShift(entry.approxIndex, 1);
|
||||
} else {
|
||||
this.insertSorted(entry);
|
||||
}
|
||||
@@ -297,7 +401,7 @@ class DataSource<
|
||||
// short circuit; no view active so update straight away
|
||||
if (!filter && !sortBy) {
|
||||
output[event.index].approxIndex = event.index;
|
||||
// TODO: notify updated
|
||||
this.notifyItemUpdated(event.index);
|
||||
} else if (!event.oldVisible) {
|
||||
if (!entry.visible) {
|
||||
// Done!
|
||||
@@ -307,12 +411,11 @@ class DataSource<
|
||||
}
|
||||
} else {
|
||||
// Entry was visible previously
|
||||
const existingIndex = this.getSortedIndex(entry, event.oldValue);
|
||||
if (!entry.visible) {
|
||||
// Remove from output
|
||||
const existingIndex = this.getSortedIndex(entry, event.oldValue); // TODO: lift this lookup if needed for events?
|
||||
output.splice(existingIndex, 1);
|
||||
// TODO: notify visible count reduced
|
||||
// TODO: notify potential effect on window
|
||||
this.notifyItemShift(existingIndex, -1);
|
||||
} else {
|
||||
// Entry was and still is visible
|
||||
if (
|
||||
@@ -320,22 +423,15 @@ class DataSource<
|
||||
this.sortBy(event.oldValue) === this.sortBy(entry.value)
|
||||
) {
|
||||
// Still at same position, so done!
|
||||
// TODO: notify of update
|
||||
this.notifyItemUpdated(existingIndex);
|
||||
} else {
|
||||
// item needs to be moved cause of sorting
|
||||
const existingIndex = this.getSortedIndex(entry, event.oldValue);
|
||||
// TODO: possible optimization: if we discover that old and new index would be the same,
|
||||
// despite different sort values, we could still only emit an update
|
||||
output.splice(existingIndex, 1);
|
||||
this.notifyItemShift(existingIndex, -1);
|
||||
// find new sort index
|
||||
const newIndex = sortedLastIndexBy(
|
||||
this.output,
|
||||
entry,
|
||||
this.sortHelper,
|
||||
);
|
||||
entry.approxIndex = newIndex;
|
||||
output.splice(newIndex, 0, entry);
|
||||
// item has moved
|
||||
// remove and replace
|
||||
// TODO: notify entry moved (or replaced, in case newIndex === existingIndex
|
||||
this.insertSorted(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +444,7 @@ class DataSource<
|
||||
|
||||
rebuildOutput() {
|
||||
const {sortBy, filter, sortHelper} = this;
|
||||
// copy base array or run filter (with side effect 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)
|
||||
let output = filter
|
||||
? this._records.filter((entry) => {
|
||||
@@ -368,7 +464,10 @@ class DataSource<
|
||||
entry.visible = true;
|
||||
});
|
||||
this.output = output;
|
||||
// TODO: bunch of events
|
||||
this.outputChangeListener?.({
|
||||
type: 'reset',
|
||||
newCount: output.length,
|
||||
});
|
||||
}
|
||||
|
||||
private sortHelper = (a: Entry<T>) =>
|
||||
@@ -412,19 +511,18 @@ class DataSource<
|
||||
);
|
||||
entry.approxIndex = insertionIndex;
|
||||
this.output.splice(insertionIndex, 0, entry);
|
||||
// TODO: notify window shift if applicable
|
||||
// TODO: shift window if following the end or beginning
|
||||
this.notifyItemShift(insertionIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDataSource<T extends object, KEY extends keyof T = any>(
|
||||
export function createDataSource<T, KEY extends keyof T = any>(
|
||||
initialSet: T[],
|
||||
keyAttribute: KEY,
|
||||
): DataSource<T, KEY, ExtractKeyType<T, KEY>>;
|
||||
export function createDataSource<T extends object>(
|
||||
export function createDataSource<T>(
|
||||
initialSet?: T[],
|
||||
): DataSource<T, never, never>;
|
||||
export function createDataSource<T extends object, KEY extends keyof T>(
|
||||
export function createDataSource<T, KEY extends keyof T>(
|
||||
initialSet: T[] = [],
|
||||
keyAttribute?: KEY | undefined,
|
||||
): DataSource<T, any, any> {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {createDataSource} from '../DataSource';
|
||||
import {createDataSource, DataSource} from '../DataSource';
|
||||
|
||||
type Todo = {
|
||||
id: string;
|
||||
@@ -276,6 +276,9 @@ test('filter + sort + index', () => {
|
||||
ds.setSortBy(undefined);
|
||||
// key insertion order
|
||||
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]);
|
||||
// verify getOutput
|
||||
expect(unwrap(ds.output.slice(1, 3))).toEqual([newCoffee, submitBug]);
|
||||
expect(ds.getOutput(1, 3)).toEqual([newCoffee, submitBug]);
|
||||
});
|
||||
|
||||
test('filter', () => {
|
||||
@@ -318,34 +321,39 @@ test('filter', () => {
|
||||
expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]);
|
||||
});
|
||||
|
||||
test.skip('reverse without sorting', () => {
|
||||
test('reverse without sorting', () => {
|
||||
const ds = createDataSource<Todo>([eatCookie, drinkCoffee]);
|
||||
expect(unwrap(ds.output)).toEqual([eatCookie, drinkCoffee]);
|
||||
ds.setWindow(0, 100);
|
||||
expect(ds.getOutput()).toEqual([eatCookie, drinkCoffee]);
|
||||
|
||||
ds.toggleReversed();
|
||||
expect(unwrap(ds.output)).toEqual([drinkCoffee, eatCookie]);
|
||||
expect(ds.getOutput(1, 2)).toEqual([eatCookie]);
|
||||
expect(ds.getOutput(0, 1)).toEqual([drinkCoffee]);
|
||||
expect(ds.getOutput(0, 2)).toEqual([drinkCoffee, eatCookie]);
|
||||
|
||||
expect(ds.getOutput()).toEqual([drinkCoffee, eatCookie]);
|
||||
|
||||
ds.append(submitBug);
|
||||
expect(ds.records).toEqual([eatCookie, drinkCoffee, submitBug]);
|
||||
expect(unwrap(ds.output)).toEqual([submitBug, drinkCoffee, eatCookie]);
|
||||
expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, eatCookie]);
|
||||
|
||||
const x = {id: 'x', title: 'x'};
|
||||
ds.update(0, x);
|
||||
expect(ds.records).toEqual([x, drinkCoffee, submitBug]);
|
||||
expect(unwrap(ds.output)).toEqual([submitBug, drinkCoffee, x]);
|
||||
expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, x]);
|
||||
const y = {id: 'y', title: 'y'};
|
||||
const z = {id: 'z', title: 'z'};
|
||||
ds.update(2, z);
|
||||
ds.update(1, y);
|
||||
|
||||
expect(ds.records).toEqual([x, y, z]);
|
||||
expect(unwrap(ds.output)).toEqual([z, y, x]);
|
||||
expect(ds.getOutput()).toEqual([z, y, x]);
|
||||
|
||||
ds.setReversed(false);
|
||||
expect(unwrap(ds.output)).toEqual([x, y, z]);
|
||||
expect(ds.getOutput()).toEqual([x, y, z]);
|
||||
});
|
||||
|
||||
test.skip('reverse with sorting', () => {
|
||||
test('reverse with sorting', () => {
|
||||
type N = {
|
||||
$: string;
|
||||
name: string;
|
||||
@@ -358,22 +366,23 @@ test.skip('reverse with sorting', () => {
|
||||
const c = {$: 'c', name: 'c'};
|
||||
|
||||
const ds = createDataSource<N>([]);
|
||||
ds.setWindow(0, 100);
|
||||
ds.setReversed(true);
|
||||
ds.append(b1);
|
||||
ds.append(c);
|
||||
expect(unwrap(ds.output)).toEqual([c, b1]);
|
||||
expect(ds.getOutput()).toEqual([c, b1]);
|
||||
|
||||
ds.setSortBy('$');
|
||||
expect(unwrap(ds.output)).toEqual([c, b1]);
|
||||
expect(ds.getOutput()).toEqual([c, b1]);
|
||||
|
||||
ds.append(b2);
|
||||
expect(unwrap(ds.output)).toEqual([c, b2, b1]);
|
||||
expect(ds.getOutput()).toEqual([c, b2, b1]);
|
||||
|
||||
ds.append(a);
|
||||
expect(unwrap(ds.output)).toEqual([c, b2, b1, a]);
|
||||
expect(ds.getOutput()).toEqual([c, b2, b1, a]);
|
||||
|
||||
ds.append(b3);
|
||||
expect(unwrap(ds.output)).toEqual([c, b3, b2, b1, a]);
|
||||
expect(ds.getOutput()).toEqual([c, b3, b2, b1, a]);
|
||||
|
||||
// if we append a new item with existig item, it should end up in the end
|
||||
const b4 = {
|
||||
@@ -381,7 +390,7 @@ test.skip('reverse with sorting', () => {
|
||||
name: 'b4',
|
||||
};
|
||||
ds.append(b4);
|
||||
expect(unwrap(ds.output)).toEqual([c, b4, b3, b2, b1, a]);
|
||||
expect(ds.getOutput()).toEqual([c, b4, b3, b2, b1, a]);
|
||||
|
||||
// if we replace the middle item, it should end up in the middle
|
||||
const b2r = {
|
||||
@@ -389,7 +398,7 @@ test.skip('reverse with sorting', () => {
|
||||
name: 'b2replacement',
|
||||
};
|
||||
ds.update(2, b2r);
|
||||
expect(unwrap(ds.output)).toEqual([c, b4, b3, b2r, b1, a]);
|
||||
expect(ds.getOutput()).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
|
||||
const b3r = {
|
||||
@@ -397,7 +406,7 @@ test.skip('reverse with sorting', () => {
|
||||
name: 'b3replacement',
|
||||
};
|
||||
ds.update(4, b3r);
|
||||
expect(unwrap(ds.output)).toEqual([c, b4, b2r, b1, b3r, a]);
|
||||
expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]);
|
||||
});
|
||||
|
||||
test('reset', () => {
|
||||
@@ -430,3 +439,129 @@ test('clear', () => {
|
||||
// resets in the same ordering as view preferences were preserved
|
||||
expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]);
|
||||
});
|
||||
|
||||
function testEvents<T>(
|
||||
initial: T[],
|
||||
op: (ds: DataSource<T, any, any>) => void,
|
||||
): any[] {
|
||||
const ds = createDataSource<T>(initial);
|
||||
const events: any[] = [];
|
||||
ds.setOutputChangeListener((e) => events.push(e));
|
||||
op(ds);
|
||||
ds.setOutputChangeListener(undefined);
|
||||
return events;
|
||||
}
|
||||
|
||||
test('it emits the right events - zero window', () => {
|
||||
expect(
|
||||
testEvents(['a', 'b'], (ds) => {
|
||||
ds.append('c');
|
||||
ds.update(1, 'x');
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
delta: 1,
|
||||
index: 2,
|
||||
location: 'after',
|
||||
newCount: 3,
|
||||
type: 'shift',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it emits the right events - small window', () => {
|
||||
expect(
|
||||
testEvents(['a', 'b'], (ds) => {
|
||||
ds.setWindow(0, 3);
|
||||
ds.append('c');
|
||||
ds.update(1, 'x');
|
||||
}),
|
||||
).toEqual([
|
||||
{delta: 1, location: 'in', newCount: 3, type: 'shift', index: 2},
|
||||
{index: 1, type: 'update'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it emits the right events - view change', () => {
|
||||
expect(
|
||||
testEvents(['a', 'b'], (ds) => {
|
||||
ds.setWindow(1, 2);
|
||||
ds.setSortBy((x) => x);
|
||||
// a, [b]
|
||||
ds.update(0, 'x');
|
||||
// b, [x]
|
||||
expect(ds.getItem(0)).toEqual('b');
|
||||
expect(ds.getItem(1)).toEqual('x');
|
||||
ds.append('y');
|
||||
// b, [x], y
|
||||
ds.append('c');
|
||||
// b, [c], x, y
|
||||
}),
|
||||
).toEqual([
|
||||
{newCount: 2, type: 'reset'},
|
||||
{index: 0, delta: -1, location: 'before', newCount: 1, type: 'shift'}, // remove a
|
||||
{index: 1, delta: 1, location: 'in', newCount: 2, type: 'shift'}, // pre-insert x
|
||||
{index: 2, delta: 1, location: 'after', newCount: 3, type: 'shift'}, // y happened after
|
||||
{index: 1, delta: 1, location: 'in', newCount: 4, type: 'shift'}, // c becomes the 'in' new indow
|
||||
]);
|
||||
});
|
||||
|
||||
test('it emits the right events - reversed view change', () => {
|
||||
expect(
|
||||
testEvents(['a', 'b'], (ds) => {
|
||||
ds.setWindow(1, 2);
|
||||
ds.setSortBy((x) => x);
|
||||
ds.setReversed(true);
|
||||
// b, [a]
|
||||
ds.update(0, 'x');
|
||||
// x, [b]
|
||||
expect(ds.getItem(0)).toEqual('x');
|
||||
expect(ds.getItem(1)).toEqual('b');
|
||||
ds.append('y');
|
||||
// y, [x], b
|
||||
ds.append('c');
|
||||
// y, [x], c, b
|
||||
ds.append('a');
|
||||
// y, [x], c, b, a
|
||||
}),
|
||||
).toEqual([
|
||||
{newCount: 2, type: 'reset'},
|
||||
{newCount: 2, type: 'reset'}, // FIXME: ideally dedupe these, but due to scheduling will do little harm
|
||||
{index: 1, delta: -1, location: 'in', newCount: 1, type: 'shift'}, // remove a
|
||||
{index: 0, delta: 1, location: 'before', newCount: 2, type: 'shift'}, // pre-insert x
|
||||
{index: 0, delta: 1, location: 'before', newCount: 3, type: 'shift'},
|
||||
{index: 2, delta: 1, location: 'after', newCount: 4, type: 'shift'},
|
||||
{index: 4, delta: 1, location: 'after', newCount: 5, type: 'shift'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it emits the right events - reversed view change with filter', () => {
|
||||
expect(
|
||||
testEvents(['a', 'b'], (ds) => {
|
||||
ds.setWindow(0, 2);
|
||||
ds.setSortBy((x) => x);
|
||||
ds.setReversed(true);
|
||||
ds.setFilter((x) => ['a', 'b'].includes(x));
|
||||
// [b, a]
|
||||
ds.update(0, 'x');
|
||||
// [b, ]
|
||||
expect(ds.getItem(0)).toEqual('b');
|
||||
expect(ds.output.length).toBe(1);
|
||||
ds.append('y');
|
||||
// [b, ]
|
||||
ds.append('c');
|
||||
// [b, ]
|
||||
ds.append('a');
|
||||
// [b, a]
|
||||
ds.append('a');
|
||||
// [b, a, a] // N.b. the new a is in the *middle*
|
||||
}),
|
||||
).toEqual([
|
||||
{newCount: 2, type: 'reset'},
|
||||
{newCount: 2, type: 'reset'}, // FIXME: ideally dedupe these, but due to scheduling will do little harm
|
||||
{newCount: 2, type: 'reset'}, // FIXME: ideally dedupe these, but due to scheduling will do little harm
|
||||
{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'},
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user