diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 319bc1c..356241c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,12 +2,13 @@ - + - - + + + - + + + + + + + + + + + + + + @@ -80,6 +95,11 @@ + + + + + diff --git a/src/App.tsx b/src/App.tsx index 4413a95..df28fff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, {useState} from 'react'; import './App.css'; import {Chat} from "./chat/Chat"; import {StarredMessages} from "./starred-messages/StarredMessages"; -import {Grid} from "@material-ui/core"; +import {Button, Grid} from "@material-ui/core"; import {useUrlSearchParams} from "use-url-search-params"; import {UserLoader} from "./user-loader/UserLoader"; @@ -11,8 +11,9 @@ function randUser() { } function App() { - const [user] = useUrlSearchParams({userId: 0}, {userId: Number}); - const userId: number = Number(user.userId); + // const [user] = useUrlSearchParams({userId: 0}, {userId: Number}); + const [displayUserLoader, setDisplayUserLoader] = useState(true); + // const userId: number = Number(user.userId); return (
{/**/} @@ -24,7 +25,8 @@ function App() { {/* */} {/**/} - + + {displayUserLoader && }
) diff --git a/src/Common.ts b/src/Common.ts index 222fcac..2632e48 100644 --- a/src/Common.ts +++ b/src/Common.ts @@ -1,4 +1,4 @@ -import React, {Dispatch, Reducer, ReducerAction, useEffect} from "react"; +import React, {Dispatch, Reducer, ReducerAction, useEffect, useRef} from "react"; import {fromEvent, merge, Observable} from "rxjs"; import {FromEventTarget} from "rxjs/internal/observable/fromEvent"; @@ -20,25 +20,60 @@ export const feedbackFactory: FeedbackFactory = (state: State) => { } }; +export function useFeedbackSet( + state: State, + query: (state: State) => Set, + effect: (query: Query) => Cleanup +) { + const activeEffects = useRef>(new Map()); + + const newQueries = Array.from(query(state)); + const currentQueries = Array.from(activeEffects.current.keys()); + const queriesToDelete = currentQueries.filter(currentQuery => !newQueries.includes(currentQuery)); + const queriesToAdd = newQueries.filter(newQuery => !currentQueries.includes(newQuery)); + + queriesToDelete.forEach(toDelete => { + const effectToDelete = activeEffects.current.get(toDelete) ?? unsupported("Effect must be present!"); + effectToDelete(); // Run the cleanup function. + activeEffects.current.delete(toDelete); + }); + + queriesToAdd.forEach(toAdd => activeEffects.current.set(toAdd, effect(toAdd))) + + // On unmount clear all the effects. + useEffect(() => { + return () => { + activeEffects.current.forEach(cleanUp => cleanUp()); + activeEffects.current.clear(); + }; + }, []) +} + export const getDispatchContext = () => React.createContext(getIdDispatcher()); const getIdDispatcher: () => Dispatch>> = () => _ => { throw Error("Using ID dispatcher!") }; -export const noop = () => {}; +export const noop = () => { +}; + export function unsupported(msg: string): never { throw new Error(msg); }; + export function unsupportedAction(state: State, action: Action): never { unsupported(`Cannot dispatch action ${JSON.stringify(action)} while state is ${JSON.stringify(state)}`); } + // I initially wrote this as "Symbol("unit")" but this cannot be stringified with JSON.stringify. export const Unit = {}; // export type TypeFromCreator object }> = ReturnType; -export function assertNever(_: never): never { throw Error(); } +export function assertNever(_: never): never { + throw Error(); +} /** * Rx helpers @@ -60,7 +95,7 @@ export function useEventStream( ) { const deps = [JSON.stringify(query)]; useEffect(() => { - if(query === undefined) return; + if (query === undefined) return; const subscription = merge(...events()).subscribe(action => dispatch(action)); return () => subscription.unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/rxjs.test.ts b/src/rxjs.test.ts new file mode 100644 index 0000000..cf37dba --- /dev/null +++ b/src/rxjs.test.ts @@ -0,0 +1,29 @@ +import {Observable, Subject} from "rxjs"; +import {publish, publishReplay, refCount} from "rxjs/operators"; + +test('RxJs', () => { + + let state = 5; + const subject = new Observable(observer => { + // observer.next(state++); + observer.next(state); + observer.error("Greska") + // observer.complete(); + }).pipe( + publish(), + refCount() + ); + + const getObserver = (name: string) => ({ + next: (n: T) => console.log(name + ": " + n), + error: (e: any) => console.log(name + ": " + e), + complete: () => console.log(name + ": " + 'Competed') + }); + + + subject.subscribe(getObserver("A")); + subject.subscribe(getObserver("B")); + subject.subscribe(getObserver("C")); +}); + +export {} diff --git a/src/service/UserService.ts b/src/service/UserService.ts index 3bddb37..30b4fc5 100644 --- a/src/service/UserService.ts +++ b/src/service/UserService.ts @@ -1,19 +1,19 @@ import {User, UserId} from "../model/Model"; import Axios from "axios-observable"; -import {map, publishReplay, refCount, retry, share, tap} from "rxjs/operators"; +import {map, share, tap} from "rxjs/operators"; import {Observable, of} from "rxjs"; import {unsupported} from "../Common"; import LRU from "lru-cache"; +interface UserService { + getUserWithId: (id: UserId) => Observable +} + interface ValueAndRequest { value?: Value request: Observable } -interface UserService { - getUserWithId: (id: UserId) => Observable -} - class UserServiceImpl implements UserService { private static readonly baseUrl = "http://localhost:5000/"; @@ -36,8 +36,7 @@ class UserServiceImpl implements UserService { .pipe( map(response => response.data), tap(user => this.cacheUser(id, user)), - publishReplay(1), - refCount() + share(), ); this.userCache.set(id, { request: userRequest diff --git a/src/setupTests.ts b/src/setupTests.ts index 74b1a27..5fdda8f 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,4 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect'; + diff --git a/src/user-loader/UserLoader.tsx b/src/user-loader/UserLoader.tsx index 8cf2272..d1e3286 100644 --- a/src/user-loader/UserLoader.tsx +++ b/src/user-loader/UserLoader.tsx @@ -1,31 +1,47 @@ import React, {Dispatch, Reducer, useReducer} from "react"; -import {User} from "../model/Model"; -import {feedbackFactory, noop, unsupported} from "../Common"; +import {User, UserId} from "../model/Model"; +import {assertNever, useFeedbackSet} from "../Common"; import userService from "../service/UserService"; import Container from "@material-ui/core/Container"; -import {TableContainer} from "@material-ui/core"; +import {IconButton, LinearProgress, TableContainer} from "@material-ui/core"; import Paper from "@material-ui/core/Paper"; import Table from "@material-ui/core/Table"; import TableHead from "@material-ui/core/TableHead"; import TableRow from "@material-ui/core/TableRow"; import TableCell from "@material-ui/core/TableCell"; import TableBody from "@material-ui/core/TableBody"; - -const randomUserIds = Array.from({length: 30}, () => Math.floor(Math.random() * 5)); - -interface State { - users: Array -} - -const initialState: State = { - users: randomUserIds.map(id => ({id, name: "-"})) -}; +import {catchError, map, switchMap, tap} from "rxjs/operators"; +import {of, timer} from "rxjs"; +import {Alert} from "@material-ui/lab"; +import {Autorenew} from "@material-ui/icons"; export const UserLoader: React.FC = () => { const [state, dispatch] = useReducer(reducer, initialState); useFeedbacks(state, dispatch); + const responseRow = (request: Request) => { + return ( + + + {request.payload} + + + { + request.kind === 'inflight' + ? + : request.kind === 'success' + ? request.response.name + : dispatch(new Retry(request.payload))} + severity="error">{request.message} + } + + + ) + }; return ( + dispatch(new Refresh())}> + + @@ -35,16 +51,7 @@ export const UserLoader: React.FC = () => { - {state.users.map(u => ( - - - {u.id} - - - {u.name} - - - ))} + {state.users.map(r => responseRow(r))}
@@ -52,29 +59,76 @@ export const UserLoader: React.FC = () => { ); }; +// @formatter:off +const randomUserIds = () => Array.from({length: 10}, () => Math.floor(Math.random() * 20)); +const randomUserRequests = () => randomUserIds().map(id => ({kind: 'inflight', payload: id} as Inflight)); + +// This should be generalized. It's useful for all type of requests. +interface Inflight

{ readonly kind: 'inflight', readonly payload: P } +interface Failure

{ readonly kind: 'failure', readonly payload: P, readonly message: string } +interface Success { readonly kind: 'success', readonly payload: P, readonly response: R } +type Request = Inflight

| Success | Failure

-type Action = { user: User } +interface State { users: Array> } +const initialState: State = { + users: randomUserRequests() +}; + +class RequestSucceeded { + kind = 'success' as const; + constructor(readonly user: User, readonly payload: UserId) { } +} +class RequestFailed { + kind = 'failure' as const; + constructor(readonly message: string, readonly payload: UserId) { } +} +class Retry { + kind = 'retry' as const; + constructor(readonly payload: UserId) {} +} +class Refresh { kind = 'refresh' as const; } +// @formatter:on + +type Action = RequestSucceeded | RequestFailed | Retry | Refresh const reducer: Reducer = (state, action) => { - const index = state.users.findIndex(u => u.id === action.user.id && u.name ==="-"); - if (index === -1) unsupported("The user must be in the array!"); - state.users[index] = action.user; - return {users: state.users}; + + const setResponse = (value: Request) => action.kind === 'refresh' + ? state + : {users: state.users.map(r => r.payload === action.payload ? value : r)}; + + switch (action.kind) { + case "refresh": + return {users: randomUserRequests()}; + case "retry": + return setResponse({kind: "inflight", payload: action.payload}); + case "success": + return setResponse({kind: "success", payload: action.payload, response: action.user}); + case "failure": + return setResponse({kind: "failure", payload: action.payload, message: action.message}); + default: + assertNever(action); + } }; const useFeedbacks = (state: State, dispatch: Dispatch) => { - const useFeedback = feedbackFactory(state); - useFeedback( - s => s.users.map(u => u.id), - ids => { - ids.forEach(id => { - setTimeout(_ => { - userService - .getUserWithId(id) - .subscribe(user => dispatch({user})) - }, Math.random() * 10000); - }); - return noop; + useFeedbackSet( + state, + s => new Set(s.users.filter(u => u.kind === "inflight").map(u => u.payload)), + payload => { + const subscription = timer(Math.random() * 10000) + .pipe( + switchMap(_ => userService + .getUserWithId(payload) + .pipe( + map(user => new RequestSucceeded(user, payload)), + catchError(err => of(new RequestFailed(err.response.data.error, payload))) + ) + ) + ) + .subscribe(action => dispatch(action)); + return () => subscription.unsubscribe(); } ) }; +