diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 5958eae0..0a2f41f3 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -16,9 +16,14 @@ import { import { of } from 'rxjs'; import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types'; import { ErrorHandling, mapToResource, withResource } from './with-resource'; +import { + stuffExtendedResourceReadable, + stuffExtendedResourceWritable, +} from './with-resource/tests/util/custom-extended-resources'; import { Address, venice, vienna } from './with-resource/tests/util/fixtures'; import { paramsForResourceTypes } from './with-resource/tests/util/params-for-resource-types'; import { setupUnnamedResource } from './with-resource/tests/util/setup-unnamed-resource'; +import { withPreviousValue } from './with-resource/tests/util/snapshot'; describe('withResource', () => { describe('standard tests', () => { @@ -263,6 +268,237 @@ describe('withResource', () => { }); }); + describe('extra properties checks', () => { + describe('extra properties go to `props` so they cannot be written to', () => { + it('for unnamed writables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceWritable(() => 'a')), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { stuff: 'b stuff' }); + }); + it('for unnamed readables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceReadable(() => 'a')), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { stuff: 'b stuff' }); + }); + it('for named readables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + name: stuffExtendedResourceReadable(() => 'a'), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.nameStuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { nameStuff: 'b stuff' }); + }); + it('for named writables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + name: stuffExtendedResourceWritable(() => 'a'), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.nameStuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { nameStuff: 'b stuff' }); + }); + }); + it('can call custom unnamed writable resource and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceWritable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('b'); + expect(store.stuff()).toBe('b stuff'); + }); + it('can call custom unnamed readable resource and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceReadable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + }); + it('can supply custom named resources (writable and unwritable) and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.idWritableValue()).toBe('a'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('a stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + + patchState(store, { idWritableValue: 'b' }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { idReadableValue: 'b' }); + + expect(store.idWritableValue()).toBe('b'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('b stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + }); + + it('can supply custom unnamed (writable and unwritable) and custom named (writable and unwritable) and extract custom extra signal properties', async () => { + const UnnamedWritableAndNamedComboStore = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceWritable(() => 'a')), + withResource(() => ({ + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), + })), + ); + const unnamedWritableAndNamedComboStore = TestBed.inject( + UnnamedWritableAndNamedComboStore, + ); + + await wait(); + + expect(unnamedWritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedWritableAndNamedComboStore.idWritableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idReadableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idWritableStuff()).toBe( + 'a stuff', + ); + expect(unnamedWritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + patchState(unnamedWritableAndNamedComboStore, { + idWritableValue: 'b', + }); + patchState(unnamedWritableAndNamedComboStore, { value: 'b' }); + patchState(unnamedWritableAndNamedComboStore, { + // @ts-expect-error - readable resources should not have their value patchable + idReadableValue: 'b', + }); + + expect(unnamedWritableAndNamedComboStore.value()).toBe('b'); + expect(unnamedWritableAndNamedComboStore.stuff()).toBe('b stuff'); + expect(unnamedWritableAndNamedComboStore.idWritableValue()).toBe('b'); + expect(unnamedWritableAndNamedComboStore.idReadableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idWritableStuff()).toBe( + 'b stuff', + ); + expect(unnamedWritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + const UnnamedUnwritableAndNamedComboStore = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => stuffExtendedResourceReadable(() => 'a')), + withResource(() => ({ + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), + })), + ); + const unnamedUnwritableAndNamedComboStore = TestBed.inject( + UnnamedUnwritableAndNamedComboStore, + ); + + await wait(); + + expect(unnamedUnwritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedUnwritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedUnwritableAndNamedComboStore.idWritableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idWritableStuff()).toBe( + 'a stuff', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + patchState(unnamedUnwritableAndNamedComboStore, { + idWritableValue: 'b', + }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(unnamedUnwritableAndNamedComboStore, { value: 'b' }); + patchState(unnamedUnwritableAndNamedComboStore, { + // @ts-expect-error - readable resources should not have their value patchable + idReadableValue: 'b', + }); + + expect(unnamedUnwritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedUnwritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedUnwritableAndNamedComboStore.idWritableValue()).toBe( + 'b', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idWritableStuff()).toBe( + 'b stuff', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + }); + }); + describe('override protection', () => { const warningSpy = jest.spyOn(console, 'warn'); @@ -312,6 +548,33 @@ describe('withResource', () => { 'userValue', ); }); + + //TODO wait for https://github.com/ngrx/platform/pull/4932 and then add 'value' to the list + it.each([ + 'status', + 'error', + 'isLoading', + '_reload', + 'hasValue', + 'stuff', + ])( + `warns if %s is not a member of the store with a custom extended resource`, + (memberName) => { + const Store = signalStore( + { providedIn: 'root' }, + withProps(() => ({ [memberName]: true })), + withResource(() => stuffExtendedResourceWritable(() => 'a')), + ); + + TestBed.inject(Store); + + expect(warningSpy).toHaveBeenCalledWith( + '@ngrx/signals: SignalStore members cannot be overridden.', + 'Trying to override:', + memberName, + ); + }, + ); }); it('works also with list/detail use case', async () => { @@ -319,12 +582,14 @@ describe('withResource', () => { { providedIn: 'root', protectedState: false }, withState({ id: undefined as number | undefined }), withResource(({ id }) => ({ - list: httpResource<{ id: number; name: string }[]>( - () => '/address', + list: httpResource< { - defaultValue: [], - }, - ), + id: number; + name: string; + }[] + >(() => '/address', { + defaultValue: [], + }), detail: httpResource
(() => id() ? `/address/${id()}` : undefined, ), @@ -609,6 +874,77 @@ describe('withResource', () => { IsEqual> >; }); + it('only exposes reload methods for reloadable resources', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => ({ + digit: resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + digitSnapshotted: withPreviousValue( + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + })), + withResource(() => + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + withMethods((store) => ({ + namedReload: () => store._digitReload(), + unnamedReload: () => store._reload(), + // @ts-expect-error - a snapshot should not have a reload + invalidReload: () => store._digitSnapshottedReload(), + })), + ); + + const _store = TestBed.inject(Store); + + type _T1 = Assert boolean>>; + type _T2 = Assert boolean>>; + }); + + describe('patchState restrictions do not allow patching', () => { + it('an unnamed non-reloadable resource value', () => { + signalStore( + withResource(() => + withPreviousValue( + resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + ), + ), + withMethods((store) => ({ + // @ts-expect-error - non-reloadable Resource values are not state + setValue: (value: number) => patchState(store, { value }), + })), + ); + }); + + it('a named non-reloadable resource value', () => { + signalStore( + withResource(() => ({ + digitSnapshotted: withPreviousValue( + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + })), + withMethods((store) => ({ + setNamedValue: (value: number) => + // @ts-expect-error digitSnapshotted is a `Resource` value, but not state, so it should not be patchable + patchState(store, { digitSnapshottedValue: value }), + })), + ); + }); + }); describe('mapToResource', () => { it('satisfies the Resource interface without default value', () => { diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 5251935f..0da527bb 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -1,9 +1,11 @@ //** Types for `withResource` */ +import { HttpResourceRef } from '@angular/common/http'; import { isSignal, Resource, ResourceRef, + ResourceSnapshot, ResourceStatus, Signal, untracked, @@ -25,6 +27,19 @@ export type ResourceResult = { status: Signal; error: Signal; isLoading: Signal; + snapshot: Signal>; + }; + methods: { + hasValue(): this is Resource>; + }; +}; +export type ResourceRefResult = { + state: { value: T }; + props: { + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>; }; methods: { hasValue(): this is Resource>; @@ -32,33 +47,136 @@ export type ResourceResult = { }; }; -export type ResourceDictionary = Record>; +type ReloadableResource = ResourceRef | HttpResourceRef; + +type ResourceCoreKeys = + | 'value' + | 'status' + | 'error' + | 'isLoading' + | 'snapshot' + | 'hasValue'; + +type AdditionalSignalProps> = { + [Prop in keyof T as Prop extends ResourceCoreKeys + ? never + : T[Prop] extends Signal + ? Prop + : never]: T[Prop]; +}; + +type InferResourceValue> = + T['value'] extends Signal ? S : never; + +type ConditionalReloadMethod> = + T extends ReloadableResource + ? { _reload(): boolean } + : Record; + +declare const NON_PATCHABLE_RESOURCE_STATE: unique symbol; + +type NonPatchableResourceStateMarker = { + [NON_PATCHABLE_RESOURCE_STATE]?: never; +}; + +type ResourceValueType< + T extends Resource, + HasUndefinedErrorHandling extends boolean, +> = HasUndefinedErrorHandling extends true + ? InferResourceValue | undefined + : InferResourceValue; + +type UnnamedResourceResult< + T extends Resource, + HasUndefinedErrorHandling extends boolean, +> = { + state: T extends ReloadableResource + ? { + value: ResourceValueType; + } + : NonPatchableResourceStateMarker; + props: (T extends ReloadableResource + ? Record + : { + value: Signal>; + }) & { + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>>; + } & AdditionalSignalProps; + methods: { + hasValue(): this is Resource, undefined>>; + } & ConditionalReloadMethod; +}; + +export type ResourceDictionary = Record>; + +// Without this, only one resource would be able to have its additional signal props extracted +// When there are two or more named resources, +// neither additional property can be indexed for certain, +// because it would be a union of the two's additional properties, +// and TS cannot pick the correct one when indexing into it. +// So there would be no resolution of a particular resource's additional property. +// This type util makes this type mapping an intersection, so all additional properties +// of all resources are preserved, but also can be indexed for a particular resource. +// https://stackoverflow.com/a/50375286 explanation, with relevant TS doc links +type UnionToIntersection = ( + T extends unknown ? (arg: T) => void : never +) extends (arg: infer R) => void + ? R + : Record; + +type NamedAdditionalSignalProps = + UnionToIntersection< + { + [ResourceName in keyof T & string]: { + [Prop in keyof AdditionalSignalProps & + string as `${ResourceName}${Capitalize}`]: AdditionalSignalProps< + T[ResourceName] + >[Prop]; + }; + }[keyof T & string] + >; export type NamedResourceResult< T extends ResourceDictionary, HasUndefinedErrorHandling extends boolean, > = { state: { - [Prop in keyof T as `${Prop & - string}Value`]: T[Prop]['value'] extends Signal - ? HasUndefinedErrorHandling extends true - ? S | undefined - : S + [Prop in keyof T as T[Prop] extends ReloadableResource + ? `${Prop & string}Value` + : never]: T[Prop] extends Resource + ? ResourceValueType : never; - }; + } & NonPatchableResourceStateMarker; props: { + [Prop in keyof T as T[Prop] extends ReloadableResource + ? never + : `${Prop & string}Value`]: Signal< + T[Prop] extends Resource + ? ResourceValueType + : never + >; + } & { [Prop in keyof T as `${Prop & string}Status`]: Signal; } & { [Prop in keyof T as `${Prop & string}Error`]: Signal; } & { [Prop in keyof T as `${Prop & string}IsLoading`]: Signal; - }; + } & { + [Prop in keyof T as `${Prop & string}Snapshot`]: Signal< + ResourceSnapshot ? S : never> + >; + } & NamedAdditionalSignalProps; methods: { [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource< - Exclude + Exclude, undefined> >; } & { - [Prop in keyof T as `_${Prop & string}Reload`]: () => boolean; + [Prop in keyof T as T[Prop] extends ReloadableResource + ? `_${Prop & string}Reload` + : never]: () => boolean; }; }; @@ -81,10 +199,13 @@ const defaultOptions: Required = { * Integrates a `Resource` into the SignalStore and makes the store instance * implement the `Resource` interface. * - * The resource's value is stored under the `value` key in the state - * and is exposed as a `DeepSignal`. + * Reloadable resources (`ResourceRef`/`HttpResourceRef`) expose their + * value under `value` in the store state and can be updated via `patchState`. * - * It can also be updated via `patchState`. + * Plain `Resource` values are exposed as signals on the store instance, + * but are not part of state and therefore cannot be changed via `patchState`. + * Additional signal members on the resource (for example `foo`) are also + * exposed on the store as props. * * @usageNotes * @@ -108,32 +229,32 @@ const defaultOptions: Required = { */ export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, -): SignalStoreFeature>; + ) => ResourceType, +): SignalStoreFeature>; export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, + ) => ResourceType, resourceOptions: { errorHandling: 'undefined value' }, -): SignalStoreFeature>; +): SignalStoreFeature>; export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, + ) => ResourceType, resourceOptions?: ResourceOptions, -): SignalStoreFeature>; +): SignalStoreFeature>; /** * @experimental @@ -143,9 +264,13 @@ export function withResource< * registered by name, which is used as a prefix when spreading the members * of `Resource` onto the store. * - * Each resource’s value is part of the state, stored under the `value` key - * with the resource name as prefix. Values are exposed as `DeepSignal`s and - * can be updated via `patchState`. + * Reloadable resources (`ResourceRef`/`HttpResourceRef`) place their values + * in state using `Value` keys and support updates via `patchState`. + * + * Plain `Resource` values are exposed as read-only signals with `Value` + * but are not stored in state. + * Additional signal members are exposed as read-only props using + * ``. * * @usageNotes * @@ -166,7 +291,7 @@ export function withResource< * ``` * * @param resourceFactory A factory function that receives the store's props, - * methods, and state signals. It must return a `Record`. + * methods, and state signals. It must return a `Record`. * @param resourceOptions Allows to configure the error handling behavior. */ export function withResource< @@ -204,7 +329,7 @@ export function withResource< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef | ResourceDictionary, + ) => Resource | ResourceDictionary, resourceOptions?: ResourceOptions, ): SignalStoreFeature { const options: Required = { @@ -218,7 +343,10 @@ export function withResource< ...store.methods, }); - if (isResourceRef(resourceOrDictionary)) { + if ( + isResourceRef(resourceOrDictionary) || + isResource(resourceOrDictionary) + ) { return createUnnamedResource( resourceOrDictionary, options.errorHandling, @@ -233,26 +361,44 @@ export function withResource< } function createUnnamedResource( - resource: ResourceRef, + resource: Resource, errorHandling: ErrorHandling, ) { function hasValue(): this is Resource> { - return resource.hasValue(); + if (isResourceRef(resource)) { + return resource.hasValue(); + } else { + return resource.hasValue(); + } + } + + const metadataProps = { + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + ...extractAdditionalSignalProps(resource), + }; + + if (isResourceRef(resource)) { + return signalStoreFeature( + withLinkedState(() => ({ + value: valueSignalForErrorHandling(resource, errorHandling), + })), + withProps(() => metadataProps), + withMethods(() => ({ + hasValue, + _reload: () => resource.reload(), + })), + ); } return signalStoreFeature( - withLinkedState(() => ({ - value: valueSignalForErrorHandling(resource, errorHandling), - })), withProps(() => ({ - status: resource.status, - error: resource.error, - isLoading: resource.isLoading, - })), - withMethods(() => ({ - hasValue, - _reload: () => resource.reload(), + value: valueSignalForErrorHandling(resource, errorHandling), + ...metadataProps, })), + withMethods(() => ({ hasValue })), ); } @@ -262,37 +408,42 @@ function createNamedResource( ) { const keys = Object.keys(dictionary); - const state: Record> = keys.reduce( - (state, resourceName) => ({ - ...state, - [`${resourceName}Value`]: valueSignalForErrorHandling( - dictionary[resourceName], + const state: Record> = {}; + const props: Record> = {}; + const methods: Record boolean> = {}; + + for (const resourceName of keys) { + const res = dictionary[resourceName]; + + props[`${resourceName}Status`] = res.status; + props[`${resourceName}Error`] = res.error; + props[`${resourceName}IsLoading`] = res.isLoading; + props[`${resourceName}Snapshot`] = res.snapshot; + assignPrefixedProps(props, resourceName, extractAdditionalSignalProps(res)); + methods[`${resourceName}HasValue`] = isResourceRef(res) + ? () => res.hasValue() + : () => res.hasValue(); + + if (isResourceRef(res)) { + state[`${resourceName}Value`] = valueSignalForErrorHandling( + res, errorHandling, - ), - }), - {}, - ); - - const props: Record> = keys.reduce( - (props, resourceName) => ({ - ...props, - [`${resourceName}Status`]: dictionary[resourceName].status, - [`${resourceName}Error`]: dictionary[resourceName].error, - [`${resourceName}IsLoading`]: dictionary[resourceName].isLoading, - }), - {}, - ); + ) as WritableSignal; + methods[`_${resourceName}Reload`] = () => res.reload(); + } else { + props[`${resourceName}Value`] = valueSignalForErrorHandling( + res, + errorHandling, + ); + } + } - const methods: Record boolean> = keys.reduce( - (methods, resourceName) => { - return { - ...methods, - [`${resourceName}HasValue`]: () => dictionary[resourceName].hasValue(), - [`_${resourceName}Reload`]: () => dictionary[resourceName].reload(), - }; - }, - {}, - ); + if (Object.keys(state).length === 0) { + return signalStoreFeature( + withProps(() => props), + withMethods(() => methods), + ); + } return signalStoreFeature( withLinkedState(() => state), @@ -301,7 +452,7 @@ function createNamedResource( ); } -export function isResourceRef(value: unknown): value is ResourceRef { +export function isResource(value: unknown): value is Resource { return ( value !== null && typeof value === 'object' && @@ -310,11 +461,69 @@ export function isResourceRef(value: unknown): value is ResourceRef { 'status' in value && 'error' in value && 'isLoading' in value && - 'hasValue' in value && - 'reload' in value + 'snapshot' in value && + 'hasValue' in value + ); +} + +export function isResourceRef(value: unknown): value is ResourceRef { + return ( + isResource(value) && + 'reload' in value && + 'set' in value && + 'update' in value && + 'asReadonly' in value ); } +function extractAdditionalSignalProps( + resource: Resource, +): Record> { + const additionalSignals: Record> = {}; + + for (const key of Object.keys(resource)) { + if (isResourceCoreKey(key)) { + continue; + } + + const candidate = (resource as unknown as Record)[key]; + if (isSignal(candidate)) { + additionalSignals[key] = candidate; + } + } + + return additionalSignals; +} + +function isResourceCoreKey(key: string): key is ResourceCoreKeys { + return ( + key === 'value' || + key === 'status' || + key === 'error' || + key === 'isLoading' || + key === 'snapshot' || + key === 'hasValue' + ); +} + +function assignPrefixedProps( + target: Record>, + resourceName: string, + source: Record>, +): void { + for (const key of Object.keys(source)) { + target[`${resourceName}${capitalizeKey(key)}`] = source[key]; + } +} + +function capitalizeKey(value: string): string { + if (value.length === 0) { + return value; + } + + return value.charAt(0).toUpperCase() + value.slice(1); +} + //** Types for `mapToResource` */ type NamedResource = { @@ -327,6 +536,8 @@ type NamedResource = { [Prop in `${Name}IsLoading`]: Signal; } & { [Prop in `${Name}HasValue`]: () => boolean; +} & { + [Prop in `${Name}Snapshot`]: Signal>; }; type IsValidResourceName< @@ -375,7 +586,7 @@ type MappedResource< * * @param store The store instance to map the resource to. * @param name The name of the resource to map. - * @returns `ResourceRef` + * @returns `Resource` */ export function mapToResource< Name extends ResourceNames, @@ -394,6 +605,7 @@ export function mapToResource< status: store[`${resourceName}Status`], error: store[`${resourceName}Error`], isLoading: store[`${resourceName}IsLoading`], + snapshot: store[`${resourceName}Snapshot`], hasValue, } as MappedResource; } @@ -464,19 +676,19 @@ export function mapToResource< * a breaking change. */ function valueSignalForErrorHandling( - res: ResourceRef, + res: Resource, errorHandling: 'undefined value', -): WritableSignal; +): Signal; function valueSignalForErrorHandling( - res: ResourceRef, + res: Resource, errorHandling: ErrorHandling, -): WritableSignal; +): Signal; function valueSignalForErrorHandling( - res: ResourceRef, + res: Resource, errorHandling: ErrorHandling, -): WritableSignal { +): Signal { const originalSignal = res.value; switch (errorHandling) { diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts new file mode 100644 index 00000000..469991b3 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts @@ -0,0 +1,54 @@ +import { computed, Resource, ResourceRef, Signal } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { of } from 'rxjs'; + +export function stuffExtendedResourceWritable( + params?: () => string | undefined, +): ResourceRef & { stuff: Signal } { + const resource = rxResource({ + params: () => params?.() ?? '', + stream: ({ params }) => { + return of(params); + }, + }); + + const stuff = computed(() => resource.value() + ' stuff'); + + return { + stuff, + value: resource.value, + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + hasValue: resource.hasValue, + set: resource.set.bind(resource), + update: resource.update.bind(resource), + asReadonly: resource.asReadonly.bind(resource), + reload: resource.reload.bind(resource), + destroy: resource.destroy.bind(resource), + }; +} + +export function stuffExtendedResourceReadable( + params?: () => string | undefined, +): Resource & { stuff: Signal } { + const resource = rxResource({ + params: () => params?.() ?? '', + stream: ({ params }) => { + return of(params); + }, + }).asReadonly(); + + const stuff = computed(() => resource.value() + ' stuff'); + + return { + stuff, + value: resource.value, + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + hasValue: resource.hasValue.bind(resource), + }; +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts index d0cc9457..f874c9bb 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts @@ -12,5 +12,8 @@ export type ResourceTestAdapter = { isLoading: boolean; hasValue: boolean; }; +}; + +export type ReloadableResourceTestAdapter = ResourceTestAdapter & { reload: () => void; }; diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts index 6f0af070..bc015ba5 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts @@ -9,7 +9,7 @@ import { setupNamedResource } from './setup-named-resource'; export function setupMappedResource( errorHandling: ErrorHandling, ): ResourceTestAdapter { - const { addressResolver, setId, setValue, reload, store } = + const { addressResolver, setId, setValue, store } = setupNamedResource(errorHandling); const resource = mapToResource( @@ -28,6 +28,5 @@ export function setupMappedResource( isLoading: resource.isLoading(), hasValue: resource.hasValue(), }), - reload, }; } diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts index 2dc4a969..ca5f1858 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts @@ -1,6 +1,6 @@ import { inject, resource } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { patchState, signalStore, withState } from '@ngrx/signals'; import { ErrorHandling, withResource } from '../../../with-resource'; import { Address, AddressResolver, venice } from './fixtures'; import { ResourceTestAdapter } from './resource-test-adapter'; @@ -27,7 +27,6 @@ export function setupNamedResource( }, { errorHandling }, ), - withMethods((store) => ({ reload: () => store._addressReload() })), ); TestBed.configureTestingModule({ @@ -54,6 +53,5 @@ export function setupNamedResource( isLoading: store.addressIsLoading(), hasValue: store.addressHasValue(), }), - reload: () => store.reload(), }; } diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts index 0939d10d..c3b075cb 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts @@ -3,11 +3,11 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { ErrorHandling, withResource } from '../../../with-resource'; import { Address, AddressResolver, venice } from './fixtures'; -import { ResourceTestAdapter } from './resource-test-adapter'; +import { ReloadableResourceTestAdapter } from './resource-test-adapter'; export function setupUnnamedResource( errorHandling?: ErrorHandling, -): ResourceTestAdapter { +): ReloadableResourceTestAdapter { const addressResolver = { resolve: jest.fn(() => Promise.resolve(venice)), }; diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts new file mode 100644 index 00000000..57829184 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts @@ -0,0 +1,26 @@ +import { + linkedSignal, + Resource, + resourceFromSnapshots, + ResourceSnapshot, +} from '@angular/core'; + +export function withPreviousValue(input: Resource): Resource { + const derived = linkedSignal, ResourceSnapshot>({ + source: input.snapshot, + computation: (snap, previous) => { + if ( + snap.status === 'loading' && + previous && + previous.value.status !== 'error' + ) { + // When the input resource enters loading state, we keep the value + // from its previous state, if any. + return { status: 'loading' as const, value: previous.value.value }; + } + // Otherwise we simply forward the state of the input resource. + return snap; + }, + }); + return resourceFromSnapshots(derived); +}