Skip to content

Commit

Permalink
feat: prototype of with-redux
Browse files Browse the repository at this point in the history
  • Loading branch information
rainerhahnekamp committed Dec 18, 2023
1 parent fd95628 commit 22157c9
Show file tree
Hide file tree
Showing 5 changed files with 315 additions and 25 deletions.
2 changes: 1 addition & 1 deletion libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './lib/with-devtools';
export { withDevtools, patchState, Action } from './lib/with-devtools';
142 changes: 137 additions & 5 deletions libs/ngrx-toolkit/src/lib/with-devtools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { signalStore } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';
import { Action, withDevtools } from 'ngrx-toolkit';
import { TestBed } from '@angular/core/testing';
import { PLATFORM_ID } from '@angular/core';
import SpyInstance = jest.SpyInstance;
import Mock = jest.Mock;
import { reset } from './with-devtools';

type Flight = {
id: number;
Expand All @@ -9,12 +15,138 @@ type Flight = {
delayed: boolean;
};

let currentFlightId = 1;

const createFlight = (flight: Partial<Flight> = {}) => ({
...{
id: ++currentFlightId,
from: 'Vienna',
to: 'London',
date: new Date(2024, 2, 1),
delayed: false,
},
...flight,
});

interface SetupOptions {
extensionsAvailable: boolean;
inSsr: boolean;
}

interface TestData {
store: unknown;
connectSpy: Mock;
sendSpy: SpyInstance;
runEffects: () => void;
}

function run(
fn: (testData: TestData) => void,
options: Partial<SetupOptions> = {}
): any {
return () => {
const defaultOptions: SetupOptions = {
inSsr: false,
extensionsAvailable: true,
};
const realOptions = { ...defaultOptions, ...options };

const sendSpy = jest.fn<void, [Action, Record<string, unknown>]>();
const connection = {
send: sendSpy,
};
const connectSpy = jest.fn(() => connection);
window.__REDUX_DEVTOOLS_EXTENSION__ = { connect: connectSpy };

TestBed.configureTestingModule({
providers: [
{
provide: PLATFORM_ID,
useValue: realOptions.inSsr ? 'server' : 'browser',
},
],
});

if (!realOptions.extensionsAvailable) {
window.__REDUX_DEVTOOLS_EXTENSION__ = undefined;
}

TestBed.runInInjectionContext(() => {
const Store = signalStore(
withEntities<Flight>(),
withDevtools('flights')
);
const store = new Store();
fn({
connectSpy,
sendSpy,
store,
runEffects: () => TestBed.flushEffects(),
});
reset();
});
};
}

describe('Devtools', () => {
it('should not fail if no Redux Devtools are available', () => {
const Flights = signalStore(withEntities<Flight>());
});
it.todo('add a state');
it.todo('add multiple store as feature stores');
it(
'should connection',
run(({ connectSpy }) => {
expect(connectSpy).toHaveBeenCalledTimes(1);
})
);

it(
'should not connect if no Redux Devtools are available',
run(
({ connectSpy }) => {
expect(connectSpy).toHaveBeenCalledTimes(0);
},
{ extensionsAvailable: false }
)
);

it(
'should not connect if it runs on the server',
run(
({ connectSpy }) => {
expect(connectSpy).toHaveBeenCalledTimes(0);
},
{ inSsr: true }
)
);

it(
'should dispatch todo state',
run(({ sendSpy, runEffects }) => {
runEffects();
expect(sendSpy).toHaveBeenCalledWith(
{ type: 'Store Update' },
{ flights: { entityMap: {}, ids: [] } }
);
})
);

it.skip(
'add multiple store as feature stores',
run(({ runEffects, sendSpy }) => {
signalStore(withDevtools('category'));
signalStore(withDevtools('bookings'));
runEffects();
const [, state] = sendSpy.mock.calls[0];
expect(Object.keys(state)).toContainEqual([
'category',
'bookings',
'flights',
]);
})
);

it.todo('should only send when store is initalisaed');
it.todo('should removed state once destroyed');
it.todo('should allow to set name afterwards');
it.todo('should allow to run with names');
it.todo('should provide a patch method with action names');
it.todo('should index store names by default');
it.todo('should fail, if indexing is disabled');
it.todo('should work with a signalStore added lazily, i.e. after a CD cycle');
Expand Down
48 changes: 29 additions & 19 deletions libs/ngrx-toolkit/src/lib/with-devtools.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import {
PartialStateUpdater,
patchState as originalPatchState,
signalState,
signalStore,
signalStoreFeature,
SignalStoreFeature,
withState,
} from '@ngrx/signals';
import { SignalStoreFeatureResult } from '@ngrx/signals/src/signal-store-models';
import { effect, inject, PLATFORM_ID, signal, Signal } from '@angular/core';
import {
effect,
EffectRef,
inject,
PLATFORM_ID,
signal,
Signal,
} from '@angular/core';
import { isPlatformServer } from '@angular/common';

declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION__: {
connect: (options: { name: string }) => {
send: (action: Action) => void;
};
};
__REDUX_DEVTOOLS_EXTENSION__:
| {
connect: (options: { name: string }) => {
send: (action: Action, state: Record<string, unknown>) => void;
};
}
| undefined;
}
}

/**
* `storeRegistry` holds
*/

type EmptyFeatureResult = { state: {}; signals: {}; methods: {} };
type Action = { type: string };
export type Action = { type: string };

const storeRegistry = signal<Record<string, Signal<unknown>>>({});

Expand Down Expand Up @@ -71,10 +71,19 @@ function getStoreSignal(store: unknown): Signal<unknown> {
}

type ConnectResponse = {
send: (action: Action, state: unknown) => void;
send: (action: Action, state: Record<string, unknown>) => void;
};
let connection: ConnectResponse | undefined;

/**
* required for testing. is not exported during build
*/
export function reset() {
connection = undefined;
synchronizationInitialized = false;
storeRegistry.set({});
}

/**
* @param name store's name as it should appear in the DevTools
*/
Expand All @@ -83,12 +92,13 @@ export function withDevtools<Input extends SignalStoreFeatureResult>(
): SignalStoreFeature<Input, EmptyFeatureResult> {
return (store) => {
const isServer = isPlatformServer(inject(PLATFORM_ID));
if (isServer || !window.__REDUX_DEVTOOLS_EXTENSION__) {
const extensions = window.__REDUX_DEVTOOLS_EXTENSION__;
if (isServer || !extensions) {
return store;
}

if (!connection) {
connection = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
connection = extensions.connect({
name: 'NgRx Signal Store',
});
}
Expand Down
45 changes: 45 additions & 0 deletions libs/ngrx-toolkit/src/lib/with-redux.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { signalStore } from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, switchMap } from 'rxjs';
import { createActions, payload, withRedux } from './with-redux';
import { TestBed } from '@angular/core/testing';

type Flight = { id: number };

describe('with redux', () => {
it('should load flights', () => {
const FlightsStore = signalStore(
withRedux({
actions: createActions({
public: {
loadFlights: payload<{ from: string; to: string }>(),
},
private: {
flightsLoaded: payload<{ flights: Flight[] }>(),
},
}),

reducer: (on, actions) => {
on(actions.loadFlights, (action, state) => state);
on(actions.flightsLoaded, (action, state) => state);
},

effects: (actions, create) => {
const httpCLient = inject(HttpClient);

create(actions.loadFlights).pipe(
switchMap(({ from, to }) =>
httpCLient.get<Flight[]>('www.angulararchitects.io', {
params: { from, to },
})
),
map((flights) => actions.flightsLoaded)
);
},
})
);

const flightsStore = new FlightsStore();
});
});
103 changes: 103 additions & 0 deletions libs/ngrx-toolkit/src/lib/with-redux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { map, Observable, of, pipe, switchMap, tap } from 'rxjs';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { signalStore, SignalStoreFeature } from '@ngrx/signals';
import {
EmptyFeatureResult,
SignalStoreFeatureResult,
} from '@ngrx/signals/src/signal-store-models';

type Action = { type: string };

type Actions = Record<string, Action>;

type State = Record<string, unknown>;

type Payload = Record<string, unknown>;

type ActionsSpec = Record<string, Payload>;

type ActionsCreator<Spec extends ActionsSpec> = Extract<
keyof Spec,
'private' | 'public'
> extends never
? {
[ActionName in keyof Spec]: Spec[ActionName] & { type: ActionName };
}
: {
[ActionName in keyof Spec['private']]: Spec['private'][ActionName] & {
type: ActionName;
};
} & {
[ActionName in keyof Spec['public']]: Spec['public'][ActionName] & {
type: ActionName;
};
};

type PublicActions<Spec extends ActionsSpec> = Extract<
keyof Spec,
'private' | 'public'
> extends never
? {
[ActionName in keyof Spec]: Spec[ActionName] & { type: ActionName };
}
: {
[ActionName in keyof Spec['public']]: Spec['public'][ActionName] & {
type: ActionName;
};
};

export function payload<Type extends Payload>(): Type {
return {} as Type;
}

export const noPayload = {};

export declare function createActions<Spec extends ActionsSpec>(
spec: Spec
): ActionsCreator<Spec>;

type ActionsFactory<StateActions extends Actions> = () => StateActions;

type ReducerFn<A extends Action = Action> = (
action: A,
reducerFn: (action: A, state: State) => State
) => void;

type ReducerFactory<A extends Actions> = (on: ReducerFn, actions: A) => void;

type EffectFn<A extends Action = Action> = {
(action: A, effect: (action: Observable<A>) => Observable<Action>): void;
};

type EffectsFactory<StateActions extends Actions> = (
actions: StateActions,
forAction: <EffectAction extends Action>(
action: EffectAction
) => Observable<EffectAction>
) => void;

/**
* @param redux redux
*
* properties do not start with `with` since they are not extension functions on their own.
*
* no dependency to NgRx
*
* actions are passed to reducer and effects, but it is also possible to use other actions.
* effects provide forAction and do not return anything. that is important because effects should stay inaccessible
*/
export declare function withRedux<
Spec extends ActionsSpec,
StateActions extends Actions,
Input extends SignalStoreFeatureResult
>(redux: {
actions: StateActions;
// actions: (actionsCreator: (spec: ActionsSpec) => StateActions) => void;
reducer: ReducerFactory<StateActions>;
effects: EffectsFactory<StateActions>;
}): SignalStoreFeature<
Input,
EmptyFeatureResult & { methods: { actions: () => StateActions } }
>;

0 comments on commit 22157c9

Please sign in to comment.