diff --git a/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts new file mode 100644 index 0000000..2f71488 --- /dev/null +++ b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts @@ -0,0 +1,34 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { PlayerEventTimeslotRegistrationChanged } from '../../../models/realtime-events'; +import { Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { eventEntityAdapter, EventsFeatureState } from '../events.state'; + +export const eventTimeslotRegistrationChangedAction = createAction( + `[${EVENTS_ACTION_SCOPE}] Event Timeslot Registration Changed`, + props() +); + +export const eventTimeslotRegistrationChangedReducers: Reducers = [ + on(eventTimeslotRegistrationChangedAction, (state, event) => + eventEntityAdapter.mapOne( + { + id: event.eventId, + map: produce(draft => { + const timeslot = draft.timeslots.find(t => t.id === event.eventTimeslotId); + if (!timeslot) return; + if (event.isRegistered) { + if (!timeslot.playerIds.includes(event.userId)) { + timeslot.playerIds.push(event.userId); + } + } else { + timeslot.playerIds = timeslot.playerIds.filter(p => p !== event.userId); + } + }), + }, + state + ) + ), +]; diff --git a/src/client/src/app/+state/events/actions/load-event.action.ts b/src/client/src/app/+state/events/actions/load-event.action.ts index e988aac..9d9fdd3 100644 --- a/src/client/src/app/+state/events/actions/load-event.action.ts +++ b/src/client/src/app/+state/events/actions/load-event.action.ts @@ -12,23 +12,24 @@ import { EVENTS_ACTION_SCOPE } from '../consts'; import { selectEventsActionState } from '../events.selectors'; import { EventsFeatureState, eventEntityAdapter } from '../events.state'; -export const loadEventAction = createHttpAction<{ eventId: string; reload?: boolean }, Event>()( - EVENTS_ACTION_SCOPE, - 'Load Event' -); +export const loadEventAction = createHttpAction< + { eventId: string; reload?: boolean; silent?: boolean }, + Event +>()(EVENTS_ACTION_SCOPE, 'Load Event'); export const loadEventReducers: Reducers = [ on(loadEventAction.success, (state, { response }) => eventEntityAdapter.upsertOne(response, state) ), handleHttpAction('loadOne', loadEventAction, { + condition: (s, p) => p.silent !== true, startCondition: (s, p) => !s.entities[p.eventId] || p.reload === true, }), ]; export const loadEventEffects: Effects = { loadEvent$: createFunctionalEffect.dispatching((api = inject(EventAdministrationService)) => - onHttpAction(loadEventAction, selectEventsActionState('loadOne')).pipe( + onHttpAction(loadEventAction, selectEventsActionState('loadOne'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getEvent(api, props), loadEventAction, props)) ) ), diff --git a/src/client/src/app/+state/events/actions/load-events.action.ts b/src/client/src/app/+state/events/actions/load-events.action.ts index d6a053a..c7e989e 100644 --- a/src/client/src/app/+state/events/actions/load-events.action.ts +++ b/src/client/src/app/+state/events/actions/load-events.action.ts @@ -15,10 +15,10 @@ import { selectEventsActionState, selectEventsContinuationToken } from '../event import { EventsFeatureState, eventEntityAdapter } from '../events.state'; type _Response = { events: Event[]; continuationToken: string | null }; -export const loadEventsAction = createHttpAction<{ reload?: boolean }, _Response>()( - EVENTS_ACTION_SCOPE, - 'Load Events' -); +export const loadEventsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + _Response +>()(EVENTS_ACTION_SCOPE, 'Load Events'); export const loadEventsReducers: Reducers = [ on(loadEventsAction.success, (state, { props, response }) => @@ -29,13 +29,13 @@ export const loadEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadEventsAction), + handleHttpAction('load', loadEventsAction, { condition: (s, p) => !p.silent }), ]; export const loadEventsEffects: Effects = { loadEvents$: createFunctionalEffect.dispatching( (store = inject(Store), api = inject(EventAdministrationService)) => - onHttpAction(loadEventsAction, selectEventsActionState('load')).pipe( + onHttpAction(loadEventsAction, selectEventsActionState('load'), p => !!p.props.silent).pipe( withLatestFrom(store.select(selectEventsContinuationToken)), switchMap(([{ props }, continuationToken]) => toHttpAction(getEvents(api, props, continuationToken), loadEventsAction, props) diff --git a/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts b/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts new file mode 100644 index 0000000..f8f11e6 --- /dev/null +++ b/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { EventsFeatureState } from '../events.state'; + +export const resetEventsActionStateAction = createAction( + `[${EVENTS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof EventsFeatureState['actionStates'] }>() +); + +export const resetEventsActionStateReducers: Reducers = [ + on( + resetEventsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/events/events.actions.ts b/src/client/src/app/+state/events/events.actions.ts index 950f5ca..449e847 100644 --- a/src/client/src/app/+state/events/events.actions.ts +++ b/src/client/src/app/+state/events/events.actions.ts @@ -2,6 +2,7 @@ export { addEventPreconfigAction } from './actions/add-event-preconfig.action'; export { addEventTimeslotAction } from './actions/add-event-timeslot.action'; export { addEventAction } from './actions/add-event.action'; export { commitEventAction } from './actions/commit-event.action'; +export { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; export { addPlayerToEventPreconfigurationAction } from './actions/add-player-to-preconfig.action'; export { buildEventInstancesAction } from './actions/build-event-instances.action'; export { loadEventAction } from './actions/load-event.action'; @@ -10,5 +11,6 @@ export { removeEventAction } from './actions/remove-event.action'; export { removeEventPreconfigAction } from './actions/remove-event-preconfig.action'; export { removeEventTimeslotAction } from './actions/remove-event-timeslot.action'; export { removePlayerFromPreconfigAction } from './actions/remove-player-from-preconfig.action'; +export { resetEventsActionStateAction } from './actions/reset-events-action-state.action'; export { startEventAction } from './actions/start-event.action'; export { updateEventTimeslotAction } from './actions/update-event-timeslot.action'; diff --git a/src/client/src/app/+state/events/events.effects.ts b/src/client/src/app/+state/events/events.effects.ts index 579ccb9..0e4502b 100644 --- a/src/client/src/app/+state/events/events.effects.ts +++ b/src/client/src/app/+state/events/events.effects.ts @@ -1,17 +1,26 @@ +import { inject } from '@angular/core'; +import { mergeMap, of, EMPTY, map, merge } from 'rxjs'; + import { addEventPreconfigEffects } from './actions/add-event-preconfig.action'; import { addEventTimeslotEffects } from './actions/add-event-timeslot.action'; import { addEventEffects } from './actions/add-event.action'; import { addPlayerToEventPreconfigurationEffects } from './actions/add-player-to-preconfig.action'; import { buildEventInstancesEffects } from './actions/build-event-instances.action'; import { commitEventEffects } from './actions/commit-event.action'; -import { loadEventEffects } from './actions/load-event.action'; +import { loadEventAction, loadEventEffects } from './actions/load-event.action'; import { loadEventsEffects } from './actions/load-events.action'; import { removeEventPreconfigEffects } from './actions/remove-event-preconfig.action'; import { removeEventTimeslotEffects } from './actions/remove-event-timeslot.action'; -import { removeEventEffects } from './actions/remove-event.action'; +import { removeEventAction, removeEventEffects } from './actions/remove-event.action'; import { removePlayerFromPreconfigEffects } from './actions/remove-player-from-preconfig.action'; import { startEventEffects } from './actions/start-event.action'; import { updateEventTimeslotEffects } from './actions/update-event-timeslot.action'; +import { + eventTimeslotRegistrationChangedAction, + resetEventsActionStateAction, +} from './events.actions'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; export const eventsFeatureEffects: Effects[] = [ @@ -29,4 +38,44 @@ export const eventsFeatureEffects: Effects[] = [ startEventEffects, commitEventEffects, updateEventTimeslotEffects, + { + eventUpdated$: createFunctionalEffect.dispatching((events = inject(RealtimeEventsService)) => + merge( + events.eventChanged, + events.eventTimeslotChanged, + events.eventPreconfigurationChanged, + events.eventInstancesChanged + ).pipe( + mergeMap(event => { + const eventId = event.eventId; + let changeType = 'changeType' in event ? event.changeType : 'updated'; + if (changeType === 'deleted' && 'eventTimeslotId' in event) { + changeType = 'updated'; + } + if (changeType === 'updated' || changeType === 'created') { + return of(loadEventAction({ eventId, reload: true, silent: true })); + } else if (changeType === 'deleted') { + return of(removeEventAction.success({ eventId }, undefined)); + } + return EMPTY; + }) + ) + ), + eventTimeslotRegistrationChanged$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).playerEventTimeslotRegistrationChanged.pipe( + map(event => eventTimeslotRegistrationChangedAction(event)) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + mergeMap(() => + of( + resetEventsActionStateAction({ scope: 'load' }), + resetEventsActionStateAction({ scope: 'loadOne' }) + ) + ) + ) + ), + }, ]; diff --git a/src/client/src/app/+state/events/events.reducer.ts b/src/client/src/app/+state/events/events.reducer.ts index 64a6919..fc116ed 100644 --- a/src/client/src/app/+state/events/events.reducer.ts +++ b/src/client/src/app/+state/events/events.reducer.ts @@ -6,12 +6,14 @@ import { addEventReducers } from './actions/add-event.action'; import { addPlayerToEventPreconfigurationReducers } from './actions/add-player-to-preconfig.action'; import { buildEventInstancesReducers } from './actions/build-event-instances.action'; import { commitEventReducers } from './actions/commit-event.action'; +import { eventTimeslotRegistrationChangedReducers } from './actions/event-timeslot-registration-changed.action'; import { loadEventReducers } from './actions/load-event.action'; import { loadEventsReducers } from './actions/load-events.action'; import { removeEventPreconfigReducers } from './actions/remove-event-preconfig.action'; import { removeEventTimeslotReducers } from './actions/remove-event-timeslot.action'; import { removeEventReducers } from './actions/remove-event.action'; import { removePlayerFromPreconfigReducers } from './actions/remove-player-from-preconfig.action'; +import { resetEventsActionStateReducers } from './actions/reset-events-action-state.action'; import { startEventReducers } from './actions/start-event.action'; import { updateEventTimeslotReducers } from './actions/update-event-timeslot.action'; import { EventsFeatureState, initialEventsFeatureState } from './events.state'; @@ -24,13 +26,15 @@ export const eventsReducer = createReducer( ...addEventReducers, ...addPlayerToEventPreconfigurationReducers, ...buildEventInstancesReducers, + ...commitEventReducers, + ...eventTimeslotRegistrationChangedReducers, ...loadEventReducers, ...loadEventsReducers, ...removeEventPreconfigReducers, ...removeEventTimeslotReducers, ...removeEventReducers, ...removePlayerFromPreconfigReducers, + ...resetEventsActionStateReducers, ...startEventReducers, - ...commitEventReducers, ...updateEventTimeslotReducers ); diff --git a/src/client/src/app/+state/events/events.utils.ts b/src/client/src/app/+state/events/events.utils.ts new file mode 100644 index 0000000..f5345e2 --- /dev/null +++ b/src/client/src/app/+state/events/events.utils.ts @@ -0,0 +1,39 @@ +import { DestroyRef, effect, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadEventsAction, loadEventAction } from './events.actions'; +import { selectEventsActionState } from './events.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepEventsLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store.dispatch(loadEventsAction({ reload: false })); + store + .select(selectEventsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadEventsAction({ reload: true, silent: true }))); +} + +export function keepEventLoaded(eventId: Signal, options?: OptionalInjector) { + const store = injectEx(Store, options); + + effect(() => store.dispatch(loadEventAction({ eventId: eventId(), reload: false })), { + ...options, + allowSignalWrites: true, + }); + + store + .select(selectEventsActionState('loadOne')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => + store.dispatch(loadEventAction({ eventId: eventId(), reload: true, silent: true })) + ); +} diff --git a/src/client/src/app/+state/maps/actions/load-maps.action.ts b/src/client/src/app/+state/maps/actions/load-maps.action.ts index 7f58870..5354db0 100644 --- a/src/client/src/app/+state/maps/actions/load-maps.action.ts +++ b/src/client/src/app/+state/maps/actions/load-maps.action.ts @@ -12,10 +12,10 @@ import { MAPS_ACTION_SCOPE } from '../consts'; import { selectMapsActionState } from '../maps.selectors'; import { mapsEntityAdapter, MapsFeatureState } from '../maps.state'; -export const loadMapsAction = createHttpAction<{ reload?: boolean }, MinigolfMap[]>()( - MAPS_ACTION_SCOPE, - 'Load Maps' -); +export const loadMapsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + MinigolfMap[] +>()(MAPS_ACTION_SCOPE, 'Load Maps'); export const loadMapsReducers: Reducers = [ on(loadMapsAction.success, (state, { props, response }) => @@ -24,12 +24,12 @@ export const loadMapsReducers: Reducers = [ props.reload ? mapsEntityAdapter.removeAll(state) : state ) ), - handleHttpAction('load', loadMapsAction), + handleHttpAction('load', loadMapsAction, { condition: (s, p) => !p.silent }), ]; export const loadMapsEffects: Effects = { loadMaps$: createFunctionalEffect.dispatching((api = inject(MapAdministrationService)) => - onHttpAction(loadMapsAction, selectMapsActionState('load')).pipe( + onHttpAction(loadMapsAction, selectMapsActionState('load'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getMaps(api, props), loadMapsAction, props)) ) ), diff --git a/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts b/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts new file mode 100644 index 0000000..ae66d60 --- /dev/null +++ b/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { MAPS_ACTION_SCOPE } from '../consts'; +import { MapsFeatureState } from '../maps.state'; + +export const resetMapsActionStateAction = createAction( + `[${MAPS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof MapsFeatureState['actionStates'] }>() +); + +export const resetMapsActionStateReducers: Reducers = [ + on( + resetMapsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/maps/actions/reset-maps.action.ts b/src/client/src/app/+state/maps/actions/reset-maps.action.ts deleted file mode 100644 index 44115d3..0000000 --- a/src/client/src/app/+state/maps/actions/reset-maps.action.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createAction, on } from '@ngrx/store'; -import { produce } from 'immer'; - -import { initialActionState } from '../../action-state'; -import { Reducers } from '../../utils'; -import { MAPS_ACTION_SCOPE } from '../consts'; -import { mapsEntityAdapter, MapsFeatureState } from '../maps.state'; - -export const resetMapsAction = createAction(`[${MAPS_ACTION_SCOPE}] Reset`); - -export const resetMapsReducers: Reducers = [ - on(resetMapsAction, state => - mapsEntityAdapter.removeAll( - produce(state, draft => { - draft.actionStates.load = initialActionState; - }) - ) - ), -]; diff --git a/src/client/src/app/+state/maps/maps.actions.ts b/src/client/src/app/+state/maps/maps.actions.ts index 6f68fd3..84d0ef2 100644 --- a/src/client/src/app/+state/maps/maps.actions.ts +++ b/src/client/src/app/+state/maps/maps.actions.ts @@ -2,5 +2,5 @@ export { addMapAction } from './actions/add-map.action'; export { loadMapAction } from './actions/load-map.action'; export { loadMapsAction } from './actions/load-maps.action'; export { removeMapAction } from './actions/remove-map.action'; -export { resetMapsAction } from './actions/reset-maps.action'; +export { resetMapsActionStateAction as resetMapActionStateAction } from './actions/reset-maps-action-state.action'; export { updateMapAction } from './actions/update-map.action'; diff --git a/src/client/src/app/+state/maps/maps.effects.ts b/src/client/src/app/+state/maps/maps.effects.ts index 33f97a6..590623f 100644 --- a/src/client/src/app/+state/maps/maps.effects.ts +++ b/src/client/src/app/+state/maps/maps.effects.ts @@ -1,15 +1,12 @@ import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { Store } from '@ngrx/store'; -import { withLatestFrom, mergeMap, of, EMPTY, skip, filter, map } from 'rxjs'; +import { mergeMap, of, EMPTY, map } from 'rxjs'; import { addMapEffects } from './actions/add-map.action'; import { loadMapAction, loadMapEffects } from './actions/load-map.action'; import { loadMapsEffects } from './actions/load-maps.action'; import { removeMapAction, removeMapEffects } from './actions/remove-map.action'; import { updateMapEffects } from './actions/update-map.action'; -import { resetMapsAction } from './maps.actions'; -import { mapSelectors } from './maps.selectors'; +import { resetMapActionStateAction } from './maps.actions'; import { RealtimeEventsService } from '../../services/realtime-events.service'; import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; @@ -21,13 +18,10 @@ export const mapsFeatureEffects: Effects[] = [ removeMapEffects, updateMapEffects, { - mapUpdated$: createFunctionalEffect.dispatching((store = inject(Store)) => + mapUpdated$: createFunctionalEffect.dispatching(() => inject(RealtimeEventsService).mapChanged.pipe( - withLatestFrom(store.select(mapSelectors.selectEntities)), - mergeMap(([{ mapId, changeType }, entities]) => { - if (changeType === 'updated') { - return mapId in entities ? of(loadMapAction({ mapId })) : EMPTY; - } else if (changeType === 'created') { + mergeMap(({ mapId, changeType }) => { + if (changeType === 'updated' || changeType === 'created') { return of(loadMapAction({ mapId })); } else if (changeType === 'deleted') { return of(removeMapAction.success({ mapId }, undefined)); @@ -37,11 +31,9 @@ export const mapsFeatureEffects: Effects[] = [ ) ), - onServerReconnect$: createFunctionalEffect.dispatching(() => - toObservable(inject(RealtimeEventsService).isConnected).pipe( - skip(1), - filter(x => x), - map(() => resetMapsAction()) + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetMapActionStateAction({ scope: 'load' })) ) ), }, diff --git a/src/client/src/app/+state/maps/maps.reducer.ts b/src/client/src/app/+state/maps/maps.reducer.ts index cbbc9e3..abc3ed9 100644 --- a/src/client/src/app/+state/maps/maps.reducer.ts +++ b/src/client/src/app/+state/maps/maps.reducer.ts @@ -4,7 +4,7 @@ import { addMapReducers } from './actions/add-map.action'; import { loadMapReducers } from './actions/load-map.action'; import { loadMapsReducers } from './actions/load-maps.action'; import { removeMapReducers } from './actions/remove-map.action'; -import { resetMapsReducers } from './actions/reset-maps.action'; +import { resetMapsActionStateReducers } from './actions/reset-maps-action-state.action'; import { updateMapReducers } from './actions/update-map.action'; import { initialMapsFeatureState, MapsFeatureState } from './maps.state'; @@ -15,6 +15,6 @@ export const mapsReducer = createReducer( ...loadMapReducers, ...loadMapsReducers, ...removeMapReducers, - ...resetMapsReducers, + ...resetMapsActionStateReducers, ...updateMapReducers ); diff --git a/src/client/src/app/+state/maps/maps.utils.ts b/src/client/src/app/+state/maps/maps.utils.ts index ee5ddca..e04fb8e 100644 --- a/src/client/src/app/+state/maps/maps.utils.ts +++ b/src/client/src/app/+state/maps/maps.utils.ts @@ -1,26 +1,20 @@ -import { effect, Signal } from '@angular/core'; +import { DestroyRef, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; import { loadMapsAction } from './maps.actions'; import { selectMapsActionState } from './maps.selectors'; import { injectEx, OptionalInjector } from '../../utils/angular.utils'; -export function keepMapsLoaded( - options?: OptionalInjector & { reload?: boolean; enabled?: Signal } -) { +export function keepMapsLoaded(options?: OptionalInjector & { enabled?: Signal }) { const store = injectEx(Store, options); - const actionState = store.selectSignal(selectMapsActionState('load')); - - if (actionState().state !== 'none' && options?.reload) { - store.dispatch(loadMapsAction({ reload: true })); - } - - effect( - () => { - if (options?.enabled?.() !== false && actionState().state === 'none') { - store.dispatch(loadMapsAction({ reload: false })); - } - }, - { ...options, allowSignalWrites: true } - ); + store.dispatch(loadMapsAction({ reload: false })); + store + .select(selectMapsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadMapsAction({ reload: true, silent: true }))); } diff --git a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts index bf77a8b..65cc2fb 100644 --- a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts +++ b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts @@ -18,10 +18,10 @@ import { import { PlayerEventsFeatureState, playerEventEntityAdapter } from '../player-events.state'; type _Response = { events: PlayerEvent[]; continuationToken: string | null }; -export const loadPlayerEventsAction = createHttpAction<{ reload?: boolean }, _Response>()( - PLAYER_EVENTS_ACTION_SCOPE, - 'Load Player Events' -); +export const loadPlayerEventsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + _Response +>()(PLAYER_EVENTS_ACTION_SCOPE, 'Load Player Events'); export const loadPlayerEventsReducers: Reducers = [ on(loadPlayerEventsAction.success, (state, { props, response }) => @@ -32,13 +32,17 @@ export const loadPlayerEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadPlayerEventsAction), + handleHttpAction('load', loadPlayerEventsAction, { condition: (s, p) => !p.silent }), ]; export const loadPlayerEventsEffects: Effects = { loadPlayerEvents$: createFunctionalEffect.dispatching( (store = inject(Store), api = inject(EventsService)) => - onHttpAction(loadPlayerEventsAction, selectPlayerEventsActionState('load')).pipe( + onHttpAction( + loadPlayerEventsAction, + selectPlayerEventsActionState('load'), + p => !!p.props.silent + ).pipe( withLatestFrom(store.select(selectPlayerEventsContinuationToken)), switchMap(([{ props }, continuationToken]) => toHttpAction( diff --git a/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts b/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts new file mode 100644 index 0000000..c3a36b3 --- /dev/null +++ b/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { PLAYER_EVENTS_ACTION_SCOPE } from '../consts'; +import { PlayerEventsFeatureState } from '../player-events.state'; + +export const resetPlayerEventsActionStateAction = createAction( + `[${PLAYER_EVENTS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof PlayerEventsFeatureState['actionStates'] }>() +); + +export const resetPlayerEventsActionStateReducers: Reducers = [ + on( + resetPlayerEventsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/player-events/actions/reset-player-events.action.ts b/src/client/src/app/+state/player-events/actions/reset-player-events.action.ts deleted file mode 100644 index 5f3765a..0000000 --- a/src/client/src/app/+state/player-events/actions/reset-player-events.action.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createAction, on } from '@ngrx/store'; -import { produce } from 'immer'; - -import { initialActionState } from '../../action-state'; -import { Reducers } from '../../utils'; -import { PLAYER_EVENTS_ACTION_SCOPE } from '../consts'; -import { playerEventEntityAdapter, PlayerEventsFeatureState } from '../player-events.state'; - -export const resetPlayerEventsAction = createAction(`[${PLAYER_EVENTS_ACTION_SCOPE}] Reset`); - -export const resetPlayerEventsReducers: Reducers = [ - on(resetPlayerEventsAction, state => - playerEventEntityAdapter.removeAll( - produce(state, draft => { - draft.actionStates.load = initialActionState; - }) - ) - ), -]; diff --git a/src/client/src/app/+state/player-events/player-events.actions.ts b/src/client/src/app/+state/player-events/player-events.actions.ts index 6457dff..bf6dbd4 100644 --- a/src/client/src/app/+state/player-events/player-events.actions.ts +++ b/src/client/src/app/+state/player-events/player-events.actions.ts @@ -1,5 +1,5 @@ export { loadPlayerEventAction } from './actions/load-player-event.action'; export { loadPlayerEventsAction } from './actions/load-player-events.action'; export { playerEventRemovedAction } from './actions/player-event-removed.action'; -export { resetPlayerEventsAction } from './actions/reset-player-events.action'; +export { resetPlayerEventsActionStateAction } from './actions/reset-player-events-action-state.action'; export { updateEventRegistrationAction } from './actions/update-event-registration.action'; diff --git a/src/client/src/app/+state/player-events/player-events.effects.ts b/src/client/src/app/+state/player-events/player-events.effects.ts index f05cb77..a437815 100644 --- a/src/client/src/app/+state/player-events/player-events.effects.ts +++ b/src/client/src/app/+state/player-events/player-events.effects.ts @@ -1,13 +1,13 @@ import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { Store } from '@ngrx/store'; -import { EMPTY, filter, map, mergeMap, of, skip, withLatestFrom } from 'rxjs'; +import { EMPTY, merge, mergeMap, of } from 'rxjs'; import { loadPlayerEventAction, loadPlayerEventEffects } from './actions/load-player-event.action'; import { loadPlayerEventsEffects } from './actions/load-player-events.action'; import { updateEventRegistrationEffects } from './actions/update-event-registration.action'; -import { playerEventRemovedAction, resetPlayerEventsAction } from './player-events.actions'; -import { playerEventSelectors } from './player-events.selectors'; +import { + playerEventRemovedAction, + resetPlayerEventsActionStateAction, +} from './player-events.actions'; import { RealtimeEventsService } from '../../services/realtime-events.service'; import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; @@ -17,39 +17,30 @@ export const PlayerEventsFeatureEffects: Effects[] = [ loadPlayerEventEffects, updateEventRegistrationEffects, { - playerEventUpdated$: createFunctionalEffect.dispatching((store = inject(Store)) => - inject(RealtimeEventsService).playerEventChanged.pipe( - withLatestFrom(store.select(playerEventSelectors.selectEntities)), - mergeMap(([{ eventId, changeType }, entities]) => { - if (changeType === 'updated') { - return eventId in entities - ? of(loadPlayerEventAction({ eventId, reload: true, silent: true })) - : EMPTY; - } else if (changeType === 'created') { - return of(loadPlayerEventAction({ eventId, reload: true, silent: true })); - } else if (changeType === 'deleted') { - return of(playerEventRemovedAction({ eventId })); - } - return EMPTY; - }) - ) - ), - playerEventRegistrationUpdated$: createFunctionalEffect.dispatching((store = inject(Store)) => - inject(RealtimeEventsService).playerEventRegistrationChanged.pipe( - withLatestFrom(store.select(playerEventSelectors.selectEntities)), - mergeMap(([{ eventId }, entities]) => - eventId in entities - ? of(loadPlayerEventAction({ eventId, reload: true, silent: true })) - : EMPTY + playerEventUpdated$: createFunctionalEffect.dispatching( + (events = inject(RealtimeEventsService)) => + merge(events.playerEventChanged, events.playerEventRegistrationChanged).pipe( + mergeMap(event => { + const eventId = event.eventId; + const changeType = 'changeType' in event ? event.changeType : 'updated'; + if (changeType === 'updated' || changeType === 'created') { + return of(loadPlayerEventAction({ eventId, reload: true, silent: true })); + } else if (changeType === 'deleted') { + return of(playerEventRemovedAction({ eventId })); + } + return EMPTY; + }) ) - ) ), - onServerReconnect$: createFunctionalEffect.dispatching(() => - toObservable(inject(RealtimeEventsService).isConnected).pipe( - skip(1), - filter(x => x), - map(() => resetPlayerEventsAction()) + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + mergeMap(() => + of( + resetPlayerEventsActionStateAction({ scope: 'load' }), + resetPlayerEventsActionStateAction({ scope: 'loadOne' }) + ) + ) ) ), }, diff --git a/src/client/src/app/+state/player-events/player-events.reducer.ts b/src/client/src/app/+state/player-events/player-events.reducer.ts index 6ac1698..24b457c 100644 --- a/src/client/src/app/+state/player-events/player-events.reducer.ts +++ b/src/client/src/app/+state/player-events/player-events.reducer.ts @@ -4,7 +4,7 @@ import { produce } from 'immer'; import { loadPlayerEventReducers } from './actions/load-player-event.action'; import { loadPlayerEventsReducers } from './actions/load-player-events.action'; import { playerEventRemovedReducers } from './actions/player-event-removed.action'; -import { resetPlayerEventsReducers } from './actions/reset-player-events.action'; +import { resetPlayerEventsActionStateReducers } from './actions/reset-player-events-action-state.action'; import { updateEventRegistrationReducers } from './actions/update-event-registration.action'; import { PlayerEventsFeatureState, @@ -26,7 +26,7 @@ export const playerEventsReducer = createReducer( ...loadPlayerEventsReducers, ...loadPlayerEventReducers, ...playerEventRemovedReducers, - ...resetPlayerEventsReducers, + ...resetPlayerEventsActionStateReducers, ...updateEventRegistrationReducers, on(addEventAction.success, (state, { response }) => diff --git a/src/client/src/app/+state/player-events/player-events.utils.ts b/src/client/src/app/+state/player-events/player-events.utils.ts new file mode 100644 index 0000000..c095e61 --- /dev/null +++ b/src/client/src/app/+state/player-events/player-events.utils.ts @@ -0,0 +1,39 @@ +import { DestroyRef, effect, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadPlayerEventAction, loadPlayerEventsAction } from './player-events.actions'; +import { selectPlayerEventsActionState } from './player-events.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepPlayerEventsLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store.dispatch(loadPlayerEventsAction({ reload: false })); + store + .select(selectPlayerEventsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadPlayerEventsAction({ reload: true, silent: true }))); +} + +export function keepPlayerEventLoaded(eventId: Signal, options?: OptionalInjector) { + const store = injectEx(Store, options); + + effect(() => store.dispatch(loadPlayerEventAction({ eventId: eventId(), reload: false })), { + ...options, + allowSignalWrites: true, + }); + + store + .select(selectPlayerEventsActionState('loadOne')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => + store.dispatch(loadPlayerEventAction({ eventId: eventId(), reload: true, silent: true })) + ); +} diff --git a/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts b/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts new file mode 100644 index 0000000..760ce1d --- /dev/null +++ b/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { USER_SETTINGS_ACTION_SCOPE } from '../consts'; +import { UserSettingsFeatureState } from '../user-settings.state'; + +export const resetUserSettingsActionStateAction = createAction( + `[${USER_SETTINGS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof UserSettingsFeatureState['actionStates'] }>() +); + +export const resetUserSettingsActionStateReducers: Reducers = [ + on( + resetUserSettingsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/user-settings/actions/reset-user-settings.action.ts b/src/client/src/app/+state/user-settings/actions/reset-user-settings.action.ts deleted file mode 100644 index a31b559..0000000 --- a/src/client/src/app/+state/user-settings/actions/reset-user-settings.action.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createAction, on } from '@ngrx/store'; -import { produce } from 'immer'; - -import { initialActionState } from '../../action-state'; -import { Reducers } from '../../utils'; -import { USER_SETTINGS_ACTION_SCOPE } from '../consts'; -import { UserSettingsFeatureState } from '../user-settings.state'; - -export const resetUserSettingsAction = createAction(`[${USER_SETTINGS_ACTION_SCOPE}] Reset`); - -export const resetUserSettingsReducers: Reducers = [ - on( - resetUserSettingsAction, - produce(state => { - state.settings = undefined; - state.actionStates.load = initialActionState; - }) - ), -]; diff --git a/src/client/src/app/+state/user-settings/user-settings.actions.ts b/src/client/src/app/+state/user-settings/user-settings.actions.ts index 9b9df4d..361b0cd 100644 --- a/src/client/src/app/+state/user-settings/user-settings.actions.ts +++ b/src/client/src/app/+state/user-settings/user-settings.actions.ts @@ -1,3 +1,3 @@ export { loadUserSettingsAction } from './actions/load-user-settings.action'; -export { resetUserSettingsAction } from './actions/reset-user-settings.action'; +export { resetUserSettingsActionStateAction } from './actions/reset-user-settings-action-state.action'; export { updateUserSettingsAction } from './actions/update-user-settings.action'; diff --git a/src/client/src/app/+state/user-settings/user-settings.effects.ts b/src/client/src/app/+state/user-settings/user-settings.effects.ts index 8ed9bbd..281cbf2 100644 --- a/src/client/src/app/+state/user-settings/user-settings.effects.ts +++ b/src/client/src/app/+state/user-settings/user-settings.effects.ts @@ -1,14 +1,13 @@ import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; -import { withLatestFrom, mergeMap, of, EMPTY, filter, map, skip } from 'rxjs'; +import { withLatestFrom, mergeMap, of, EMPTY, map } from 'rxjs'; import { loadUserSettingsAction, loadUserSettingsEffects, } from './actions/load-user-settings.action'; import { updateUserSettingsEffects } from './actions/update-user-settings.action'; -import { resetUserSettingsAction } from './user-settings.actions'; +import { resetUserSettingsActionStateAction } from './user-settings.actions'; import { selectUserSettings } from './user-settings.selectors'; import { RealtimeEventsService } from '../../services/realtime-events.service'; import { createFunctionalEffect } from '../functional-effect'; @@ -30,11 +29,9 @@ export const userSettingsFeatureEffects: Effects[] = [ ) ), - onServerReconnect$: createFunctionalEffect.dispatching(() => - toObservable(inject(RealtimeEventsService).isConnected).pipe( - skip(1), - filter(x => x), - map(() => resetUserSettingsAction()) + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetUserSettingsActionStateAction({ scope: 'load' })) ) ), }, diff --git a/src/client/src/app/+state/user-settings/user-settings.reducer.ts b/src/client/src/app/+state/user-settings/user-settings.reducer.ts index 5c14e58..31ede11 100644 --- a/src/client/src/app/+state/user-settings/user-settings.reducer.ts +++ b/src/client/src/app/+state/user-settings/user-settings.reducer.ts @@ -1,7 +1,7 @@ import { createReducer } from '@ngrx/store'; import { loadUserSettingsReducers } from './actions/load-user-settings.action'; -import { resetUserSettingsReducers } from './actions/reset-user-settings.action'; +import { resetUserSettingsActionStateReducers } from './actions/reset-user-settings-action-state.action'; import { updateUserSettingsReducers } from './actions/update-user-settings.action'; import { UserSettingsFeatureState, initialUserSettingsFeatureState } from './user-settings.state'; @@ -9,6 +9,6 @@ export const userSettingsReducer = createReducer( initialUserSettingsFeatureState, ...loadUserSettingsReducers, - ...resetUserSettingsReducers, + ...resetUserSettingsActionStateReducers, ...updateUserSettingsReducers ); diff --git a/src/client/src/app/+state/user-settings/user-settings.utils.ts b/src/client/src/app/+state/user-settings/user-settings.utils.ts index 2f7c512..d7527e3 100644 --- a/src/client/src/app/+state/user-settings/user-settings.utils.ts +++ b/src/client/src/app/+state/user-settings/user-settings.utils.ts @@ -1,24 +1,20 @@ -import { effect } from '@angular/core'; +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; +import { filter, map } from 'rxjs'; import { loadUserSettingsAction } from './user-settings.actions'; import { selectUserSettingsActionState } from './user-settings.selectors'; import { injectEx, OptionalInjector } from '../../utils/angular.utils'; -export function keepUserSettingsLoaded(options?: OptionalInjector & { reload?: boolean }) { +export function keepUserSettingsLoaded(options?: OptionalInjector) { const store = injectEx(Store, options); - const actionState = store.selectSignal(selectUserSettingsActionState('load')); - - if (actionState().state !== 'none' && options?.reload) { - store.dispatch(loadUserSettingsAction({ reload: true })); - } - - effect( - () => { - if (actionState().state === 'none') { - store.dispatch(loadUserSettingsAction({ reload: false })); - } - }, - { ...options, allowSignalWrites: true } - ); + store + .select(selectUserSettingsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + map((_, index) => index), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(i => store.dispatch(loadUserSettingsAction({ reload: true, silent: i > 0 }))); } diff --git a/src/client/src/app/+state/users/actions/load-users.action.ts b/src/client/src/app/+state/users/actions/load-users.action.ts index 4476528..6f1b385 100644 --- a/src/client/src/app/+state/users/actions/load-users.action.ts +++ b/src/client/src/app/+state/users/actions/load-users.action.ts @@ -12,7 +12,7 @@ import { USERS_ACTION_SCOPE } from '../consts'; import { selectUsersActionState } from '../users.selectors'; import { UsersFeatureState, userEntityAdapter } from '../users.state'; -export const loadUsersAction = createHttpAction<{ reload?: boolean }, User[]>()( +export const loadUsersAction = createHttpAction<{ reload?: boolean; silent?: boolean }, User[]>()( USERS_ACTION_SCOPE, 'Load Users' ); @@ -22,13 +22,14 @@ export const loadUsersReducers: Reducers = [ userEntityAdapter.upsertMany(response, state) ), handleHttpAction('load', loadUsersAction, { + condition: (s, p) => !p.silent, startCondition: (s, p) => s.actionStates.load.state === 'none' || p.reload === true, }), ]; export const loadUsersEffects: Effects = { loadUsers$: createFunctionalEffect.dispatching((api = inject(UserAdministrationService)) => - onHttpAction(loadUsersAction, selectUsersActionState('load')).pipe( + onHttpAction(loadUsersAction, selectUsersActionState('load'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getUsers(api, props), loadUsersAction, props)) ) ), diff --git a/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts b/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts new file mode 100644 index 0000000..baf9e21 --- /dev/null +++ b/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { USERS_ACTION_SCOPE } from '../consts'; +import { UsersFeatureState } from '../users.state'; + +export const resetUsersActionStateAction = createAction( + `[${USERS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof UsersFeatureState['actionStates'] }>() +); + +export const resetUsersActionStateReducers: Reducers = [ + on( + resetUsersActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/users/actions/reset-users.actions.ts b/src/client/src/app/+state/users/actions/reset-users.actions.ts deleted file mode 100644 index a16a071..0000000 --- a/src/client/src/app/+state/users/actions/reset-users.actions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createAction, on } from '@ngrx/store'; -import { produce } from 'immer'; - -import { initialActionState } from '../../action-state'; -import { Reducers } from '../../utils'; -import { USERS_ACTION_SCOPE } from '../consts'; -import { userEntityAdapter, UsersFeatureState } from '../users.state'; - -export const resetUsersAction = createAction(`[${USERS_ACTION_SCOPE}] Reset`); - -export const resetUsersReducers: Reducers = [ - on(resetUsersAction, state => - userEntityAdapter.removeAll( - produce(state, draft => { - draft.actionStates.load = initialActionState; - }) - ) - ), -]; diff --git a/src/client/src/app/+state/users/users.actions.ts b/src/client/src/app/+state/users/users.actions.ts index 1de5374..924f0e4 100644 --- a/src/client/src/app/+state/users/users.actions.ts +++ b/src/client/src/app/+state/users/users.actions.ts @@ -3,5 +3,5 @@ export { loadUserLoginTokenAction } from './actions/load-user-login-token.action export { loadUserAction } from './actions/load-user.action'; export { loadUsersAction } from './actions/load-users.action'; export { removeUserAction } from './actions/remove-user.action'; -export { resetUsersAction } from './actions/reset-users.actions'; +export { resetUsersActionStateAction } from './actions/reset-users-action-state.action'; export { updateUserAction } from './actions/update-user.action'; diff --git a/src/client/src/app/+state/users/users.effects.ts b/src/client/src/app/+state/users/users.effects.ts index f3df64b..c965804 100644 --- a/src/client/src/app/+state/users/users.effects.ts +++ b/src/client/src/app/+state/users/users.effects.ts @@ -1,7 +1,5 @@ import { inject } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { Store } from '@ngrx/store'; -import { EMPTY, filter, map, mergeMap, of, skip, withLatestFrom } from 'rxjs'; +import { EMPTY, map, mergeMap, of } from 'rxjs'; import { addUserEffects } from './actions/add-user.action'; import { loadUserLoginTokenEffects } from './actions/load-user-login-token.action'; @@ -9,8 +7,7 @@ import { loadUserAction, loadUserEffects } from './actions/load-user.action'; import { loadUsersEffects } from './actions/load-users.action'; import { removeUserAction, removeUserEffects } from './actions/remove-user.action'; import { updateUserEffects } from './actions/update-user.action'; -import { resetUsersAction } from './users.actions'; -import { userSelectors } from './users.selectors'; +import { resetUsersActionStateAction } from './users.actions'; import { RealtimeEventsService } from '../../services/realtime-events.service'; import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; @@ -23,13 +20,10 @@ export const usersFeatureEffects: Effects[] = [ removeUserEffects, updateUserEffects, { - userUpdated$: createFunctionalEffect.dispatching((store = inject(Store)) => + userUpdated$: createFunctionalEffect.dispatching(() => inject(RealtimeEventsService).userChanged.pipe( - withLatestFrom(store.select(userSelectors.selectEntities)), - mergeMap(([{ userId, changeType }, entities]) => { - if (changeType === 'updated') { - return userId in entities ? of(loadUserAction({ userId })) : EMPTY; - } else if (changeType === 'created') { + mergeMap(({ userId, changeType }) => { + if (changeType === 'updated' || changeType === 'created') { return of(loadUserAction({ userId })); } else if (changeType === 'deleted') { return of(removeUserAction.success({ userId }, undefined)); @@ -39,11 +33,9 @@ export const usersFeatureEffects: Effects[] = [ ) ), - onServerReconnect$: createFunctionalEffect.dispatching(() => - toObservable(inject(RealtimeEventsService).isConnected).pipe( - skip(1), - filter(x => x), - map(() => resetUsersAction()) + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetUsersActionStateAction({ scope: 'load' })) ) ), }, diff --git a/src/client/src/app/+state/users/users.reducer.ts b/src/client/src/app/+state/users/users.reducer.ts index 1e6b8c1..2a62fd1 100644 --- a/src/client/src/app/+state/users/users.reducer.ts +++ b/src/client/src/app/+state/users/users.reducer.ts @@ -5,7 +5,7 @@ import { loadUserLoginTokenReducers } from './actions/load-user-login-token.acti import { loadUserReducers } from './actions/load-user.action'; import { loadUsersReducers } from './actions/load-users.action'; import { removeUserReducers } from './actions/remove-user.action'; -import { resetUsersReducers } from './actions/reset-users.actions'; +import { resetUsersActionStateReducers } from './actions/reset-users-action-state.action'; import { updateUserReducers } from './actions/update-user.action'; import { UsersFeatureState, initialUsersFeatureState } from './users.state'; @@ -17,6 +17,6 @@ export const usersReducer = createReducer( ...loadUserReducers, ...loadUsersReducers, ...removeUserReducers, - ...resetUsersReducers, + ...resetUsersActionStateReducers, ...updateUserReducers ); diff --git a/src/client/src/app/+state/users/users.utils.ts b/src/client/src/app/+state/users/users.utils.ts index 808dfe1..8a23049 100644 --- a/src/client/src/app/+state/users/users.utils.ts +++ b/src/client/src/app/+state/users/users.utils.ts @@ -1,24 +1,20 @@ -import { effect } from '@angular/core'; +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; import { loadUsersAction } from './users.actions'; import { selectUsersActionState } from './users.selectors'; import { injectEx, OptionalInjector } from '../../utils/angular.utils'; -export function keepUsersLoaded(options?: OptionalInjector & { reload?: boolean }) { +export function keepUsersLoaded(options?: OptionalInjector) { const store = injectEx(Store, options); - const actionState = store.selectSignal(selectUsersActionState('load')); - - if (actionState().state !== 'none' && options?.reload) { - store.dispatch(loadUsersAction({ reload: true })); - } - - effect( - () => { - if (actionState().state === 'none') { - store.dispatch(loadUsersAction({ reload: false })); - } - }, - { ...options, allowSignalWrites: true } - ); + store.dispatch(loadUsersAction({ reload: false })); + store + .select(selectUsersActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadUsersAction({ reload: true, silent: true }))); } diff --git a/src/client/src/app/components/app/menu/menu.component.html b/src/client/src/app/components/app/menu/menu.component.html index 589c1bc..bbf0966 100644 --- a/src/client/src/app/components/app/menu/menu.component.html +++ b/src/client/src/app/components/app/menu/menu.component.html @@ -81,6 +81,3 @@ } } - - - diff --git a/src/client/src/app/components/app/menu/menu.component.ts b/src/client/src/app/components/app/menu/menu.component.ts index 64b1ed4..2678557 100644 --- a/src/client/src/app/components/app/menu/menu.component.ts +++ b/src/client/src/app/components/app/menu/menu.component.ts @@ -9,12 +9,11 @@ import { ButtonModule } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { MenubarModule } from 'primeng/menubar'; import { TooltipModule } from 'primeng/tooltip'; -import { fromEvent } from 'rxjs'; +import { fromEvent, merge, startWith } from 'rxjs'; import { selectAppTitle } from '../../../+state/app'; import { AuthService } from '../../../services/auth.service'; import { RealtimeEventsService } from '../../../services/realtime-events.service'; -import { ThemeService } from '../../../services/theme.service'; import { TranslateService, TranslationKey } from '../../../services/translate.service'; import { chainSignals } from '../../../utils/signal.utils'; @@ -36,11 +35,9 @@ import { chainSignals } from '../../../utils/signal.utils'; export class MenuComponent { private readonly _store = inject(Store); private readonly _translateService = inject(TranslateService); - private readonly _themeService = inject(ThemeService); private readonly _authService = inject(AuthService); private readonly _swUpdate = inject(SwUpdate); - - protected readonly _reService = inject(RealtimeEventsService); + private readonly _realtimeEventsService = inject(RealtimeEventsService); private readonly _versionInfo = toSignal(this._swUpdate.versionUpdates); @@ -101,8 +98,8 @@ export class MenuComponent { constructor() { if (this._swUpdate.isEnabled) { - fromEvent(document, 'visibilitychange') - .pipe(takeUntilDestroyed()) + merge(fromEvent(document, 'visibilitychange'), this._realtimeEventsService.onReconnected$) + .pipe(startWith(null), takeUntilDestroyed()) .subscribe(() => { if (!document.hidden) { console.info('Checking for updates...'); diff --git a/src/client/src/app/components/events/event-details/event-details.component.ts b/src/client/src/app/components/events/event-details/event-details.component.ts index f445f71..f4be5f5 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.ts +++ b/src/client/src/app/components/events/event-details/event-details.component.ts @@ -1,5 +1,5 @@ import { CommonModule, formatDate } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -16,13 +16,13 @@ import { filter, map, timer } from 'rxjs'; import { hasActionFailed, isActionBusy } from '../../../+state/action-state'; import { buildEventInstancesAction, - loadEventAction, removeEventAction, selectEvent, selectEventsActionState, startEventAction, commitEventAction, } from '../../../+state/events'; +import { keepEventLoaded } from '../../../+state/events/events.utils'; import { mapSelectors } from '../../../+state/maps'; import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; import { keepUsersLoaded, userSelectors } from '../../../+state/users'; @@ -112,10 +112,7 @@ export class EventDetailsComponent { constructor() { keepMapsLoaded(); keepUsersLoaded(); - - effect(() => this._store.dispatch(loadEventAction({ eventId: this.eventId(), reload: true })), { - allowSignalWrites: true, - }); + keepEventLoaded(this.eventId); errorToastEffect(this.translations.events_error_delete, selectEventsActionState('remove')); errorToastEffect(this.translations.events_error_start, this.startActionState); diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts index db674e4..833b829 100644 --- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts +++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; @@ -18,7 +18,6 @@ import { map } from 'rxjs'; import { isActionBusy, hasActionFailed } from '../../../+state/action-state'; import { addPlayerToEventPreconfigurationAction, - loadEventAction, removeEventPreconfigAction, removeEventTimeslotAction, removePlayerFromPreconfigAction, @@ -27,6 +26,7 @@ import { selectEventsActionState, } from '../../../+state/events'; import { addEventPreconfigAction } from '../../../+state/events/actions/add-event-preconfig.action'; +import { keepEventLoaded } from '../../../+state/events/events.utils'; import { mapSelectors } from '../../../+state/maps'; import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; import { @@ -129,10 +129,7 @@ export class EventTimeslotComponent { keepMapsLoaded(); keepUsersLoaded(); - - effect(() => this._store.dispatch(loadEventAction({ eventId: this.eventId(), reload: true })), { - allowSignalWrites: true, - }); + keepEventLoaded(this.eventId); errorToastEffect( this.translations.events_error_addPlayerToPreconfig, diff --git a/src/client/src/app/components/events/events.component.ts b/src/client/src/app/components/events/events.component.ts index 73c43b2..0cba4b5 100644 --- a/src/client/src/app/components/events/events.component.ts +++ b/src/client/src/app/components/events/events.component.ts @@ -1,5 +1,5 @@ import { CommonModule, DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, OnInit, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -20,6 +20,7 @@ import { loadEventsAction, selectEventsActionState, } from '../../+state/events'; +import { keepEventsLoaded } from '../../+state/events/events.utils'; import { TranslateService } from '../../services/translate.service'; import { selectSignal } from '../../utils/ngrx.utils'; @@ -42,7 +43,7 @@ import { selectSignal } from '../../utils/ngrx.utils'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class EventsComponent implements OnInit { +export class EventsComponent { private readonly _store = inject(Store); private readonly _router = inject(Router); private readonly _activatedRoute = inject(ActivatedRoute); @@ -68,6 +69,8 @@ export class EventsComponent implements OnInit { ); constructor() { + keepEventsLoaded(); + const actions$ = inject(Actions); actions$ .pipe(takeUntilDestroyed(), ofType(addEventAction.success)) @@ -76,12 +79,6 @@ export class EventsComponent implements OnInit { ); } - public ngOnInit(): void { - if (this.actionState().state === 'none') { - this._store.dispatch(loadEventsAction({ reload: false })); - } - } - protected loadNextPage(): void { this._store.dispatch(loadEventsAction({ reload: false })); } diff --git a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts index 1a67305..0196c41 100644 --- a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts +++ b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts @@ -25,11 +25,11 @@ import { filter, map, Subject, timer } from 'rxjs'; import { FadingMessageComponent } from '../../+common/fading-message.component'; import { hasActionFailed, isActionBusy } from '../../../+state/action-state'; import { - loadPlayerEventAction, updateEventRegistrationAction, selectPlayerEvent, selectPlayerEventsActionState, } from '../../../+state/player-events'; +import { keepPlayerEventLoaded } from '../../../+state/player-events/player-events.utils'; import { ApiEventTimeslotRegistration } from '../../../api/models'; import { ResetNgModelDirective } from '../../../directives/reset-ng-model.directive'; import { PlayerEventTimeslot } from '../../../models/parsed-models'; @@ -142,14 +142,7 @@ export class PlayerEventDetailsComponent { ); constructor() { - effect( - () => { - if (!this.event()) { - this._store.dispatch(loadPlayerEventAction({ eventId: this.eventId() })); - } - }, - { allowSignalWrites: true } - ); + keepPlayerEventLoaded(this.eventId); effect( () => { diff --git a/src/client/src/app/components/player-events/player-events.component.ts b/src/client/src/app/components/player-events/player-events.component.ts index cceead4..a8c521a 100644 --- a/src/client/src/app/components/player-events/player-events.component.ts +++ b/src/client/src/app/components/player-events/player-events.component.ts @@ -8,11 +8,8 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { map, timer } from 'rxjs'; import { hasActionFailed, isActionBusy } from '../../+state/action-state'; -import { - loadPlayerEventsAction, - playerEventSelectors, - selectPlayerEventsActionState, -} from '../../+state/player-events'; +import { playerEventSelectors, selectPlayerEventsActionState } from '../../+state/player-events'; +import { keepPlayerEventsLoaded } from '../../+state/player-events/player-events.utils'; import { InterpolatePipe } from '../../directives/interpolate.pipe'; import { PlayerEvent } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; @@ -57,7 +54,7 @@ export class PlayerEventsComponent { protected readonly hasLoadFailed = computed(() => hasActionFailed(this.loadActionState())); constructor() { - this._store.dispatch(loadPlayerEventsAction({ reload: false })); + keepPlayerEventsLoaded(); } protected getRegisteredTimeslotsCount(event: PlayerEvent) { diff --git a/src/client/src/app/models/realtime-events.ts b/src/client/src/app/models/realtime-events.ts index fef42bb..52c0dd9 100644 --- a/src/client/src/app/models/realtime-events.ts +++ b/src/client/src/app/models/realtime-events.ts @@ -34,3 +34,9 @@ export type PlayerEventRegistrationChangedRealtimeEvent = { eventId: string; }; export type UserSettingsChangedRealtimeEvent = {}; +export type PlayerEventTimeslotRegistrationChanged = { + eventId: string; + eventTimeslotId: string; + userId: string; + isRegistered: boolean; +}; diff --git a/src/client/src/app/services/realtime-events.service.ts b/src/client/src/app/services/realtime-events.service.ts index 160896d..ed9803f 100644 --- a/src/client/src/app/services/realtime-events.service.ts +++ b/src/client/src/app/services/realtime-events.service.ts @@ -1,4 +1,5 @@ import { + computed, effect, EventEmitter, inject, @@ -7,8 +8,9 @@ import { signal, untracked, } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { defer, EMPTY, EmptyError, firstValueFrom } from 'rxjs'; +import { defer, EMPTY, EmptyError, filter, firstValueFrom, map, pairwise, startWith } from 'rxjs'; import { AuthService } from './auth.service'; import { AuthTokenInfo } from './storage'; @@ -22,6 +24,7 @@ import { PlayerEventChangedRealtimeEvent, PlayerEventRegistrationChangedRealtimeEvent, UserSettingsChangedRealtimeEvent, + PlayerEventTimeslotRegistrationChanged, } from '../models/realtime-events'; import { SignalrRetryPolicy } from '../signalr-retry-policy'; import { retryWithPolicy } from '../utils/rxjs.utils'; @@ -35,11 +38,12 @@ const EVENT_PRECONFIGURATION_CHANGED = 'eventPreconfigurationChanged'; const PLAYER_EVENT_CHANGED = 'playerEventChanged'; const PLAYER_EVENT_REGISTRATION_CHANGED = 'playerEventRegistrationChanged'; const USER_SETTINGS_CHANGED = 'userSettingsChanged'; +const PLAYER_EVENT_TIMESLOT_REGISTRATION_CHANGED = 'playerEventTimeslotRegistrationChanged'; @Injectable({ providedIn: 'root' }) export class RealtimeEventsService implements OnDestroy { private readonly _authService = inject(AuthService); - private readonly _isConnected = signal(false); + private readonly _isConnected = signal(null); private _hubConnection?: HubConnection; public readonly userChanged = new EventEmitter(); @@ -53,11 +57,20 @@ export class RealtimeEventsService implements OnDestroy { public readonly playerEventRegistrationChanged = new EventEmitter(); public readonly userSettingsChanged = new EventEmitter(); - public readonly isConnected = this._isConnected.asReadonly(); + public readonly playerEventTimeslotRegistrationChanged = + new EventEmitter(); + public readonly isConnected = computed(() => !!this._isConnected()); + public readonly onReconnected$ = toObservable(this._isConnected).pipe( + startWith(null), + pairwise(), + filter(([p, c]) => p === false && c === true), + map((): void => {}) + ); constructor() { effect(() => { - untracked(() => this.onLoginChanged(this._authService.token())); + const tokenInfo = this._authService.token(); + untracked(() => this.onLoginChanged(tokenInfo)); }); } @@ -103,6 +116,11 @@ export class RealtimeEventsService implements OnDestroy { this.on(connection, PLAYER_EVENT_CHANGED, this.playerEventChanged); this.on(connection, PLAYER_EVENT_REGISTRATION_CHANGED, this.playerEventRegistrationChanged); this.on(connection, USER_SETTINGS_CHANGED, this.userSettingsChanged); + this.on( + connection, + PLAYER_EVENT_TIMESLOT_REGISTRATION_CHANGED, + this.playerEventTimeslotRegistrationChanged + ); connection.onreconnecting(() => this._isConnected.set(false)); connection.onreconnected(() => this._isConnected.set(true)); diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs index 62e057b..f1eeb66 100644 --- a/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs @@ -108,4 +108,18 @@ public record UserSettingsChanged([property: JsonIgnore] string UserId) : IUserR { public static string MethodName => "UserSettingsChanged"; } + + /// Event that is triggered when a player event timeslot registration changed. + public record PlayerEventTimeslotRegistrationChanged( + string EventId, + string EventTimeslotId, + string UserId, + bool IsRegistered + ) : IGroupRealtimeEvent + { + public static string MethodName => "PlayerEventTimeslotRegistrationChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.All; + } } diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs index 6e99b22..b9df269 100644 --- a/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs @@ -2,6 +2,7 @@ namespace MinigolfFriday.Domain.Models.RealtimeEvents; public enum RealtimeEventGroup { + All, Admin, Player } diff --git a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs index 07acbf9..8855a6e 100644 --- a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs +++ b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs @@ -158,5 +158,25 @@ await realtimeEventsService.SendEventAsync( ), ct ); + var changeEvents = registrations + .Where(x => !targetRegistrations.Any(y => y.TimeslotId == x.EventTimeslotId)) + .Select(x => new RealtimeEvent.PlayerEventTimeslotRegistrationChanged( + idService.Event.Encode(eventId), + idService.EventTimeslot.Encode(x.EventTimeslotId), + idService.User.Encode(userId), + false + )) + .Concat( + targetRegistrations + .Where(x => !registrations.Any(y => y.EventTimeslotId == x.TimeslotId)) + .Select(x => new RealtimeEvent.PlayerEventTimeslotRegistrationChanged( + idService.Event.Encode(eventId), + idService.EventTimeslot.Encode(x.TimeslotId), + idService.User.Encode(userId), + true + )) + ); + foreach (var changeEvent in changeEvents) + await realtimeEventsService.SendEventAsync(changeEvent, ct); } } diff --git a/src/server/host/Services/RealtimeEventsService.cs b/src/server/host/Services/RealtimeEventsService.cs index e7ea16d..d870b7b 100644 --- a/src/server/host/Services/RealtimeEventsService.cs +++ b/src/server/host/Services/RealtimeEventsService.cs @@ -14,9 +14,11 @@ public async Task SendEventAsync(T @event, CancellationToken ct = default) { if (@event is IGroupRealtimeEvent groupEvent) { - await hubContext - .Clients.Group(FastEnum.GetName(groupEvent.Group)!) - .SendAsync(T.MethodName, groupEvent, ct); + var group = + groupEvent.Group == RealtimeEventGroup.All + ? hubContext.Clients.All + : hubContext.Clients.Group(FastEnum.GetName(groupEvent.Group)!); + await group.SendAsync(T.MethodName, groupEvent, ct); } else if (@event is IUserRealtimeEvent userEvent) { diff --git a/src/server/host/appsettings.Development.json b/src/server/host/appsettings.Development.json index 6ce9789..cc98e9c 100644 --- a/src/server/host/appsettings.Development.json +++ b/src/server/host/appsettings.Development.json @@ -12,7 +12,7 @@ "Url": "http://0.0.0.0:5000" }, "Https": { - "Url": "https://0.0.0.0:5001" + "Url": "https://0.0.0.0:5004" } } },