diff --git a/modules/signals/entities/spec/updaters/clear-selected-entity.spec.ts b/modules/signals/entities/spec/updaters/clear-selected-entity.spec.ts new file mode 100644 index 0000000000..b074bfc6ae --- /dev/null +++ b/modules/signals/entities/spec/updaters/clear-selected-entity.spec.ts @@ -0,0 +1,57 @@ +import { patchState, signalStore } from '@ngrx/signals'; +import { + addEntities, + withEntities, + selectEntity, + clearSelectedEntity, +} from '../../src'; +import { User, user1, user2, user3 } from '../mocks'; + +describe('selectEntity', () => { + it('should clear the selectedEntity and selectedEntityId if an entity is selected and exists in the state', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2, user3])); + patchState(store, selectEntity(user1.id)); + + expect(store.selectedId()).toBe(user1.id); + expect(store.selectedEntity()).toBe(user1); + + patchState(store, clearSelectedEntity()); + + expect(store.selectedId()).toBe(null); + expect(store.selectedEntity()).toBe(null); + }); + + it('should clear the selectedEntity and selectedEntityId if an entity is selected and it does not exists in the state', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2])); + patchState(store, selectEntity(user3.id)); + + expect(store.selectedId()).toBe(user3.id); + expect(store.selectedEntity()).toBe(null); + + patchState(store, clearSelectedEntity()); + + expect(store.selectedId()).toBe(null); + expect(store.selectedEntity()).toBe(null); + }); + + it('should not change the state if an entity is not selected', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2, user3])); + + expect(store.selectedId()).toBe(null); + expect(store.selectedEntity()).toBe(null); + + patchState(store, clearSelectedEntity()); + + expect(store.selectedId()).toBe(null); + expect(store.selectedEntity()).toBe(null); + }); +}); diff --git a/modules/signals/entities/spec/updaters/select-entity.spec.ts b/modules/signals/entities/spec/updaters/select-entity.spec.ts new file mode 100644 index 0000000000..e7a4cd550a --- /dev/null +++ b/modules/signals/entities/spec/updaters/select-entity.spec.ts @@ -0,0 +1,49 @@ +import { patchState, signalStore, type } from '@ngrx/signals'; +import { + addEntities, + addEntity, + withEntities, + selectEntity, + setEntities, +} from '../../src'; +import { Todo, todo1, todo2, todo3, User, user1, user2, user3 } from '../mocks'; + +describe('selectEntity', () => { + it('should select an entity and return it if exists', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2, user3])); + patchState(store, selectEntity(user1.id)); + + expect(store.selectedId()).toBe(user1.id); + expect(store.selectedEntity()).toBe(user1); + }); + + it('should select an entity and return null if it does not exists', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2])); + patchState(store, selectEntity(user3.id)); + + expect(store.selectedId()).toBe(user3.id); + expect(store.selectedEntity()).toBe(null); + }); + + it('should return null if the selected entity does not exist and return the entity as soon as it is added to the state', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, addEntities([user1, user2])); + patchState(store, selectEntity(user3.id)); + + expect(store.selectedId()).toBe(user3.id); + expect(store.selectedEntity()).toBe(null); + + patchState(store, addEntity(user3)); + + expect(store.selectedId()).toBe(user3.id); + expect(store.selectedEntity()).toBe(user3); + }); +}); diff --git a/modules/signals/entities/spec/with-entities.spec.ts b/modules/signals/entities/spec/with-entities.spec.ts index 339ffe5289..27b6c6bfff 100644 --- a/modules/signals/entities/spec/with-entities.spec.ts +++ b/modules/signals/entities/spec/with-entities.spec.ts @@ -5,31 +5,41 @@ import { Todo, todo2, todo3, User, user1, user2 } from './mocks'; import { selectTodoId } from './helpers'; describe('withEntities', () => { - it('adds entity feature to the store', () => { - const Store = signalStore( - withEntities(), - withMethods((store) => ({ - addUsers(): void { - patchState(store, addEntities([user1, user2])); - }, - })) - ); - const store = new Store(); - - expect(isSignal(store.entityMap)).toBe(true); - expect(store.entityMap()).toEqual({}); - - expect(isSignal(store.ids)).toBe(true); - expect(store.ids()).toEqual([]); - - expect(isSignal(store.entities)).toBe(true); - expect(store.entities()).toEqual([]); - - store.addUsers(); - - expect(store.entityMap()).toEqual({ 1: user1, 2: user2 }); - expect(store.ids()).toEqual([1, 2]); - expect(store.entities()).toEqual([user1, user2]); + describe('signle entity feature', () => { + it('adds entity feature to the store', () => { + const Store = signalStore( + withEntities(), + withMethods((store) => ({ + addUsers(): void { + patchState(store, addEntities([user1, user2])); + }, + })) + ); + const store = new Store(); + + expect(isSignal(store.entityMap)).toBe(true); + expect(store.entityMap()).toEqual({}); + + expect(isSignal(store.ids)).toBe(true); + expect(store.ids()).toEqual([]); + + expect(isSignal(store.entities)).toBe(true); + expect(store.entities()).toEqual([]); + + expect(isSignal(store.selectedId)).toBe(true); + expect(store.selectedId()).toEqual(null); + + expect(isSignal(store.selectedEntity)).toBe(true); + expect(store.selectedEntity()).toEqual(null); + + store.addUsers(); + + expect(store.entityMap()).toEqual({ 1: user1, 2: user2 }); + expect(store.ids()).toEqual([1, 2]); + expect(store.entities()).toEqual([user1, user2]); + expect(store.selectedId()).toEqual(null); + expect(store.selectedEntity()).toEqual(null); + }); }); it('adds named entity feature to the store', () => { @@ -55,11 +65,19 @@ describe('withEntities', () => { expect(isSignal(store.userEntities)).toBe(true); expect(store.userEntities()).toEqual([]); + expect(isSignal(store.userSelectedId)).toBe(true); + expect(store.userSelectedId()).toEqual(null); + + expect(isSignal(store.userSelectedEntity)).toBe(true); + expect(store.userSelectedEntity()).toEqual(null); + store.addUsers(); expect(store.userEntityMap()).toEqual({ 2: user2, 1: user1 }); expect(store.userIds()).toEqual([2, 1]); expect(store.userEntities()).toEqual([user2, user1]); + expect(store.userSelectedId()).toEqual(null); + expect(store.userSelectedEntity()).toEqual(null); }); it('combines multiple entity features', () => { @@ -99,13 +117,28 @@ describe('withEntities', () => { expect(isSignal(store.todoEntities)).toBe(true); expect(store.todoEntities()).toEqual([]); + expect(isSignal(store.selectedId)).toBe(true); + expect(store.selectedId()).toEqual(null); + expect(isSignal(store.todoSelectedId)).toBe(true); + expect(store.todoSelectedId()).toEqual(null); + + expect(isSignal(store.selectedEntity)).toBe(true); + expect(store.selectedEntity()).toEqual(null); + expect(isSignal(store.todoSelectedEntity)).toBe(true); + expect(store.todoSelectedEntity()).toEqual(null); + store.addEntities(); expect(store.entityMap()).toEqual({ 2: user2, 1: user1 }); expect(store.ids()).toEqual([2, 1]); expect(store.entities()).toEqual([user2, user1]); + expect(store.selectedId()).toEqual(null); + expect(store.selectedEntity()).toEqual(null); + expect(store.todoEntityMap()).toEqual({ y: todo2, z: todo3 }); expect(store.todoIds()).toEqual(['y', 'z']); expect(store.todoEntities()).toEqual([todo2, todo3]); + expect(store.todoSelectedId()).toEqual(null); + expect(store.todoSelectedEntity()).toEqual(null); }); }); diff --git a/modules/signals/entities/src/helpers.ts b/modules/signals/entities/src/helpers.ts index 0ed58ccb4a..67268e5607 100644 --- a/modules/signals/entities/src/helpers.ts +++ b/modules/signals/entities/src/helpers.ts @@ -1,9 +1,10 @@ import { - DidMutate, + didMutate, EntityChanges, EntityId, EntityPredicate, EntityState, + Mutated, SelectEntityId, } from './models'; @@ -17,23 +18,36 @@ export function getEntityIdSelector(config?: { } export function getEntityStateKeys(config?: { collection?: string }): { + selectedEntityIdKey: string; + selectedEntityKey: string; entityMapKey: string; idsKey: string; entitiesKey: string; } { const collection = config?.collection; + const selectedEntityIdKey = + collection === undefined ? 'selectedId' : `${collection}SelectedId`; + const selectedEntityKey = + collection === undefined ? 'selectedEntity' : `${collection}SelectedEntity`; const entityMapKey = collection === undefined ? 'entityMap' : `${collection}EntityMap`; const idsKey = collection === undefined ? 'ids' : `${collection}Ids`; const entitiesKey = collection === undefined ? 'entities' : `${collection}Entities`; - return { entityMapKey, idsKey, entitiesKey }; + return { + selectedEntityIdKey, + selectedEntityKey, + entityMapKey, + idsKey, + entitiesKey, + }; } export function cloneEntityState( state: Record, stateKeys: { + selectedEntityIdKey: string; entityMapKey: string; idsKey: string; } @@ -41,127 +55,126 @@ export function cloneEntityState( return { entityMap: { ...state[stateKeys.entityMapKey] }, ids: [...state[stateKeys.idsKey]], + selectedId: state[stateKeys.selectedEntityIdKey], }; } export function getEntityUpdaterResult( state: EntityState, stateKeys: { + selectedEntityIdKey: string; entityMapKey: string; idsKey: string; }, - didMutate: DidMutate + mutated: Mutated ): Record { - switch (didMutate) { - case DidMutate.Both: { - return { - [stateKeys.entityMapKey]: state.entityMap, - [stateKeys.idsKey]: state.ids, - }; - } - case DidMutate.Entities: { - return { [stateKeys.entityMapKey]: state.entityMap }; - } - default: { - return {}; - } - } + const changes: Record = {}; + + if (didMutate(mutated, Mutated.Entities)) + changes[stateKeys.entityMapKey] = state.entityMap; + + if (didMutate(mutated, Mutated.Ids)) changes[stateKeys.idsKey] = state.ids; + + if (didMutate(mutated, Mutated.SelectedEntityId)) + changes[stateKeys.selectedEntityIdKey] = state.selectedId; + + return changes; } export function addEntityMutably( state: EntityState, entity: any, selectId: SelectEntityId -): DidMutate { +): Mutated { const id = selectId(entity); if (state.entityMap[id]) { - return DidMutate.None; + return Mutated.None; } state.entityMap[id] = entity; state.ids.push(id); - return DidMutate.Both; + return Mutated.Entities | Mutated.Ids; } export function addEntitiesMutably( state: EntityState, entities: any[], selectId: SelectEntityId -): DidMutate { - let didMutate = DidMutate.None; +): Mutated { + let mutated = Mutated.None; for (const entity of entities) { const result = addEntityMutably(state, entity, selectId); - if (result === DidMutate.Both) { - didMutate = result; + if (didMutate(result, Mutated.Entities | Mutated.Ids)) { + mutated = result; } } - return didMutate; + return mutated; } export function setEntityMutably( state: EntityState, entity: any, selectId: SelectEntityId -): DidMutate { +): Mutated { const id = selectId(entity); if (state.entityMap[id]) { state.entityMap[id] = entity; - return DidMutate.Entities; + return Mutated.Entities; } state.entityMap[id] = entity; state.ids.push(id); - return DidMutate.Both; + return Mutated.Entities | Mutated.Ids; } export function setEntitiesMutably( state: EntityState, entities: any[], selectId: SelectEntityId -): DidMutate { - let didMutate = DidMutate.None; +): Mutated { + let mutated = Mutated.None; for (const entity of entities) { const result = setEntityMutably(state, entity, selectId); - if (didMutate === DidMutate.Both) { + if (didMutate(mutated, Mutated.Entities | Mutated.Ids)) { continue; } - didMutate = result; + mutated = result; } - return didMutate; + return mutated; } export function removeEntitiesMutably( state: EntityState, idsOrPredicate: EntityId[] | EntityPredicate -): DidMutate { +): Mutated { const ids = Array.isArray(idsOrPredicate) ? idsOrPredicate : state.ids.filter((id) => idsOrPredicate(state.entityMap[id])); - let didMutate = DidMutate.None; + let mutated = Mutated.None; for (const id of ids) { if (state.entityMap[id]) { delete state.entityMap[id]; - didMutate = DidMutate.Both; + mutated = Mutated.Entities | Mutated.Ids; } } - if (didMutate === DidMutate.Both) { + if (didMutate(mutated, Mutated.Entities | Mutated.Ids)) { state.ids = state.ids.filter((id) => id in state.entityMap); } - return didMutate; + return mutated; } export function updateEntitiesMutably( @@ -169,12 +182,12 @@ export function updateEntitiesMutably( idsOrPredicate: EntityId[] | EntityPredicate, changes: EntityChanges, selectId: SelectEntityId -): DidMutate { +): Mutated { const ids = Array.isArray(idsOrPredicate) ? idsOrPredicate : state.ids.filter((id) => idsOrPredicate(state.entityMap[id])); let newIds: Record | undefined = undefined; - let didMutate = DidMutate.None; + let mutated = Mutated.None; for (const id of ids) { const entity = state.entityMap[id]; @@ -183,7 +196,7 @@ export function updateEntitiesMutably( const changesRecord = typeof changes === 'function' ? changes(entity) : changes; state.entityMap[id] = { ...entity, ...changesRecord }; - didMutate = DidMutate.Entities; + mutated = Mutated.Entities; const newId = selectId(state.entityMap[id]); if (newId !== id) { @@ -198,7 +211,7 @@ export function updateEntitiesMutably( if (newIds) { state.ids = state.ids.map((id) => newIds[id] ?? id); - didMutate = DidMutate.Both; + mutated = Mutated.Entities | Mutated.Ids; } if ( @@ -215,5 +228,5 @@ export function updateEntitiesMutably( ); } - return didMutate; + return mutated; } diff --git a/modules/signals/entities/src/index.ts b/modules/signals/entities/src/index.ts index f6bdb94a50..2f7cf27d46 100644 --- a/modules/signals/entities/src/index.ts +++ b/modules/signals/entities/src/index.ts @@ -9,6 +9,8 @@ export { setAllEntities } from './updaters/set-all-entities'; export { updateEntity } from './updaters/update-entity'; export { updateEntities } from './updaters/update-entities'; export { updateAllEntities } from './updaters/update-all-entities'; +export { selectEntity } from './updaters/select-entity'; +export { clearSelectedEntity } from './updaters/clear-selected-entity'; export { entityConfig } from './entity-config'; export { diff --git a/modules/signals/entities/src/models.ts b/modules/signals/entities/src/models.ts index 956311b50b..b869dfebb8 100644 --- a/modules/signals/entities/src/models.ts +++ b/modules/signals/entities/src/models.ts @@ -7,6 +7,7 @@ export type EntityMap = Record; export type EntityState = { entityMap: EntityMap; ids: EntityId[]; + selectedId: EntityId | null; }; export type NamedEntityState = { @@ -15,6 +16,7 @@ export type NamedEntityState = { export type EntityProps = { entities: Signal; + selectedEntity: Signal; }; export type NamedEntityProps = { @@ -29,8 +31,13 @@ export type EntityChanges = | Partial | ((entity: Entity) => Partial); -export enum DidMutate { - None, - Entities, - Both, +export enum Mutated { + None = 1 << 0, + Entities = 1 << 1, + Ids = 1 << 2, + SelectedEntityId = 1 << 3, +} + +export function didMutate(value: Mutated, flag: Mutated): boolean { + return (value & flag) === flag; } diff --git a/modules/signals/entities/src/updaters/clear-selected-entity.ts b/modules/signals/entities/src/updaters/clear-selected-entity.ts new file mode 100644 index 0000000000..28979fef03 --- /dev/null +++ b/modules/signals/entities/src/updaters/clear-selected-entity.ts @@ -0,0 +1,20 @@ +import { PartialStateUpdater } from '@ngrx/signals'; +import { NamedEntityState, EntityState } from '../models'; +import { getEntityStateKeys } from '../helpers'; + +/** + * Sets the selected entity and the selected id to null. + */ +export function clearSelectedEntity(): PartialStateUpdater>; +export function clearSelectedEntity(config: { + collection: Collection; +}): PartialStateUpdater>; +export function clearSelectedEntity(config?: { + collection?: string; +}): PartialStateUpdater | NamedEntityState> { + const { selectedEntityIdKey } = getEntityStateKeys(config); + + return () => { + return { [selectedEntityIdKey]: null }; + }; +} diff --git a/modules/signals/entities/src/updaters/select-entity.ts b/modules/signals/entities/src/updaters/select-entity.ts new file mode 100644 index 0000000000..a22a297d77 --- /dev/null +++ b/modules/signals/entities/src/updaters/select-entity.ts @@ -0,0 +1,26 @@ +import { PartialStateUpdater } from '@ngrx/signals'; +import { getEntityStateKeys } from '../helpers'; +import { EntityState, EntityId, NamedEntityState } from '../models'; + +/** + * Selects an entity by its id. + * + * Note: It does not throw any error if the entity does not exists it will simply return null on the selectedEntity property. + */ +export function selectEntity( + id: EntityId +): PartialStateUpdater>; +export function selectEntity( + id: EntityId, + config: { collection: Collection } +): PartialStateUpdater>; +export function selectEntity( + id: EntityId, + config?: { collection?: string } +): PartialStateUpdater | NamedEntityState> { + const { selectedEntityIdKey } = getEntityStateKeys(config); + + return () => { + return { [selectedEntityIdKey]: id }; + }; +} diff --git a/modules/signals/entities/src/updaters/set-all-entities.ts b/modules/signals/entities/src/updaters/set-all-entities.ts index 3a2c56b799..ebeeacf4e9 100644 --- a/modules/signals/entities/src/updaters/set-all-entities.ts +++ b/modules/signals/entities/src/updaters/set-all-entities.ts @@ -37,7 +37,11 @@ export function setAllEntities( const stateKeys = getEntityStateKeys(config); return () => { - const state: EntityState = { entityMap: {}, ids: [] }; + const state: EntityState = { + entityMap: {}, + ids: [], + selectedId: null, + }; setEntitiesMutably(state, entities, selectId); return { diff --git a/modules/signals/entities/src/with-entities.ts b/modules/signals/entities/src/with-entities.ts index 2177c6afe1..321e04984e 100644 --- a/modules/signals/entities/src/with-entities.ts +++ b/modules/signals/entities/src/with-entities.ts @@ -49,12 +49,19 @@ export function withEntities(config?: { entity: Entity; collection?: string; }): SignalStoreFeature { - const { entityMapKey, idsKey, entitiesKey } = getEntityStateKeys(config); + const { + selectedEntityIdKey, + selectedEntityKey, + entityMapKey, + idsKey, + entitiesKey, + } = getEntityStateKeys(config); return signalStoreFeature( withState({ [entityMapKey]: {}, [idsKey]: [], + [selectedEntityIdKey]: null, }), withComputed((store: Record>) => ({ [entitiesKey]: computed(() => { @@ -63,6 +70,15 @@ export function withEntities(config?: { return ids.map((id) => entityMap[id]); }), + [selectedEntityKey]: computed(() => { + const selectedEntityId = store[selectedEntityIdKey]() as EntityId; + if (!selectedEntityId) return null; + return ( + (store[entityMapKey]() as Record)[ + selectedEntityId + ] ?? null + ); + }), })) ); } diff --git a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md index 7685a911bb..0799a57ce4 100644 --- a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md +++ b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md @@ -110,6 +110,34 @@ Replaces the current entity collection with the provided collection. patchState(store, setAllEntities([todo1, todo2, todo3])); ``` +### `selectEntity` + +Selects an entity given its ID. + +```ts +patchState( + store, + selectEntity("exampleId") +); + +store.selectedId(); // "exampleId" +store.selectedEntity(); // {id: "exampleId", ...} +``` + +### `clearSelectedEntity` + +Deselects the currently selected id setting it as null. + +```ts +patchState( + store, + clearSelectedEntity() +); + +store.selectedId(); // null +store.selectedEntity(); // null +``` + ### `updateEntity` Updates an entity in the collection by ID. Supports partial updates. No error is thrown if an entity doesn't exist.