Skip to content

Commit

Permalink
feat(signals): add option to type errors for withCalls, withCallStatu…
Browse files Browse the repository at this point in the history
…s and withEntitiesLoadingCall

Changes to allow typing the error withCalls, withCallStatus and withEntitiesLoadingCall, also a new
mapError to allow transforming the error before storing it

Fix #77
  • Loading branch information
Gabriel Guerrero authored and gabrielguerrero committed May 21, 2024
1 parent b85cb43 commit 5d3cc51
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ const productsStoreFeature = signalStoreFeature(
entity: productsEntity,
collection: productsCollection,
}),
withCallStatus({ initialValue: 'loading', collection: productsCollection }),
withCallStatus({
initialValue: 'loading',
collection: productsCollection,
errorType: type<string>(),
}),
withEntitiesRemoteFilter({
entity: productsEntity,
collection: productsCollection,
Expand Down Expand Up @@ -132,6 +136,7 @@ export const ProductsShopStore = signalStore(
);
return { entities: res.resultList, total: res.total };
},
mapError: (error) => (error as Error).message,
}),
withCalls(({ orderItemsEntities }, snackBar = inject(MatSnackBar)) => ({
loadProductDetail: ({ id }: { id: string }) =>
Expand All @@ -151,6 +156,12 @@ export const ProductsShopStore = signalStore(
duration: 5000,
});
},
mapError: (error) => (error as Error).message,
onError: (error) => {
snackBar.open(error, 'Close', {
duration: 5000,
});
},
}),
})),
withMethods(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,34 @@ export type CallStatus = 'init' | 'loading' | 'loaded' | { error: unknown };
export type CallStatusState = {
callStatus: CallStatus;
};
export type CallStatusComputed = {
export type CallStatusComputed<Error = unknown> = {
isLoading: Signal<boolean>;
} & {
isLoaded: Signal<boolean>;
} & {
error: Signal<string | null>;
error: Signal<Error | undefined>;
};
export type CallStatusMethods = {
export type CallStatusMethods<Error = any> = {
setLoading: () => void;
} & {
setLoaded: () => void;
} & {
setError: (error?: unknown) => void;
setError: (error?: Error) => void;
};
export type NamedCallStatusState<Prop extends string> = {
[K in Prop as `${K}CallStatus`]: CallStatus;
};
export type NamedCallStatusComputed<Prop extends string> = {
export type NamedCallStatusComputed<Prop extends string, Error = unknown> = {
[K in Prop as `is${Capitalize<string & K>}Loading`]: Signal<boolean>;
} & {
[K in Prop as `is${Capitalize<string & K>}Loaded`]: Signal<boolean>;
} & {
[K in Prop as `${K}Error`]: Signal<string | null>;
[K in Prop as `${K}Error`]: Signal<Error | null>;
};
export type NamedCallStatusMethods<Prop extends string> = {
export type NamedCallStatusMethods<Prop extends string, Error = any> = {
[K in Prop as `set${Capitalize<string & K>}Loading`]: () => void;
} & {
[K in Prop as `set${Capitalize<string & K>}Loaded`]: () => void;
} & {
[K in Prop as `set${Capitalize<string & K>}Error`]: (error?: unknown) => void;
[K in Prop as `set${Capitalize<string & K>}Error`]: (error?: Error) => void;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { signalStore } from '@ngrx/signals';
import { signalStore, type } from '@ngrx/signals';

import { withCallStatus } from '../index';

Expand Down Expand Up @@ -28,6 +28,17 @@ describe('withCallStatus', () => {
const store = new Store();
expect(store.isLoading()).toEqual(true);
});

it('setError should make error return the object set with typed error', () => {
const Store = signalStore(
withCallStatus({ errorType: type<'error1' | 'error2'>() }),
);
const store = new Store();
expect(store.error()).toEqual(undefined);
store.setError('error1');
expect(store.error() === 'error1').toBeTruthy();
});

it('check prop rename works', () => {
const Store = signalStore(withCallStatus({ prop: 'test' }));
const store = new Store();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getWithCallStatusKeys } from './with-call-status.util';
* @param config.prop - The name of the property for which this represents the call status
* @param config.initialValue - The initial value of the call status
* @param config.collection - The name of the collection for which this represents the call status is an alias to prop param
* @param config.errorType - The type of the error
* they do the same thing
*
* prop or collection is required
Expand All @@ -33,7 +34,7 @@ import { getWithCallStatusKeys } from './with-call-status.util';
* withCallStatus({ prop: 'users', })
* // other valid configurations
* // withCallStatus()
* // withCallStatus({ collection: 'users', initialValue: 'loading' })
* // withCallStatus({ collection: 'users', initialValue: 'loading' , errorType: type<string>()})
* )
*
* // generates the following signals
Expand All @@ -47,14 +48,15 @@ import { getWithCallStatusKeys } from './with-call-status.util';
* store.setUsersLoaded // () => void
* store.setUsersError // (error?: unknown) => void
*/
export function withCallStatus(config?: {
export function withCallStatus<Error = unknown>(config?: {
initialValue?: CallStatus;
errorType?: Error;
}): SignalStoreFeature<
{ state: {}; signals: {}; methods: {} },
{
state: CallStatusState;
signals: CallStatusComputed;
methods: CallStatusMethods;
signals: CallStatusComputed<Error>;
methods: CallStatusMethods<Error>;
}
>;

Expand All @@ -64,6 +66,7 @@ export function withCallStatus(config?: {
* @param config.prop - The name of the property for which this represents the call status
* @param config.initialValue - The initial value of the call status
* @param config.collection - The name of the collection for which this represents the call status is an alias to prop param
* @param config.errorType - The type of the error
* they do the same thing
*
* prop or collection is required
Expand All @@ -72,7 +75,7 @@ export function withCallStatus(config?: {
* withCallStatus({ prop: 'users', })
* // other valid configurations
* // withCallStatus()
* // withCallStatus({ collection: 'users', initialValue: 'loading' })
* // withCallStatus({ collection: 'users', initialValue: 'loading' , errorType: type<string>()})
* )
*
* // generates the following signals
Expand All @@ -86,22 +89,24 @@ export function withCallStatus(config?: {
* store.setUsersLoaded // () => void
* store.setUsersError // (error?: unknown) => void
*/
export function withCallStatus<Prop extends string>(
export function withCallStatus<Prop extends string, Error = unknown>(
config?:
| {
prop: Prop;
initialValue?: CallStatus;
errorType?: Error;
}
| {
collection: Prop;
initialValue?: CallStatus;
errorType?: Error;
},
): SignalStoreFeature<
{ state: {}; signals: {}; methods: {} },
{
state: NamedCallStatusState<Prop>;
signals: NamedCallStatusComputed<Prop>;
methods: NamedCallStatusMethods<Prop>;
signals: NamedCallStatusComputed<Prop, Error>;
methods: NamedCallStatusMethods<Prop, Error>;
}
>;
export function withCallStatus<Prop extends string>({
Expand Down
25 changes: 23 additions & 2 deletions libs/ngrx-traits/signals/src/lib/with-calls/with-calls.model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Signal } from '@angular/core';
import { Observable } from 'rxjs';

export type Call<Params extends readonly any[] = any[], Result = any> = (
Expand All @@ -7,13 +8,15 @@ export type CallConfig<
Params extends readonly any[] = any[],
Result = any,
PropName extends string = string,
Error = any,
> = {
call: Call<Params, Result>;
resultProp: PropName;
mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap';
storeResult?: boolean;
onSuccess?: (result: Result, ...params: Params) => void;
onError?: (error: unknown, ...params: Params) => void;
onSuccess?: (result: Result, param: Params[0]) => void;
mapError?: (error: unknown, param: Params[0]) => Error;
onError?: (error: Error, param: Params[0]) => void;
};
export type ExtractCallResultType<T extends Call | CallConfig> =
T extends Call<any, infer R>
Expand All @@ -23,3 +26,21 @@ export type ExtractCallResultType<T extends Call | CallConfig> =
: never;
export type ExtractCallParams<T extends Call | CallConfig> =
T extends Call<infer P> ? P : T extends CallConfig<infer P> ? P : [];

export type NamedCallsStatusComputed<Prop extends string> = {
[K in Prop as `is${Capitalize<string & K>}Loading`]: Signal<boolean>;
} & {
[K in Prop as `is${Capitalize<string & K>}Loaded`]: Signal<boolean>;
};
export type NamedCallsStatusErrorComputed<
Calls extends Record<string, Call | CallConfig>,
> = {
[K in keyof Calls as `${K & string}Error`]: Calls[K] extends CallConfig<
any,
any,
any,
infer Error
>
? Signal<Error | undefined>
: Signal<unknown | undefined>;
};
24 changes: 24 additions & 0 deletions libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,30 @@ describe('withCalls', () => {
expect(onError).toHaveBeenCalledWith(new Error('fail'), { ok: false });
});
});
it('Fail on a call should set status return error with correct type if mapError is used ', async () => {
TestBed.runInInjectionContext(() => {
const Store = signalStore(
withState({ foo: 'bar' }),
withCalls(() => ({
testCall2: typedCallConfig({
call: ({ ok }: { ok: boolean }) => {
return ok ? apiResponse : throwError(() => new Error('fail'));
},
mapError: (error, { ok }) => (error as Error).message + ' ' + ok,
resultProp: 'result',
onSuccess,
onError,
}),
})),
);
const store = new Store();
expect(store.isTestCall2Loading()).toBeFalsy();
store.testCall2({ ok: false });
expect(store.testCall2Error()).toEqual('fail false');
expect(store.result()).toBe(undefined);
expect(onError).toHaveBeenCalledWith('fail false', { ok: false });
});
});
it('Successful call of a no parameters method and resultProp, should set status to loading and loaded ', async () => {
TestBed.runInInjectionContext(() => {
const Store = signalStore(
Expand Down
26 changes: 19 additions & 7 deletions libs/ngrx-traits/signals/src/lib/with-calls/with-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
CallConfig,
ExtractCallParams,
ExtractCallResultType,
NamedCallsStatusComputed,
NamedCallsStatusErrorComputed,
} from './with-calls.model';
import { getWithCallKeys } from './with-calls.util';

Expand All @@ -67,6 +69,9 @@ import { getWithCallKeys } from './with-calls.util';
* onSuccess: (result, callParam) => {
* // do something with the result
* },
* mapError: (error, callParam) => {
* return // transform the error before storing it
* }
* onError: (error, callParam) => {
* // do something with the error
* },
Expand All @@ -89,7 +94,7 @@ import { getWithCallKeys } from './with-calls.util';
* store.loadProductDetailError // string | null
* store.isCheckoutLoading // boolean
* store.isCheckoutLoaded // boolean
* store.checkoutError // string | null
* store.checkoutError // unknown | null
* // generates the following methods
* store.loadProductDetail // ({id: string} | Signal<{id: string}> | Observable<{id: string}>) => void
* store.checkout // () => void
Expand Down Expand Up @@ -119,7 +124,8 @@ export function withCalls<
: Calls[K]['resultProp'] & string
: `${K & string}Result`]: ExtractCallResultType<Calls[K]> | undefined;
};
signals: NamedCallStatusComputed<keyof Calls & string>;
signals: NamedCallsStatusComputed<keyof Calls & string> &
NamedCallsStatusErrorComputed<Calls>;
methods: {
[K in keyof Calls]: ExtractCallParams<Calls[K]> extends []
? { (): void }
Expand Down Expand Up @@ -244,10 +250,14 @@ export function withCalls<
}),
first(),
catchError((error: unknown) => {
setError(error);
const e =
(isCallConfig(call) &&
call.mapError?.(error, params)) ||
error;
setError(e);
isCallConfig(call) &&
call.onError &&
call.onError(error, params);
call.onError(e, params);
return of();
}),
);
Expand Down Expand Up @@ -279,13 +289,15 @@ export function typedCallConfig<
Params extends readonly any[] = any[],
Result = any,
PropName extends string = '',
C extends CallConfig<Params, Result, PropName> = CallConfig<
Error = unknown,
C extends CallConfig<Params, Result, PropName, Error> = CallConfig<
Params,
Result,
PropName
PropName,
Error
>,
>(
config: Omit<CallConfig<Params, Result, PropName>, 'resultProp'> & {
config: Omit<CallConfig<Params, Result, PropName, Error>, 'resultProp'> & {
resultProp?: PropName;
},
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,36 @@ describe('withEntitiesLoadingCall', () => {
expect(onSuccess).not.toHaveBeenCalled();
});
}));

it('should call setError and onError if fetchEntities call fails with correct type if mapError is used', fakeAsync(() => {
const onSuccess = jest.fn();
const onError = jest.fn();
TestBed.runInInjectionContext(() => {
const Store = signalStore(
withEntities({
entity,
}),
withCallStatus({ errorType: type<string>() }),
withEntitiesLoadingCall({
fetchEntities: () => {
return throwError(() => new Error('fail'));
},
onSuccess,
mapError: (error) => (error as Error).message,
onError,
}),
);
const store = new Store();
TestBed.flushEffects();
expect(store.entities()).toEqual([]);
store.setLoading();
tick();
expect(store.entities()).toEqual([]);
expect(store.error()).toEqual('fail');
expect(onError).toHaveBeenCalledWith('fail');
expect(onSuccess).not.toHaveBeenCalled();
});
}));
});

describe('with collection set[Collection]Loading should call fetch entities', () => {
Expand Down
Loading

0 comments on commit 5d3cc51

Please sign in to comment.