add entity store class (#70)
This commit is contained in:
8108
package-lock.json
generated
8108
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@most/adapter": "^1.0.0",
|
||||
"@most/core": "^1.6.1",
|
||||
"@most/hold": "^4.1.0",
|
||||
"@most/scheduler": "^1.3.0",
|
||||
"axios": "^0.21.0",
|
||||
"convert-layout": "^0.8.2",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {makeApi} from '_utils/makeApi';
|
||||
import {http} from '_infrastructure/Http';
|
||||
|
||||
type User = {
|
||||
export type User = {
|
||||
id: number;
|
||||
avatar: string;
|
||||
email: string;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {v4} from 'uuid';
|
||||
import {TaskStatus} from '_enums/common';
|
||||
import {Task} from '_types/common';
|
||||
import {createService} from '_utils/createService';
|
||||
import {createStore} from '_utils/createStore';
|
||||
import {makeLocalStorageService} from './LocalStorageService';
|
||||
|
||||
const TASK_STORAGE_NAME = 'FYB_TASK_STORAGE';
|
||||
@ -32,5 +32,5 @@ const INIT_TASKS: Task[] = [
|
||||
|
||||
const taskListService = makeLocalStorageService(INIT_TASKS, TASK_STORAGE_NAME);
|
||||
|
||||
export const tasksService = createService(taskListService.get(), {
|
||||
export const tasksService = createStore(taskListService.get(), {
|
||||
});
|
||||
|
||||
4
src/core/types/LiveData.ts
Normal file
4
src/core/types/LiveData.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import {RemoteData} from '@devexperts/remote-data-ts';
|
||||
import {Stream} from '@most/types';
|
||||
|
||||
export type LiveData<A, B> = Stream<RemoteData<A, B>>;
|
||||
@ -1,8 +1,9 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {RemoteData, fold, map as remoteDateMap} from '@devexperts/remote-data-ts';
|
||||
import {RemoteData, fold, map as remoteDataMap, isSuccess} from '@devexperts/remote-data-ts';
|
||||
import {Stream} from '@most/types';
|
||||
import {map} from '@most/core';
|
||||
import {chain, map, now, tap} from '@most/core';
|
||||
import {pipe} from 'fp-ts/lib/pipeable';
|
||||
import {LiveData} from '_types/LiveData';
|
||||
|
||||
export const renderAsyncData = <E, A>(
|
||||
data: RemoteData<E, A>,
|
||||
@ -20,7 +21,32 @@ export const mapRD = <E, A, R>(mapper: (val: A) => R) => {
|
||||
return (stream$: Stream<RemoteData<E, A>>): Stream<RemoteData<E, R>> => {
|
||||
return pipe(
|
||||
stream$,
|
||||
map(val => remoteDateMap(mapper)(val))
|
||||
map(val => remoteDataMap(mapper)(val))
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const tapRD = <E, A>(mapper: (val: A) => void) => {
|
||||
return (stream$: Stream<RemoteData<E, A>>) => {
|
||||
return pipe(
|
||||
stream$,
|
||||
tap(val => remoteDataMap(mapper)(val))
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const chainRD = <E, A, R>(chainer: (val: A) => Stream<RemoteData<E, R>>) => {
|
||||
return (stream$: Stream<RemoteData<E, A>>) => {
|
||||
return pipe(
|
||||
stream$,
|
||||
chain(rVal => {
|
||||
if (isSuccess(rVal)) {
|
||||
return chainer(rVal.value);
|
||||
}
|
||||
const res: LiveData<E, R> = now(rVal);
|
||||
|
||||
return res;
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@ -13,7 +13,7 @@ type ServiceActions<State, T extends Record<string, ServiceAction<State, unknown
|
||||
};
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const createService = <State, Actions extends Record<string, ServiceAction<State, any>>>(
|
||||
export const createStore = <State, Actions extends Record<string, ServiceAction<State, any>>>(
|
||||
initData: State,
|
||||
actions: Actions
|
||||
) => {
|
||||
23
src/core/utils/createSubject.ts
Normal file
23
src/core/utils/createSubject.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {createAdapter} from '@most/adapter';
|
||||
import {Stream} from '@most/types';
|
||||
import {hold} from '@most/hold';
|
||||
|
||||
export type Subject<T> = {
|
||||
stream$: Stream<T>;
|
||||
next: (val: T) => void;
|
||||
getValue: () => T;
|
||||
}
|
||||
export const createSubject = <T>(data: T): Subject<T> => {
|
||||
let cache = data;
|
||||
|
||||
const [handler, stream$] = createAdapter<T>();
|
||||
|
||||
return {
|
||||
next: (val: T) => {
|
||||
cache = val;
|
||||
handler(val);
|
||||
},
|
||||
stream$: hold(stream$),
|
||||
getValue: () => cache
|
||||
};
|
||||
};
|
||||
149
src/core/utils/entity-store.ts
Normal file
149
src/core/utils/entity-store.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import {RemoteData, remoteData, isSuccess, success} from '@devexperts/remote-data-ts';
|
||||
import {filter, map, skipRepeats, chain, tap, now} from '@most/core';
|
||||
import {hold} from '@most/hold';
|
||||
import {newDefaultScheduler} from '@most/scheduler';
|
||||
import {array} from 'fp-ts/lib/Array';
|
||||
import {Predicate} from 'fp-ts/lib/function';
|
||||
import {pipe} from 'fp-ts/pipeable';
|
||||
import {noop} from 'lodash';
|
||||
|
||||
import {isNotEmpty} from '_referers/common';
|
||||
import {LiveData} from '_types/LiveData';
|
||||
|
||||
import {chainRD, mapRD, tapRD} from './asyncDataUtils';
|
||||
import {StreamMap} from './streamMap';
|
||||
|
||||
export class EntityStore<L = never, A = never> {
|
||||
get allValues$() {
|
||||
return this._getAllValues$;
|
||||
}
|
||||
|
||||
set allValues$(value: any) {
|
||||
this._getAllValues$ = value;
|
||||
}
|
||||
|
||||
private readonly cache = new StreamMap<string, RemoteData<L, A>>();
|
||||
|
||||
private readonly cachedStreams = new Map<string, LiveData<L, A>>();
|
||||
|
||||
private hasLoadedAll = false;
|
||||
|
||||
private isLoadingAll = false;
|
||||
|
||||
private _getAllValues$ = pipe(
|
||||
this.cache.values$,
|
||||
filter(() => !this.isLoadingAll && this.hasLoadedAll),
|
||||
map(data => data.filter(item => isSuccess(item))),
|
||||
map(array.sequence(remoteData)),
|
||||
skipRepeats,
|
||||
hold
|
||||
);
|
||||
|
||||
readonly keys$ = this.cache.keys$;
|
||||
|
||||
get(key: string, get: () => LiveData<L, A>): LiveData<L, A> {
|
||||
let sharedGetter = this.cachedStreams.get(key);
|
||||
|
||||
if (!isNotEmpty(sharedGetter)) {
|
||||
const hasValue = this.cache.has(key);
|
||||
const cachedValue = this.cache.getValue(key);
|
||||
const valueIsResolved = isNotEmpty(cachedValue) && isSuccess(cachedValue);
|
||||
if (hasValue && valueIsResolved) {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
sharedGetter = pipe(get(), hold);
|
||||
|
||||
this.cachedStreams.set(key, sharedGetter);
|
||||
}
|
||||
|
||||
return sharedGetter;
|
||||
}
|
||||
|
||||
getAll(
|
||||
personalKey: (value: A) => string,
|
||||
partialGetAll: () => LiveData<L, A[]>,
|
||||
predicate?: Predicate<A>
|
||||
): LiveData<L, A[]> {
|
||||
this.isLoadingAll = false;
|
||||
return pipe(
|
||||
partialGetAll(),
|
||||
tapRD(values => {
|
||||
this.hasLoadedAll = true;
|
||||
this.updateCache(values, personalKey);
|
||||
}),
|
||||
chain(data => {
|
||||
this.cache.values$.run(
|
||||
{
|
||||
event: noop,
|
||||
end: noop,
|
||||
error: noop
|
||||
},
|
||||
newDefaultScheduler()
|
||||
);
|
||||
return isSuccess(data) ? this._getAllValues$ : now(data);
|
||||
}),
|
||||
skipRepeats,
|
||||
mapRD(entities => {
|
||||
if (typeof predicate === 'undefined') {
|
||||
return entities;
|
||||
}
|
||||
let hasChanges = false;
|
||||
const filtered = entities.filter(value => {
|
||||
const result = predicate(value);
|
||||
if (!result) {
|
||||
hasChanges = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return hasChanges ? filtered : entities;
|
||||
}),
|
||||
hold
|
||||
);
|
||||
}
|
||||
|
||||
remove(
|
||||
key: string,
|
||||
pk: (value: A) => string,
|
||||
remove: () => LiveData<L, A[]>,
|
||||
optimistic = true
|
||||
): LiveData<L, A[]> {
|
||||
if (optimistic) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
return pipe(
|
||||
remove(),
|
||||
tapRD(values => {
|
||||
this.updateCache(values, pk);
|
||||
}),
|
||||
chain(() => this._getAllValues$)
|
||||
);
|
||||
}
|
||||
|
||||
create(personalKey: (value: A) => string, create: () => LiveData<L, A>): LiveData<L, A> {
|
||||
return pipe(
|
||||
create(),
|
||||
chainRD(value => {
|
||||
const key = personalKey(value);
|
||||
this.cache.set(key, success(value));
|
||||
return this.cache.get(key);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update(key: string, update: () => LiveData<L, A>): LiveData<L, A> {
|
||||
return pipe(
|
||||
update(),
|
||||
tap(value => {
|
||||
if (isSuccess(value)) {
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private updateCache(values: A[], pk: (value: A) => string): void {
|
||||
const entries = values.map<[string, RemoteData<L, A>]>(item => [pk(item), success(item)]);
|
||||
this.cache.setMany(entries);
|
||||
}
|
||||
}
|
||||
185
src/core/utils/streamMap.ts
Normal file
185
src/core/utils/streamMap.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import {skipRepeats, filter, map} from '@most/core';
|
||||
import {hold} from '@most/hold';
|
||||
import {Stream} from '@most/types';
|
||||
import {pipe} from 'fp-ts/pipeable';
|
||||
|
||||
import {createSubject, Subject} from '_utils/createSubject';
|
||||
|
||||
import {isNotEmpty} from '../referers/common';
|
||||
|
||||
type UninitializedEntity<V> = {
|
||||
hasValue: false;
|
||||
subject: Subject<V | undefined>;
|
||||
stream: Stream<V>;
|
||||
};
|
||||
|
||||
type InitializedEntity<V> = {
|
||||
hasValue: true;
|
||||
subject: Subject<V>;
|
||||
stream: Stream<V>;
|
||||
};
|
||||
|
||||
type StreamMapEntity<V> = UninitializedEntity<V> | InitializedEntity<V>;
|
||||
|
||||
type InitializedEntry<K, V> = [K, InitializedEntity<V>];
|
||||
type StreamMapEntry<K, V> = [K, StreamMapEntity<V>];
|
||||
|
||||
export class StreamMap<K, V> {
|
||||
private cache = new Map<K, StreamMapEntity<V>>();
|
||||
|
||||
private allSubject$ = createSubject<void>(undefined);
|
||||
|
||||
private isInTransaction = false;
|
||||
|
||||
private hasChanges = false;
|
||||
|
||||
private _keys$ = createSubject<K[]>([]);
|
||||
|
||||
readonly keys$ = this._keys$.stream$;
|
||||
|
||||
readonly values$ = pipe(this.allSubject$.stream$,
|
||||
map(() => {
|
||||
const values = Array.from(this.cache.values());
|
||||
return values.filter(isInitialized).map(entity => entity.subject.getValue());
|
||||
}),
|
||||
);
|
||||
|
||||
readonly entries$: Stream<[K, V][]> = pipe(
|
||||
this.allSubject$.stream$,
|
||||
map(() => {
|
||||
const entries = Array.from(this.cache.entries()).filter(isEntryInitialized);
|
||||
return entries.map<[K, V]>(entry => [entry[0], entry[1].subject.getValue()]);
|
||||
}),
|
||||
hold,
|
||||
);
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
get isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
private handleKeys = (keys: K[]) => this._keys$.next(keys);
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
get(key: K): Stream<V> {
|
||||
return this.getOrCreateCached(key).stream;
|
||||
}
|
||||
|
||||
getValue(key: K): V | undefined {
|
||||
if (this.cache.has(key)) {
|
||||
const value = this.cache.get(key);
|
||||
if (isNotEmpty(value) && value.hasValue) {
|
||||
return value.subject.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
this.transaction(() => {
|
||||
const isCachedKey = this.cache.has(key);
|
||||
let cached = this.getOrCreateCached(key);
|
||||
if (cached.hasValue === false) {
|
||||
cached = initializeEntity(cached);
|
||||
this.cache.set(key, cached);
|
||||
if (!isCachedKey) {
|
||||
this.handleKeys(Array.from(this.cache.keys()));
|
||||
}
|
||||
}
|
||||
|
||||
if (cached.subject.getValue() !== value) {
|
||||
cached.subject.next(value);
|
||||
this.hasChanges = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setMany(entries: [K, V][]): void {
|
||||
this.transaction(() => {
|
||||
entries.forEach(entry => {
|
||||
const [key, value] = entry;
|
||||
this.set(key, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
transaction(thunk: () => void): void {
|
||||
if (!this.isInTransaction) {
|
||||
this.isInTransaction = true;
|
||||
thunk();
|
||||
if (this.hasChanges) {
|
||||
this.hasChanges = false;
|
||||
this.allSubject$.next(undefined);
|
||||
}
|
||||
this.isInTransaction = false;
|
||||
} else {
|
||||
// Execute the thunk, notifications will be handled by parent transaction
|
||||
thunk();
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: K): void {
|
||||
this.transaction(() => {
|
||||
const isCachedKey = this.cache.has(key);
|
||||
this.cache.delete(key);
|
||||
if (isCachedKey) {
|
||||
this.handleKeys(Array.from(this.cache.keys()));
|
||||
}
|
||||
this.hasChanges = true;
|
||||
});
|
||||
}
|
||||
|
||||
deleteMany(keys: K[]): void {
|
||||
this.transaction(() => {
|
||||
keys.forEach(key => {
|
||||
this.delete(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.transaction(() => {
|
||||
this.cache.clear();
|
||||
this.handleKeys(Array.from(this.cache.keys()));
|
||||
this.hasChanges = true;
|
||||
});
|
||||
}
|
||||
|
||||
private getOrCreateCached(key: K): StreamMapEntity<V> {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const subject = createSubject<V | undefined>(undefined);
|
||||
const stream = pipe(subject.stream$, filter(isNotEmpty), skipRepeats);
|
||||
const entity: StreamMapEntity<V> = {
|
||||
hasValue: false,
|
||||
subject,
|
||||
stream,
|
||||
};
|
||||
this.cache.set(key, entity);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
function initializeEntity<V>(entity: UninitializedEntity<V>): InitializedEntity<V> {
|
||||
return {
|
||||
...entity,
|
||||
subject: entity.subject as Subject<V>,
|
||||
hasValue: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isInitialized<V>(entity: StreamMapEntity<V>): entity is InitializedEntity<V> {
|
||||
return entity.hasValue;
|
||||
}
|
||||
|
||||
function isEntryInitialized<K, V>(entry: StreamMapEntry<K, V>): entry is InitializedEntry<K, V> {
|
||||
return isInitialized(entry[1]);
|
||||
}
|
||||
@ -1,23 +1,27 @@
|
||||
import React, {memo} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import React, {Fragment, memo} from 'react';
|
||||
|
||||
import {usersApi} from '_api/usersTestApi';
|
||||
import {useStream} from '_utils/useStream';
|
||||
|
||||
const userList$ = usersApi.request();
|
||||
import UserComponent from './User';
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const users = useStream(() => userList$, []);
|
||||
|
||||
const users = useStream(() => usersApi.request(), []);
|
||||
return (
|
||||
<div>
|
||||
tags
|
||||
{users?.map(user => (
|
||||
<div key={user.id}>
|
||||
{user.first_name}, {user.last_name}
|
||||
<span><Link to={`/tags/${user.id}`}> More info...</Link></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Fragment>
|
||||
<div>
|
||||
tags
|
||||
{users?.map(user => (
|
||||
<UserComponent userId={user.id} key={user.id}/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
tags
|
||||
{users?.map(user => (
|
||||
<UserComponent userId={user.id} key={user.id}/>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
43
src/pages/tags/components/page/User.tsx
Normal file
43
src/pages/tags/components/page/User.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import {pending, success} from '@devexperts/remote-data-ts';
|
||||
import {map} from '@most/core';
|
||||
import {pipe} from 'fp-ts/lib/function';
|
||||
import React, {FC, Fragment, memo} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
import {usersApi} from '_api/usersTestApi';
|
||||
import {renderAsyncData} from '_utils/asyncDataUtils';
|
||||
import {useStream} from '_utils/useStream';
|
||||
import {userEntityStore} from './utils';
|
||||
|
||||
type Props = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
const User: FC<Props> = ({userId}) => {
|
||||
const data =
|
||||
useStream(() => {
|
||||
const userStringId = userId.toString();
|
||||
|
||||
return userEntityStore.get(userStringId, () =>
|
||||
pipe(
|
||||
usersApi.findById(userStringId),
|
||||
map(val => success(val))
|
||||
)
|
||||
);
|
||||
}, [userId]) ?? pending;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderAsyncData(data, user => (
|
||||
<div key={user.id}>
|
||||
{user.first_name}, {user.last_name}
|
||||
<span>
|
||||
<Link to={`/tags/${user.id}`}> More info...</Link>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(User);
|
||||
4
src/pages/tags/components/page/utils.ts
Normal file
4
src/pages/tags/components/page/utils.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import {User} from '_api/usersTestApi';
|
||||
import {EntityStore} from '_utils/entity-store';
|
||||
|
||||
export const userEntityStore = new EntityStore<Error, User>();
|
||||
Reference in New Issue
Block a user