diff --git a/apps/demo/e2e/todo-entity-resource.spec.ts b/apps/demo/e2e/todo-entity-resource.spec.ts new file mode 100644 index 00000000..3548298c --- /dev/null +++ b/apps/demo/e2e/todo-entity-resource.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +test.describe('withEntityResources - todos', () => { + test.beforeEach(async ({ page }) => { + await page.goto(''); + await page.getByRole('link', { name: 'withEntityResources' }).click(); + }); + + test('add one todo and remove another', async ({ page }) => { + await expect(page.getByRole('row', { name: 'Buy milk' })).toBeVisible(); + await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible(); + + await page.locator('[data-id="todoer-new"]').click(); + await page.locator('[data-id="todoer-new"]').fill('Read a book'); + await page.locator('[data-id="todoer-add"]').click(); + + await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible(); + + await page + .getByRole('row', { name: 'Buy milk' }) + .locator('[data-id="todoer-delete"]') + .click(); + + await expect(page.getByRole('row', { name: 'Buy milk' })).toHaveCount(0); + await expect(page.getByRole('row', { name: 'Read a book' })).toBeVisible(); + await expect(page.getByRole('row', { name: 'Walk the dog' })).toBeVisible(); + }); +}); diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index a18faa3d..b2d68ff3 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -28,6 +28,9 @@ withConditional withMutation rxMutation (without Store) + withEntityResources diff --git a/apps/demo/src/app/app.config.ts b/apps/demo/src/app/app.config.ts index 81c8a4d8..bb04ffa1 100644 --- a/apps/demo/src/app/app.config.ts +++ b/apps/demo/src/app/app.config.ts @@ -1,15 +1,16 @@ import { LayoutModule } from '@angular/cdk/layout'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, importProvidersFrom } from '@angular/core'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { appRoutes } from './app.routes'; +import { memoryHttpInterceptor } from './todo-entity-resource/memory-http.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(appRoutes, withComponentInputBinding()), provideAnimations(), - provideHttpClient(), + provideHttpClient(withInterceptors([memoryHttpInterceptor])), importProvidersFrom(LayoutModule), ], }; diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index ba4c66e5..7b6266be 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -75,4 +75,11 @@ export const lazyRoutes: Route[] = [ (m) => m.CounterRxMutation, ), }, + { + path: 'todo-entity-resource', + loadComponent: () => + import('./todo-entity-resource/todo-entity-resource.component').then( + (m) => m.TodoEntityResourceComponent, + ), + }, ]; diff --git a/apps/demo/src/app/todo-entity-resource/memory-http.interceptor.ts b/apps/demo/src/app/todo-entity-resource/memory-http.interceptor.ts new file mode 100644 index 00000000..8a2cff0c --- /dev/null +++ b/apps/demo/src/app/todo-entity-resource/memory-http.interceptor.ts @@ -0,0 +1,68 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpInterceptorFn, + HttpRequest, + HttpResponse, +} from '@angular/common/http'; +import { + EnvironmentInjector, + inject, + runInInjectionContext, +} from '@angular/core'; +import { Observable, of, switchMap } from 'rxjs'; +import { Todo, TodoMemoryService } from './todo-memory.service'; + +function respond(req: HttpRequest, body: T): HttpResponse { + return new HttpResponse({ + url: req.url, + status: 200, + statusText: 'OK', + body, + }); +} + +export const memoryHttpInterceptor: HttpInterceptorFn = ( + req, + next, +): Observable> => { + const match = req.url.match(/\/memory\/(add|toggle|remove)(?:\/(\d+))?/); + if (!match) return next(req); + + // Ensure we resolve service inside an injection context + const env = inject(EnvironmentInjector); + const svc = runInInjectionContext(env, () => inject(TodoMemoryService)); + + const action = match[1]; + const idPart = match[2]; + + switch (action) { + case 'add': { + const todo = req.body as Todo; + return svc + .add(todo) + .pipe(switchMap((t) => of(respond(req, t) as HttpEvent))); + } + case 'toggle': { + const id = Number(idPart); + const completed = (req.body as { completed: boolean }).completed; + return svc.toggle(id, completed).pipe( + switchMap((t) => { + if (t) { + return of(respond(req, t) as HttpEvent); + } + const err = new HttpErrorResponse({ url: req.url, status: 404 }); + throw err; + }), + ); + } + case 'remove': { + const id = Number(idPart); + return svc + .remove(id) + .pipe(switchMap((ok) => of(respond(req, ok) as HttpEvent))); + } + default: + return next(req); + } +}; diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html new file mode 100644 index 00000000..aad6ccc2 --- /dev/null +++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.html @@ -0,0 +1,60 @@ +
+ + Filter + + + + + New todo + + + +
+ + + + + + + {{ row.completed ? 'check_box' : 'check_box_outline_blank' }} + + delete + + + + + Title + {{ element.title }} + + + + + diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts new file mode 100644 index 00000000..fbeba5b3 --- /dev/null +++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.component.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { TodoEntityResourceStore } from './todo-entity-resource.store'; + +@Component({ + selector: 'demo-todo-entity-resource', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIcon, + MatInputModule, + MatListModule, + MatTableModule, + ], + templateUrl: './todo-entity-resource.component.html', + styles: [], +}) +export class TodoEntityResourceComponent { + protected readonly store = inject(TodoEntityResourceStore); + protected newTitle = ''; + protected readonly dataSource = new MatTableDataSource<{ + id: number; + title: string; + completed: boolean; + }>([]); + protected readonly filtered = computed(() => + this.store.entities().filter((t) => + (this.store.filter() || '') + .toLowerCase() + .split(/\s+/) + .filter((s) => s.length > 0) + .every((s) => t.title.toLowerCase().includes(s)), + ), + ); + constructor() { + effect(() => { + this.dataSource.data = this.filtered(); + }); + } + trackById = (_: number, t: { id: number }) => t.id; + add() { + const title = this.newTitle.trim(); + if (!title) return; + const ids = this.store.ids() as Array; + const nextId = ids.length ? Math.max(...ids) + 1 : 1; + this.store.addTodo({ id: nextId, title, completed: false }); + this.newTitle = ''; + } +} diff --git a/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts new file mode 100644 index 00000000..6d0883be --- /dev/null +++ b/apps/demo/src/app/todo-entity-resource/todo-entity-resource.store.ts @@ -0,0 +1,61 @@ +import { + httpMutation, + withEntityResources, + withMutations, +} from '@angular-architects/ngrx-toolkit'; +import { inject, resource } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities'; +import { firstValueFrom } from 'rxjs'; +import { Todo, TodoMemoryService } from './todo-memory.service'; + +export const TodoEntityResourceStore = signalStore( + { providedIn: 'root' }, + withState({ baseUrl: '/api', filter: '' }), + withEntityResources((store, svc = inject(TodoMemoryService)) => + resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] }), + ), + withMethods((store) => ({ + setFilter(filter: string) { + patchState(store, { filter }); + }, + })), + withMutations((store, svc = inject(TodoMemoryService)) => ({ + addTodo: httpMutation({ + request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }), + parse: (raw) => raw as Todo, + onSuccess: async (todo) => { + await firstValueFrom(svc.add(todo)); + patchState(store, addEntity(todo)); + }, + }), + toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({ + request: (p) => ({ + url: `/memory/toggle/${p.id}`, + method: 'PATCH', + body: p, + }), + parse: (raw) => raw as Todo, + onSuccess: async (_todo, p) => { + const todo = await firstValueFrom(svc.toggle(p.id, p.completed)); + if (todo) { + patchState( + store, + updateEntity({ + id: todo.id, + changes: { completed: todo.completed }, + }), + ); + } + }, + }), + removeTodo: httpMutation({ + request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }), + parse: () => true, + onSuccess: async (_r, id) => { + await firstValueFrom(svc.remove(id)); + patchState(store, removeEntity(id)); + }, + }), + })), +); diff --git a/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts b/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts new file mode 100644 index 00000000..a86f654a --- /dev/null +++ b/apps/demo/src/app/todo-entity-resource/todo-memory.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, of } from 'rxjs'; + +export interface Todo { + id: number; + title: string; + completed: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class TodoMemoryService { + private readonly todos$ = new BehaviorSubject([ + { id: 1, title: 'Buy milk', completed: false }, + { id: 2, title: 'Walk the dog', completed: true }, + ]); + + list(): Observable { + return this.todos$.asObservable(); + } + + add(todo: Todo): Observable { + const list = this.todos$.value.slice(); + list.push(todo); + this.todos$.next(list); + return of(todo); + } + + toggle(id: number, completed: boolean): Observable { + const list = this.todos$.value.slice(); + const idx = list.findIndex((t) => t.id === id); + if (idx >= 0) { + list[idx] = { ...list[idx], completed }; + this.todos$.next(list); + return of(list[idx]); + } + return of(undefined); + } + + remove(id: number): Observable { + const list = this.todos$.value.slice(); + const filtered = list.filter((t) => t.id !== id); + this.todos$.next(filtered); + return of(true); + } +} diff --git a/docs/docs/extensions.md b/docs/docs/extensions.md index 94ad0e0c..702836b1 100644 --- a/docs/docs/extensions.md +++ b/docs/docs/extensions.md @@ -12,6 +12,7 @@ It offers extensions like: - [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store. - [~Redux~](./with-redux): Possibility to use the Redux Pattern. Deprecated in favor of NgRx's `@ngrx/signals/events` starting in 19.2 - [Resource](./with-resource): Integrates Angular's Resource into SignalStore for async data operations +- [Entity Resources](./with-entity-resources): Builds on top of [withResource](./with-resource); adds entity support for array resources (`ids`, `entityMap`, `entities`) - [Mutations](./mutations): Seek to offer an appropriate equivalent to signal resources for sending data back to the backend - [Reset](./with-reset): Adds a `resetState` method to your store - [Call State](./with-call-state): Add call state management to your signal stores diff --git a/docs/docs/with-entity-resources.md b/docs/docs/with-entity-resources.md new file mode 100644 index 00000000..f6c0b4bb --- /dev/null +++ b/docs/docs/with-entity-resources.md @@ -0,0 +1,198 @@ +--- +title: withEntityResources() +--- + +```typescript +import { withEntityResources } from '@angular-architects/ngrx-toolkit'; +``` + +`withEntityResources()` integrates Angular Resources that return arrays into NgRx SignalStore using the Entity helpers from `@ngrx/signals/entities`. + +- **Unnamed resource**: Your store exposes resource members (`value`, `status`, `error`, `isLoading`, etc.) and additionally derives entity members: `ids`, `entityMap`, `entities`. +- **Named resources**: Register multiple array resources by name. The store exposes prefixed members per resource, e.g. `todosValue`, `todosIds`, `todosEntityMap`, `todosEntities`. + +This feature composes `withResource()` and the Entities utilities without effects. Entity state is linked to the resource value using linked signals, so updaters like `addEntity`, `updateEntity`, and `removeEntity` mutate the entity view in the store while the source of truth remains the resource. + +## Accepted Inputs and Type Signatures + +```ts +// Single (unnamed) resource producing an array of entities +withEntityResources< + Entity extends { id: EntityId } +>((store) => ResourceRef); + +// Multiple (named) resources: a dictionary of array resources +withEntityResources< + Dictionary extends Record> +>((store) => Dictionary); +``` + +- **Must be arrays**: Each `ResourceRef` must resolve to an array (possibly `readonly` and possibly `undefined` while loading). Use `defaultValue: []` for a consistent empty state. +- **Entity identity**: Array element type must include an `id` compatible with `EntityId`. +- **Named resources**: For the dictionary form, keys become the name prefixes (e.g., `todosEntities()`), and each entry can have a different element type. +- **Non-array resources**: If your resource does not produce an array, use `withResource()` instead. + +## How it works internally + +- **Composes withResource**: Internally calls `withResource()` with either a single `ResourceRef` or a dictionary of `ResourceRef`s, so all standard Resource members are available on the store (or prefixed for named resources). +- **Derives entity signals**: From the resource's `value` signal (the array), it derives: + - `ids` via a linked signal that maps each entity to its `id` + - `entityMap` via a linked signal that builds an `id -> entity` map + - `entities` as a computed projection of `ids` through `entityMap` +- **No effects**: Synchronization is purely signal-based; entity updaters mutate the store's entity state while the underlying Resource value remains the source of truth. + +## Basic Usage + +### Unnamed Resource + +```typescript +import { signalStore, withState, patchState } from '@ngrx/signals'; +import { resource } from '@angular/core'; +import { addEntity } from '@ngrx/signals/entities'; +import { withEntityResources } from '@angular-architects/ngrx-toolkit'; + +export type Todo = { id: number; title: string; completed: boolean }; + +export const TodoStore = signalStore( + { providedIn: 'root' }, + withState({}), + withEntityResources(() => resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] })), +); + +// Later, you can use entity updaters +// patchState(TodoStore, addEntity({ id: 1, title: 'A', completed: false })); +``` + +The store now provides: + +- **Resource members**: `value()`, `status()`, `error()`, `isLoading()`, `hasValue()`, `_reload()` +- **Entity members**: `ids()`, `entityMap()`, `entities()` + +### Named Resources + +```typescript +import { signalStore } from '@ngrx/signals'; +import { resource } from '@angular/core'; +import { withEntityResources } from '@angular-architects/ngrx-toolkit'; + +export type Todo = { id: number; title: string; completed: boolean }; + +export const Store = signalStore( + { providedIn: 'root' }, + withEntityResources(() => ({ + todos: resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }), + projects: resource({ loader: () => Promise.resolve([] as { id: number; name: string }[]), defaultValue: [] }), + })), +); +``` + +This exposes per-resource members with the resource name as a prefix: + +- **Resource members**: `todosValue()`, `todosStatus()`, `todosError()`, `todosIsLoading()`; `projectsValue()`, ... +- **Entity members**: `todosIds()`, `todosEntityMap()`, `todosEntities()`; `projectsIds()`, `projectsEntityMap()`, `projectsEntities()` + +## Component Usage + +```typescript +import { Component, inject } from '@angular/core'; + +@Component({ + selector: 'todo-list', + template: ` + @if (store.isLoading()) { +
Loading...
+ } @else if (store.error()) { +

An error has happened.

+ } @else if (store.hasValue()) { +
    + @for (t of store.entities(); track t.id) { +
  • {{ t.title }} — {{ t.completed ? 'done' : 'open' }}
  • + } +
+ } + `, +}) +export class TodoListComponent { + protected readonly store = inject(TodoStore); +} +``` + +For a named collection like `todos`, use `todosIsLoading()`, `todosError()`, `todosEntities()`, etc. + +## Using Entity Updaters + +The derived entity state is writable via NgRx entity updaters, just like with `withEntities()`: + +```typescript +import { patchState } from '@ngrx/signals'; +import { addEntity, updateEntity, removeEntity, setAllEntities } from '@ngrx/signals/entities'; + +// Unnamed +patchState(store, setAllEntities([{ id: 1, title: 'A', completed: false }])); +patchState(store, addEntity({ id: 2, title: 'B', completed: true })); +patchState(store, updateEntity({ id: 2, changes: { completed: false } })); +patchState(store, removeEntity(1)); + +// Named (e.g., todos) +patchState(store, addEntity({ id: 3, title: 'C', completed: false }, { collection: 'todos' })); +patchState(store, removeEntity(3, { collection: 'todos' })); +``` + +## Demo Example + +See the demo store `todo-entity-resource` for a full example that combines mutations and entity resources. + +```typescript +import { httpMutation, withMutations, withEntityResources } from '@angular-architects/ngrx-toolkit'; +import { inject, resource } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities'; +import { firstValueFrom } from 'rxjs'; +import { Todo, TodoMemoryService } from './todo-memory.service'; + +export const TodoEntityResourceStore = signalStore( + { providedIn: 'root' }, + withState({ baseUrl: '/api', filter: '' }), + withEntityResources((_store, svc = inject(TodoMemoryService)) => resource({ loader: () => firstValueFrom(svc.list()), defaultValue: [] })), + withMethods((store) => ({ + setFilter(filter: string) { + patchState(store, { filter }); + }, + })), + withMutations((store, svc = inject(TodoMemoryService)) => ({ + addTodo: httpMutation({ + request: (todo) => ({ url: '/memory/add', method: 'POST', body: todo }), + parse: (raw) => raw as Todo, + onSuccess: async (todo) => { + await firstValueFrom(svc.add(todo)); + patchState(store, addEntity(todo)); + }, + }), + toggleTodo: httpMutation<{ id: number; completed: boolean }, Todo>({ + request: (p) => ({ url: `/memory/toggle/${p.id}`, method: 'PATCH', body: p }), + parse: (raw) => raw as Todo, + onSuccess: async (_todo, p) => { + const todo = await firstValueFrom(svc.toggle(p.id, p.completed)); + if (todo) { + patchState(store, updateEntity({ id: todo.id, changes: { completed: todo.completed } })); + } + }, + }), + removeTodo: httpMutation({ + request: (id) => ({ url: `/memory/remove/${id}`, method: 'DELETE' }), + parse: () => true, + onSuccess: async (_r, id) => { + await firstValueFrom(svc.remove(id)); + patchState(store, removeEntity(id)); + }, + }), + })), +); +``` + +## Interop and Notes + +- **Type Safety**: The entity type is inferred from the resource value (array element type). Ensure your resource returns an array type with an `id` field (`EntityId`). +- **Composition**: Can be composed with `withEntities()` for additional collections alongside resource-backed collections. +- **No effects**: Synchronization is purely signal-based via linked signals; no imperative effects are used. +- **Named vs Unnamed**: Choose unnamed for a single list; use named when you manage multiple lists in one store. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index a1dc30be..38b249c1 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = { 'with-immutable-state', 'with-reset', 'with-resource', + 'with-entity-resources', 'with-storage-sync', 'with-undo-redo', 'with-redux', diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 8b8bba85..6a03974d 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -43,6 +43,7 @@ export { emptyFeature, withConditional } from './lib/with-conditional'; export { withFeatureFactory } from './lib/with-feature-factory'; export * from './lib/mutation/rx-mutation'; +export * from './lib/with-entity-resources'; export * from './lib/with-mutations'; export { mapToResource, withResource } from './lib/with-resource'; diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts new file mode 100644 index 00000000..5830e6aa --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts @@ -0,0 +1,258 @@ +import { resource } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withState } from '@ngrx/signals'; +import { + addEntity, + removeEntity, + setAllEntities, +} from '@ngrx/signals/entities'; +import { withEntityResources } from './with-entity-resources'; + +type Todo = { id: number; title: string; completed: boolean }; +const wait = async () => { + await new Promise((r) => setTimeout(r)); +}; + +describe('withEntityResources', () => { + describe('unnamed entities', () => { + it('derives ids, entityMap and entities from resource value', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ load: undefined as boolean | undefined }), + withEntityResources((store) => + resource({ + params: store.load, + loader: ({ params }) => + Promise.resolve( + params + ? ([{ id: 1, title: 'A', completed: false }] as Todo[]) + : ([] as Todo[]), + ), + defaultValue: [], + }), + ), + ); + + const store = TestBed.inject(Store); + + // trigger load and verify derived signals + patchState(store, { load: true }); + await wait(); + + expect(store.ids()).toEqual([1]); + expect(store.entityMap()).toEqual({ + 1: { id: 1, title: 'A', completed: false }, + }); + expect(store.entities()).toEqual([ + { id: 1, title: 'A', completed: false }, + ]); + }); + + it('supports addEntity updater mutating ids/entityMap/derived entities', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => + resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + ), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.entities()).toEqual([]); + + patchState( + store, + addEntity({ id: 1, title: 'X', completed: false } as Todo), + ); + + expect(store.ids()).toEqual([1]); + expect(store.entityMap()).toEqual({ + 1: { id: 1, title: 'X', completed: false }, + }); + expect(store.entities()).toEqual([ + { id: 1, title: 'X', completed: false }, + ]); + }); + }); + + describe('named entities', () => { + it('derives Ids, EntityMap and Entities from resource value', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => ({ + todos: resource({ + loader: () => + Promise.resolve([ + { id: 1, title: 'A', completed: false }, + ] as Todo[]), + defaultValue: [], + }), + projects: resource({ + loader: () => + Promise.resolve([{ id: 10, name: 'X' }] as { + id: number; + name: string; + }[]), + defaultValue: [], + }), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.todosIds()).toEqual([1]); + expect(store.todosEntityMap()).toEqual({ + 1: { id: 1, title: 'A', completed: false }, + }); + expect(store.todosValue()).toEqual([ + { id: 1, title: 'A', completed: false }, + ]); + expect(store.projectsEntities()).toHaveLength(1); + expect(store.projectsValue()).toEqual([{ id: 10, name: 'X' }]); + }); + + it('supports addEntity for named collection via ids/entityMap', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => ({ + todos: resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.todosEntities()).toEqual([]); + + patchState( + store, + addEntity( + { id: 2, title: 'Y', completed: true }, + { collection: 'todos' }, + ), + ); + + expect(store.todosIds()).toEqual([2]); + expect(store.todosEntityMap()).toEqual({ + 2: { id: 2, title: 'Y', completed: true }, + }); + expect(store.todosEntities()).toEqual([ + { id: 2, title: 'Y', completed: true }, + ]); + }); + }); + + describe('entity updaters', () => { + it('supports setAllEntities/addEntity/removeEntity for unnamed', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => + resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + ), + ); + const store = TestBed.inject(Store); + + await wait(); + + // set all + patchState( + store, + setAllEntities([ + { id: 1, title: 'A', completed: false }, + { id: 2, title: 'B', completed: true }, + ] as Todo[]), + ); + expect(store.ids()).toEqual([1, 2]); + expect(store.entities()).toEqual([ + { id: 1, title: 'A', completed: false }, + { id: 2, title: 'B', completed: true }, + ]); + + // add + patchState( + store, + addEntity({ id: 3, title: 'C', completed: false } as Todo), + ); + expect(store.ids()).toEqual([1, 2, 3]); + expect(store.entities()).toEqual([ + { id: 1, title: 'A', completed: false }, + { id: 2, title: 'B', completed: true }, + { id: 3, title: 'C', completed: false }, + ]); + + // remove + patchState(store, removeEntity(2)); + expect(store.ids()).toEqual([1, 3]); + expect(store.entities()).toEqual([ + { id: 1, title: 'A', completed: false }, + { id: 3, title: 'C', completed: false }, + ]); + }); + + it('supports setAllEntities/addEntity/removeEntity for named', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => ({ + todos: resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + // set all + patchState( + store, + setAllEntities( + [ + { id: 10, title: 'X', completed: false }, + { id: 11, title: 'Y', completed: true }, + ] as Todo[], + { collection: 'todos' }, + ), + ); + expect(store.todosIds()).toEqual([10, 11]); + expect(store.todosEntities()).toEqual([ + { id: 10, title: 'X', completed: false }, + { id: 11, title: 'Y', completed: true }, + ]); + + // add + patchState( + store, + addEntity({ id: 12, title: 'Z', completed: false } as Todo, { + collection: 'todos', + }), + ); + expect(store.todosIds()).toEqual([10, 11, 12]); + expect(store.todosEntities()).toEqual([ + { id: 10, title: 'X', completed: false }, + { id: 11, title: 'Y', completed: true }, + { id: 12, title: 'Z', completed: false }, + ]); + + // remove + patchState(store, removeEntity(11, { collection: 'todos' })); + expect(store.todosIds()).toEqual([10, 12]); + expect(store.todosEntities()).toEqual([ + { id: 10, title: 'X', completed: false }, + { id: 12, title: 'Z', completed: false }, + ]); + }); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts new file mode 100644 index 00000000..84f571b8 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts @@ -0,0 +1,305 @@ +import { ResourceRef, Signal, computed, linkedSignal } from '@angular/core'; +import { + SignalStoreFeature, + SignalStoreFeatureResult, + StateSignals, + signalStoreFeature, + withComputed, + withLinkedState, +} from '@ngrx/signals'; +import { + EntityId, + EntityProps, + EntityState, + NamedEntityProps, + NamedEntityState, +} from '@ngrx/signals/entities'; +import { + NamedResourceResult, + ResourceResult, + isResourceRef, + withResource, +} from './with-resource'; + +/** + * @experimental + * @description + * + * Integrates array-based `Resource` data into Entity-style state for NgRx SignalStore. + * + * - For a single (unnamed) resource: exposes `value`, `status`, `error`, `isLoading` + * from the underlying resource (via `withResource`), and derives + * `ids`, `entityMap`, and `entities` via `withLinkedState`/`withComputed`. + * - For multiple (named) resources: registers each resource by name and exposes + * the same members prefixed with the resource name, e.g. `todosIds`, + * `todosEntityMap`, `todosEntities`, along with `todosValue`, `todosStatus`, etc. + * + * No effects are used. All derived signals are linked to the resource's value + * through `withLinkedState`, so entity updaters such as `addEntity`, `updateEntity`, + * and `removeEntity` mutate the store's entity view without directly writing to the + * resource. The source of truth remains the resource value. + * + * @usageNotes + * + * Unnamed resource example: + * + * ```ts + * type Todo = { id: number; title: string; completed: boolean }; + * + * const Store = signalStore( + * { providedIn: 'root' }, + * withEntityResources(() => + * resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }), + * ), + * ); + * + * const store = TestBed.inject(Store); + * store.status(); // 'idle' | 'loading' | 'resolved' | 'error' + * store.value(); // Todo[] + * store.ids(); // EntityId[] + * store.entityMap(); // Record + * store.entities(); // Todo[] + * + * // Works with @ngrx/signals/entities updaters + * patchState(store, addEntity({ id: 1, title: 'X', completed: false })); + * ``` + * + * Named resources example: + * + * ```ts + * const Store = signalStore( + * { providedIn: 'root' }, + * withEntityResources(() => ({ + * todos: resource({ loader: () => Promise.resolve([] as Todo[]), defaultValue: [] }), + * projects: resource({ loader: () => Promise.resolve([] as { id: number; name: string }[]), defaultValue: [] }), + * })), + * ); + * + * const store = TestBed.inject(Store); + * store.todosValue(); + * store.todosIds(); + * store.todosEntityMap(); + * store.todosEntities(); + * patchState(store, addEntity({ id: 2, title: 'Y', completed: true }, { collection: 'todos' })); + * ``` + */ +export function withEntityResources< + Input extends SignalStoreFeatureResult, + Entity extends { id: EntityId }, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => ResourceRef, +): SignalStoreFeature>; + +export function withEntityResources< + Input extends SignalStoreFeatureResult, + Dictionary extends EntityDictionary, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => Dictionary, +): SignalStoreFeature>; + +export function withEntityResources< + Input extends SignalStoreFeatureResult, + ResourceValue extends readonly unknown[] | unknown[] | undefined, +>( + entityResourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => ResourceRef | EntityDictionary, +): SignalStoreFeature { + return (store) => { + const resourceOrDict = entityResourceFactory({ + ...store.stateSignals, + ...store.props, + ...store.methods, + }); + + if (isResourceRef(resourceOrDict)) { + return createUnnamedEntityResource(resourceOrDict)(store); + } + return createNamedEntityResources(resourceOrDict)(store); + }; +} + +function createUnnamedEntityResource< + R extends ResourceRef, +>(resource: R) { + type E = InferEntityFromRef & { id: EntityId }; + const { idsLinked, entityMapLinked, entitiesSignal } = + createEntityDerivations( + resource.value as Signal, + ); + + return signalStoreFeature( + withResource(() => resource), + withLinkedState(() => ({ + entityMap: entityMapLinked, + ids: idsLinked, + })), + withComputed(() => ({ + entities: entitiesSignal, + })), + ); +} + +function createNamedEntityResources( + dictionary: Dictionary, +) { + const keys = Object.keys(dictionary); + + const linkedState: Record> = {}; + const computedProps: Record> = {}; + + keys.forEach((name) => { + const ref = dictionary[name]; + type E = InferEntityFromRef & { id: EntityId }; + const { idsLinked, entityMapLinked, entitiesSignal } = + createEntityDerivations( + ref.value as Signal, + ); + + linkedState[`${String(name)}EntityMap`] = entityMapLinked; + linkedState[`${String(name)}Ids`] = idsLinked; + computedProps[`${String(name)}Entities`] = entitiesSignal; + }); + + return signalStoreFeature( + withResource(() => dictionary), + withLinkedState(() => linkedState), + withComputed(() => computedProps), + ); +} + +// Types for `withEntityResources` +/** + * @internal + * @description + * + * Type composition notes: we intentionally do not duplicate or re-declare + * types that already exist in `@ngrx/signals/entities` or in this library's + * `with-resource` feature. Instead, we compose the resulting API via + * intersections of those public contracts. + * + * Rationale: + * - Keeps our types in sync with upstream sources and avoids drift. + * - Reduces maintenance overhead and duplication. + * - Ensures consumers benefit automatically from upstream typing fixes. + * + * Concretely: + * - For unnamed resources we return `ResourceResult` intersected with + * `EntityState` and `EntityProps`. + * - For named resources we return `NamedResourceResult` intersected with + * `NamedEntityState` and `NamedEntityProps` for each entry. + */ +export type EntityResourceResult = { + state: ResourceResult['state'] & EntityState; + props: ResourceResult['props'] & EntityProps; + methods: ResourceResult['methods']; +}; + +// Generic helpers for inferring entity types and merging unions +type ArrayElement = T extends readonly (infer E)[] | (infer E)[] ? E : never; + +type InferEntityFromSignal = + T extends Signal ? ArrayElement : never; + +type InferEntityFromRef< + R extends ResourceRef, +> = R['value'] extends Signal ? ArrayElement : never; + +type MergeUnion = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +export type EntityDictionary = Record< + string, + ResourceRef +>; + +type MergeNamedEntityStates = MergeUnion< + { + [Prop in keyof T]: Prop extends string + ? InferEntityFromSignal extends infer E + ? E extends never + ? never + : NamedEntityState + : never + : never; + }[keyof T] +>; + +type MergeNamedEntityProps = MergeUnion< + { + [Prop in keyof T]: Prop extends string + ? InferEntityFromSignal extends infer E + ? E extends never + ? never + : NamedEntityProps + : never + : never; + }[keyof T] +>; + +export type NamedEntityResourceResult = { + state: NamedResourceResult['state'] & MergeNamedEntityStates; + props: NamedResourceResult['props'] & MergeNamedEntityProps; + methods: NamedResourceResult['methods']; +}; + +/** + * @internal + * @description + * + * Creates the three entity-related signals (`ids`, `entityMap`, `entities`) from + * a single source signal of entities. This mirrors the public contract of + * `withEntities()`: + * - `ids`: derived list of entity ids + * - `entityMap`: map of id -> entity + * - `entities`: projection of `ids` through `entityMap` + * + * Implementation details: + * - Uses `withLinkedState` + `linkedSignal` for `ids` and `entityMap` so they are + * writable state signals in the store (updaters like `addEntity` can mutate them). + * - `entities` is a pure `computed` derived from `ids` and `entityMap`, matching + * the pattern in `withEntities` where `entities` is computed from the two bases. + * + * Why not `watchState` or `effect`? + * - `watchState` would fire for any state change in the store, not just changes + * to the underlying resource value. That would cause unnecessary recomputation + * and make it harder to reason about updates. + * - `effect` would introduce side-effects and lifecycle management for syncing, + * which is heavier and not aligned with this feature's goal to be purely + * derived from signals. Using linked signals keeps the data flow declarative + * and avoids imperative syncing code. + */ +function createEntityDerivations( + source: Signal, +) { + const idsLinked = linkedSignal({ + source, + computation: (list) => (list ?? []).map((e) => e.id), + }); + + const entityMapLinked = linkedSignal({ + source, + computation: (list) => { + const map = {} as Record; + for (const item of list ?? []) { + map[item.id] = item as E; + } + return map; + }, + }); + + const entitiesSignal = computed(() => { + const ids = idsLinked(); + const map = entityMapLinked(); + return ids.map((id) => map[id]) as readonly E[]; + }); + + return { idsLinked, entityMapLinked, entitiesSignal }; +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 4f57a5a3..c2011b29 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -31,9 +31,9 @@ export type ResourceResult = { }; }; -type ResourceDictionary = Record>; +export type ResourceDictionary = Record>; -type NamedResourceResult = { +export type NamedResourceResult = { state: { [Prop in keyof T as `${Prop & string}Value`]: T[Prop]['value'] extends Signal ? S : never; @@ -223,7 +223,7 @@ function createNamedResource( ); } -function isResourceRef(value: unknown): value is ResourceRef { +export function isResourceRef(value: unknown): value is ResourceRef { return ( value !== null && typeof value === 'object' &&