@journeyapps-labs/lib-reactor-data-layer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ import { IObservableArray, observable } from 'mobx';
2
+ import { BaseObserver } from '@journeyapps-labs/lib-reactor-utils';
3
+
4
+ export interface CollectionListener {
5
+ cleared: () => any;
6
+ }
7
+
8
+ export interface LoadEvent {
9
+ aborted: boolean;
10
+ }
11
+
12
+ export class Collection<T> extends BaseObserver<CollectionListener> {
13
+ @observable
14
+ accessor items: IObservableArray<T>;
15
+
16
+ @observable
17
+ accessor loading: boolean;
18
+
19
+ @observable
20
+ accessor failed: boolean;
21
+
22
+ protected promise: Promise<T[]>;
23
+
24
+ constructor() {
25
+ super();
26
+ this.items = [] as IObservableArray<T>;
27
+ this.loading = false;
28
+ this.failed = false;
29
+ }
30
+
31
+ clear() {
32
+ this.loading = false;
33
+ this.failed = false;
34
+ this.items.clear();
35
+ this.iterateListeners((cb) => cb.cleared?.());
36
+ }
37
+
38
+ async load(loader: (event: LoadEvent) => Promise<T[]>) {
39
+ if (this.promise) {
40
+ return this.promise;
41
+ }
42
+ const loadEvent: LoadEvent = {
43
+ aborted: false
44
+ };
45
+ this.loading = true;
46
+ this.promise = loader(loadEvent);
47
+ const l = this.registerListener({
48
+ cleared: () => {
49
+ loadEvent.aborted = true;
50
+ }
51
+ });
52
+ try {
53
+ const items = await this.promise;
54
+ if (!items || loadEvent.aborted) {
55
+ return;
56
+ }
57
+ this.items.replace(items);
58
+ } catch (ex) {
59
+ this.failed = true;
60
+ throw ex;
61
+ } finally {
62
+ l();
63
+ this.loading = false;
64
+ this.promise = null;
65
+ }
66
+ return this.items;
67
+ }
68
+ }
@@ -0,0 +1,85 @@
1
+ import { Collection } from './Collection';
2
+ import { autorun, computed, IReactionDisposer, makeObservable, observable } from 'mobx';
3
+ import * as _ from 'lodash';
4
+
5
+ export interface LifecycleModel<Serialized> {
6
+ key: string;
7
+
8
+ dispose();
9
+
10
+ patch(data: Serialized);
11
+ }
12
+
13
+ export interface LifecycleCollectionOptions<Serialized, Model extends LifecycleModel<Serialized>> {
14
+ collection: Collection<Serialized>;
15
+ generateModel: (d: Serialized) => Model;
16
+ getKeyForSerialized: (s: Serialized) => string;
17
+ }
18
+
19
+ /**
20
+ * Wraps an existing collection with data-to-model conversion, and is smart about creating / patching and deleting
21
+ * models based on how the wrapping collection loads items
22
+ */
23
+ export class LifecycleCollection<Serialized, Model extends LifecycleModel<Serialized>> {
24
+ @observable
25
+ accessor _items: Map<string, Model>;
26
+
27
+ reaction: IReactionDisposer;
28
+
29
+ constructor(protected options: LifecycleCollectionOptions<Serialized, Model>) {
30
+ this._items = new Map();
31
+ this.reaction = autorun(
32
+ () => {
33
+ const items = options.collection.items.reduce((prev, cur) => {
34
+ const key = options.getKeyForSerialized(cur);
35
+ if (!key) {
36
+ console.error(`Missing key for lifecycle item, check that the 'get key()' accessor is implemented.`);
37
+ return prev;
38
+ }
39
+ prev[options.getKeyForSerialized(cur)] = cur;
40
+ return prev;
41
+ }, {});
42
+ const incomingKeys = Object.keys(items);
43
+ let existingKeys = Array.from(this._items.keys());
44
+
45
+ _.difference(existingKeys, incomingKeys).forEach((m) => {
46
+ if (this._items.has(m)) {
47
+ this._items.get(m).dispose();
48
+ this._items.delete(m);
49
+ }
50
+ });
51
+
52
+ _.intersection(incomingKeys, existingKeys).forEach((key: string) => {
53
+ this._items.get(key)?.patch(items[key]);
54
+ });
55
+
56
+ _.difference(incomingKeys, existingKeys).map((k) => {
57
+ const model = options.generateModel(items[k]);
58
+ this._items.set(model.key, model);
59
+ });
60
+ },
61
+ {
62
+ name: 'LifecycleCollection'
63
+ }
64
+ );
65
+ }
66
+
67
+ get collection() {
68
+ return this.options.collection;
69
+ }
70
+
71
+ @computed get items(): Model[] {
72
+ return Array.from(this._items.values());
73
+ }
74
+
75
+ @computed get loading() {
76
+ return this.options.collection.loading;
77
+ }
78
+
79
+ dispose() {
80
+ this.reaction();
81
+ this.items.forEach((i) => {
82
+ i.dispose();
83
+ });
84
+ }
85
+ }
@@ -0,0 +1,150 @@
1
+ import { autorun, IReactionDisposer, observable } from 'mobx';
2
+ import { Collection } from './Collection';
3
+ import { SearchResult, SearchResultEntry } from '@journeyapps-labs/lib-reactor-search';
4
+
5
+ export interface PaginatedCollectionOptions<T, R> {
6
+ transformer: (res: R) => T[];
7
+ loaderIterator?: () => Promise<AsyncIterator<R>> | AsyncIterator<R>;
8
+ hasMore?: (res: R) => boolean;
9
+ }
10
+
11
+ export interface AsSearchResultOptions<T> {
12
+ idTransformer: (item: T) => string;
13
+ match: (item: T) => boolean;
14
+ }
15
+
16
+ export interface PaginatedSearchResultEntry<T = any> extends SearchResultEntry {
17
+ item: T;
18
+ }
19
+
20
+ export class PaginatedSearchResult<T extends any> extends SearchResult<PaginatedSearchResultEntry<T>> {
21
+ observer: PaginatedCollection;
22
+ disposer: IReactionDisposer;
23
+
24
+ constructor(observer: PaginatedCollection) {
25
+ super();
26
+ this.observer = observer;
27
+ this.disposer = autorun(() => {
28
+ this.loading = observer ? observer.loading : false;
29
+ });
30
+ }
31
+
32
+ dispose() {
33
+ super.dispose();
34
+ this.disposer?.();
35
+ }
36
+
37
+ hasMore() {
38
+ if (!this.observer) {
39
+ return false;
40
+ }
41
+ return this.observer.hasMore;
42
+ }
43
+
44
+ loadMore() {
45
+ if (!this.observer) {
46
+ return;
47
+ }
48
+ return this.observer.loadMore();
49
+ }
50
+ }
51
+
52
+ export class PaginatedCollection<T = any, R = any> extends Collection<T> {
53
+ @observable
54
+ protected accessor lastResponse: R;
55
+
56
+ protected iterator: AsyncIterator<R>;
57
+ protected options: PaginatedCollectionOptions<T, R>;
58
+
59
+ public hasMore: boolean;
60
+
61
+ constructor(options: PaginatedCollectionOptions<T, R>) {
62
+ super();
63
+ this.lastResponse = null;
64
+ this.iterator = null;
65
+ this.options = options;
66
+ }
67
+
68
+ getData(res: R) {
69
+ return this.options.transformer(res);
70
+ }
71
+
72
+ async loadAll(
73
+ load?: () => Promise<AsyncIterator<R>> | AsyncIterator<R>,
74
+ options?: { abort: AbortController }
75
+ ): Promise<any> {
76
+ const loader = load ?? this.options.loaderIterator;
77
+ this.hasMore = await this.loadInitialData(loader);
78
+ while (!options?.abort.signal.aborted && this.hasMore) {
79
+ this.hasMore = await this.loadMore();
80
+ }
81
+ }
82
+
83
+ clear() {
84
+ this.lastResponse = null;
85
+ this.hasMore = false;
86
+ this.iterator = null;
87
+ super.clear();
88
+ }
89
+
90
+ async loadMore(): Promise<boolean> {
91
+ if (!this.iterator) {
92
+ return false;
93
+ }
94
+ this.hasMore = false;
95
+ await this.load(async (event) => {
96
+ const res = await this.iterator.next();
97
+ if (event.aborted || res.done || !res.value) {
98
+ return this.items;
99
+ }
100
+ this.hasMore = res.value.more;
101
+ this.lastResponse = res.value;
102
+ return this.items.concat(this.getData(res.value));
103
+ });
104
+ return this.hasMore;
105
+ }
106
+
107
+ async loadInitialData(load?: () => Promise<AsyncIterator<R>> | AsyncIterator<R>): Promise<boolean> {
108
+ const loader = load ?? this.options.loaderIterator;
109
+
110
+ await this.load(async (event) => {
111
+ this.iterator = await loader();
112
+ const next = await this.iterator.next();
113
+ if (event.aborted || next.done || !next.value) {
114
+ return [];
115
+ }
116
+ this.lastResponse = next.value;
117
+ this.hasMore = this.options.hasMore(this.lastResponse);
118
+ return this.getData(this.lastResponse);
119
+ });
120
+ return this.hasMore;
121
+ }
122
+
123
+ asSearchResult(options: AsSearchResultOptions<T>): PaginatedSearchResult<T> {
124
+ const result = new PaginatedSearchResult<T>(this);
125
+ const runner = autorun(() => {
126
+ if (!this.lastResponse) {
127
+ return;
128
+ }
129
+ result.setValues(
130
+ this.items
131
+ .filter((d) => options.match(d))
132
+ .map((d) => {
133
+ return {
134
+ item: d,
135
+ key: options.idTransformer(d)
136
+ };
137
+ })
138
+ );
139
+ });
140
+ result.registerListener({
141
+ dispose: () => {
142
+ runner();
143
+ }
144
+ });
145
+
146
+ // also load the entries
147
+ this.loadMore();
148
+ return result;
149
+ }
150
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './PaginatedCollection';
2
+ export * from './Collection';
3
+ export * from './LifecycleCollection';
@@ -0,0 +1,92 @@
1
+ import { expect, it } from 'vitest';
2
+ import { Collection } from '../src/Collection';
3
+ import { configure } from 'mobx';
4
+
5
+ configure({
6
+ enforceActions: 'never'
7
+ });
8
+
9
+ const sleep = (s: number) =>
10
+ new Promise<void>((resolve) => {
11
+ setTimeout(resolve, s);
12
+ });
13
+
14
+ it('Should load and clear collections', async () => {
15
+ let collection = new Collection<string>();
16
+
17
+ expect(collection.loading).toEqual(false);
18
+ expect(collection.failed).toEqual(false);
19
+ expect(collection.items).toEqual([]);
20
+
21
+ collection.load(async (event) => {
22
+ await sleep(200);
23
+ expect(event.aborted).toEqual(false);
24
+ return ['test1'];
25
+ });
26
+
27
+ expect(collection.loading).toEqual(true);
28
+ expect(collection.failed).toEqual(false);
29
+ expect(collection.items).toEqual([]);
30
+
31
+ await sleep(500);
32
+ expect(collection.loading).toEqual(false);
33
+ expect(collection.failed).toEqual(false);
34
+ expect(collection.items).toEqual(['test1']);
35
+
36
+ collection.clear();
37
+ expect(collection.loading).toEqual(false);
38
+ expect(collection.failed).toEqual(false);
39
+ expect(collection.items).toEqual([]);
40
+
41
+ expect(collection._listenerSize).toEqual(0);
42
+ });
43
+
44
+ it('Should abort collections', async () => {
45
+ let collection = new Collection<string>();
46
+
47
+ collection.load(async (event) => {
48
+ await sleep(200);
49
+ expect(event.aborted).toEqual(true);
50
+ return ['test1'];
51
+ });
52
+
53
+ expect(collection.loading).toEqual(true);
54
+ expect(collection.failed).toEqual(false);
55
+ expect(collection.items).toEqual([]);
56
+
57
+ collection.clear();
58
+ await sleep(500);
59
+ expect(collection.loading).toEqual(false);
60
+ expect(collection.failed).toEqual(false);
61
+ expect(collection.items).toEqual([]);
62
+
63
+ expect(collection._listenerSize).toEqual(0);
64
+ });
65
+
66
+ it('Should rethrow errors when loading data after updating state', async () => {
67
+ let collection = new Collection<string>();
68
+ let ex: Error;
69
+
70
+ collection
71
+ .load(async (event) => {
72
+ await sleep(200);
73
+ throw new Error('test');
74
+ })
75
+ .catch((_ex) => {
76
+ ex = _ex;
77
+ });
78
+
79
+ expect(collection.loading).toEqual(true);
80
+ expect(collection.failed).toEqual(false);
81
+ expect(collection.items).toEqual([]);
82
+
83
+ collection.clear();
84
+ await sleep(500);
85
+ expect(collection.loading).toEqual(false);
86
+ expect(collection.failed).toEqual(true);
87
+ expect(collection.items).toEqual([]);
88
+
89
+ expect(collection._listenerSize).toEqual(0);
90
+ expect(ex).toBeTruthy();
91
+ expect(ex.message).toEqual('test');
92
+ });
@@ -0,0 +1,114 @@
1
+ import { expect, it } from 'vitest';
2
+ import { Collection } from '../src/Collection';
3
+ import { LifecycleCollection, LifecycleModel } from '../src';
4
+ import { autorun, configure, observable } from 'mobx';
5
+
6
+ configure({
7
+ enforceActions: 'never'
8
+ });
9
+
10
+ export interface DummySerialized {
11
+ id: string;
12
+ label: string;
13
+ }
14
+
15
+ class DummyModel implements LifecycleModel<DummySerialized> {
16
+ @observable
17
+ accessor ser: DummySerialized;
18
+
19
+ disposed: boolean;
20
+
21
+ constructor(ser: DummySerialized) {
22
+ this.ser = ser;
23
+ this.disposed = false;
24
+ }
25
+
26
+ get key() {
27
+ return this.ser.id;
28
+ }
29
+
30
+ dispose() {
31
+ this.disposed = true;
32
+ }
33
+
34
+ patch(data: DummySerialized) {
35
+ this.ser = data;
36
+ }
37
+ }
38
+
39
+ it('Should correctly manage lifecycle', async () => {
40
+ let generated: DummyModel[] = [];
41
+
42
+ let collection = new Collection<DummySerialized>();
43
+ let lifecycle_collection = new LifecycleCollection<DummySerialized, DummyModel>({
44
+ collection: collection,
45
+ generateModel: (ser: DummySerialized) => {
46
+ const c = new DummyModel(ser);
47
+ generated.push(c);
48
+ return c;
49
+ },
50
+ getKeyForSerialized: (ser) => {
51
+ return ser.id;
52
+ }
53
+ });
54
+
55
+ expect(collection.items.length).toEqual(0);
56
+ expect(lifecycle_collection.items.length).toEqual(0);
57
+
58
+ let items = [];
59
+ autorun(() => {
60
+ items = [...lifecycle_collection.items];
61
+ });
62
+
63
+ await collection.load(async () => {
64
+ return [
65
+ {
66
+ id: '1',
67
+ label: 'a'
68
+ }
69
+ ];
70
+ });
71
+
72
+ expect(collection.items.length).toEqual(1);
73
+ expect(lifecycle_collection.items.length).toEqual(1);
74
+ expect(items.length).toEqual(1);
75
+ expect(lifecycle_collection.items[0].disposed).toEqual(false);
76
+ expect(generated.length).toEqual(1);
77
+
78
+ // test patching
79
+ await collection.load(async () => {
80
+ return [
81
+ {
82
+ id: '1',
83
+ label: 'b'
84
+ }
85
+ ];
86
+ });
87
+
88
+ expect(collection.items.length).toEqual(1);
89
+ expect(lifecycle_collection.items.length).toEqual(1);
90
+ expect(items.length).toEqual(1);
91
+ expect(items[0].ser.label).toEqual('b');
92
+ expect(lifecycle_collection.items[0].ser.label).toEqual('b');
93
+ expect(lifecycle_collection.items[0].disposed).toEqual(false);
94
+ expect(generated.length).toEqual(1);
95
+
96
+ // create new item, old item should be disposed
97
+ await collection.load(async () => {
98
+ return [
99
+ {
100
+ id: '2',
101
+ label: 'c'
102
+ }
103
+ ];
104
+ });
105
+
106
+ expect(collection.items.length).toEqual(1);
107
+ expect(lifecycle_collection.items.length).toEqual(1);
108
+ expect(items.length).toEqual(1);
109
+ expect(lifecycle_collection.items[0].ser.label).toEqual('c');
110
+ expect(items[0].ser.label).toEqual('c');
111
+ expect(lifecycle_collection.items[0].disposed).toEqual(false);
112
+ expect(generated[0].disposed).toEqual(true);
113
+ expect(generated.length).toEqual(2);
114
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "declarationDir": "dist/@types",
5
+ "rootDir": "src",
6
+ "outDir": "dist"
7
+ },
8
+ "include": [
9
+ "src/**/*.ts",
10
+ "src/**/*.tsx",
11
+ "src/**/*.json"
12
+ ],
13
+ "references": [
14
+ {
15
+ "path": "../lib-reactor-search"
16
+ },
17
+ {
18
+ "path": "../lib-reactor-utils"
19
+ }
20
+ ]
21
+ }