diff --git a/docs/api/createEntityAdapter.mdx b/docs/api/createEntityAdapter.mdx index d08d145bc9..e74a7349ab 100644 --- a/docs/api/createEntityAdapter.mdx +++ b/docs/api/createEntityAdapter.mdx @@ -239,7 +239,7 @@ In other words, they accept a state that looks like `{ids: [], entities: {}}`, a These CRUD methods may be used in multiple ways: -- They may be passed as case reducers directly to `createReducer` and `createSlice`. +- They may be passed as case reducers directly to `createReducer` and `createSlice`. (also see the [`create.entityMethods`](./createSlice#createentitymethods-entitymethodscreator) slice creator which can assist with this) - They may be used as "mutating" helper methods when called manually, such as a separate hand-written call to `addOne()` inside of an existing case reducer, if the `state` argument is actually an Immer `Draft` value. - They may be used as immutable update methods when called manually, if the `state` argument is actually a plain JS object or array. diff --git a/docs/usage/custom-slice-creators.mdx b/docs/usage/custom-slice-creators.mdx index 6cfbbdd0b1..bf71565083 100644 --- a/docs/usage/custom-slice-creators.mdx +++ b/docs/usage/custom-slice-creators.mdx @@ -324,6 +324,105 @@ reducers: (create) => { ::: +##### `create.entityMethods` (`entityMethodsCreator`) + +Creates a set of reducers for managing a normalized entity state, based on a provided [adapter](./createEntityAdapter). + +```ts +import { + createEntityAdapter, + buildCreateSlice, + entityMethodsCreator, +} from '@reduxjs/toolkit' + +const createAppSlice = buildCreateSlice({ + creators: { entityMethods: entityMethodsCreator }, +}) + +interface Post { + id: string + text: string +} + +const postsAdapter = createEntityAdapter() + +const postsSlice = createAppSlice({ + name: 'posts', + initialState: postsAdapter.getInitialState(), + reducers: (create) => ({ + ...create.entityMethods(postsAdapter), + }), +}) + +export const { setOne, upsertMany, removeAll, ...etc } = postsSlice.actions +``` + +:::caution + +Because this creator returns an object of multiple reducer definitions, it should be spread into the final object returned by the `reducers` callback. + +::: + +**Parameters** + +- **adapter** The [adapter](../api/createEntityAdapter) to use. +- **config** The configuration object. (optional) + +The configuration object can contain some of the following options: + +**`selectEntityState`** + +A selector to retrieve the entity state from the slice state. Defaults to `state => state`, but should be provided if the entity state is nested. + +```ts no-transpile +const postsSlice = createAppSlice({ + name: 'posts', + initialState: { posts: postsAdapter.getInitialState() }, + reducers: (create) => ({ + ...create.entityMethods(postsAdapter, { + selectEntityState: (state) => state.posts, + }), + }), +}) +``` + +**`name`, `pluralName`** + +It's often desirable to modify the reducer names to be specific to the data type being used. These options allow you to do that simply. + +```ts no-transpile +const postsSlice = createAppSlice({ + name: 'posts', + initialState: postsAdapter.getInitialState(), + reducers: (create) => ({ + ...create.entityMethods(postsAdapter, { + name: 'post', + }), + }), +}) + +const { addOnePost, upsertManyPosts, removeAllPosts, ...etc } = + postsSlice.actions +``` + +`pluralName` defaults to `name + 's'`, but can be provided if this isn't desired. + +```ts no-transpile +const gooseSlice = createAppSlice({ + name: 'geese', + initialState: gooseAdapter.getInitialState(), + reducers: (create) => ({ + ...create.entityMethods(gooseAdapter, { + name: 'goose', + pluralName: 'geese', + }), + }), +}) + +const { addOneGoose, upsertManyGeese, removeAllGeese, ...etc } = + gooseSlice.actions +``` + ## Writing your own creators In version v2.3.0, we introduced a system for including your own creators. @@ -338,7 +437,7 @@ For example, the `create.preparedReducer` creator uses a definition that looks l The callback form of `reducers` should return an object of reducer definitions, by calling creators and nesting the result of each under a key. -```js no-transpile +```js reducers: (create) => ({ addTodo: create.preparedReducer( (todo) => ({ payload: { id: nanoid(), ...todo } }), diff --git a/packages/toolkit/src/asyncThunkCreator.ts b/packages/toolkit/src/asyncThunkCreator.ts index a2a974a1bf..19fb0380ac 100644 --- a/packages/toolkit/src/asyncThunkCreator.ts +++ b/packages/toolkit/src/asyncThunkCreator.ts @@ -15,37 +15,6 @@ import type { import { ReducerType } from './createSlice' import type { Id } from './tsHelpers' -export type AsyncThunkCreatorExposes< - State, - CaseReducers extends CreatorCaseReducers, -> = { - actions: { - [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< - State, - infer ThunkArg, - infer Returned, - infer ThunkApiConfig - > - ? AsyncThunk - : never - } - caseReducers: { - [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< - State, - any, - any, - any - > - ? Id< - Pick< - Required, - 'fulfilled' | 'rejected' | 'pending' | 'settled' - > - > - : never - } -} - export type AsyncThunkSliceReducerConfig< State, ThunkArg extends any, @@ -145,6 +114,37 @@ export interface AsyncThunkCreator< > } +export type AsyncThunkCreatorExposes< + State, + CaseReducers extends CreatorCaseReducers, +> = { + actions: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< + State, + infer ThunkArg, + infer Returned, + infer ThunkApiConfig + > + ? AsyncThunk + : never + } + caseReducers: { + [ReducerName in keyof CaseReducers]: CaseReducers[ReducerName] extends AsyncThunkSliceReducerDefinition< + State, + any, + any, + any + > + ? Id< + Pick< + Required, + 'fulfilled' | 'rejected' | 'pending' | 'settled' + > + > + : never + } +} + export const asyncThunkCreator: ReducerCreator = { type: ReducerType.asyncThunk, create: /* @__PURE__ */ (() => { diff --git a/packages/toolkit/src/createSlice.ts b/packages/toolkit/src/createSlice.ts index f34f78a2d5..3725913375 100644 --- a/packages/toolkit/src/createSlice.ts +++ b/packages/toolkit/src/createSlice.ts @@ -20,6 +20,7 @@ import type { ReducerWithInitialState, } from './createReducer' import { createReducer, makeGetInitialState } from './createReducer' +import type { EntityMethodsCreator } from './entities/slice_creator' import type { ActionReducerMapBuilder, TypedActionCreator } from './mapBuilders' import { executeReducerBuilderCallback } from './mapBuilders' import type { @@ -36,6 +37,7 @@ export enum ReducerType { reducer = 'reducer', reducerWithPrepare = 'reducerWithPrepare', asyncThunk = 'asyncThunk', + entityMethods = 'entityMethods', } export type RegisteredReducerType = keyof SliceReducerCreators< @@ -146,6 +148,7 @@ export interface SliceReducerCreators< AsyncThunkCreator, AsyncThunkCreatorExposes > + [ReducerType.entityMethods]: ReducerCreatorEntry> } export type ReducerCreators< diff --git a/packages/toolkit/src/entities/index.ts b/packages/toolkit/src/entities/index.ts deleted file mode 100644 index e258527474..0000000000 --- a/packages/toolkit/src/entities/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createEntityAdapter } from './create_adapter' -export type { - EntityState, - EntityAdapter, - Update, - IdSelector, - Comparer, -} from './models' diff --git a/packages/toolkit/src/entities/models.ts b/packages/toolkit/src/entities/models.ts index 072b8a8894..2b20b84826 100644 --- a/packages/toolkit/src/entities/models.ts +++ b/packages/toolkit/src/entities/models.ts @@ -1,8 +1,10 @@ +import type { UncheckedIndexedAccess } from '../uncheckedindexed' import type { Draft } from 'immer' import type { PayloadAction } from '../createAction' -import type { CastAny, Id } from '../tsHelpers' -import type { UncheckedIndexedAccess } from '../uncheckedindexed.js' import type { GetSelectorsOptions } from './state_selectors' +import type { CastAny, Id } from '../tsHelpers' +import type { CaseReducerDefinition } from '../createSlice' +import type { CaseReducer } from '../createReducer' /** * @public @@ -158,12 +160,53 @@ export interface EntityStateAdapter { /** * @public */ -export interface EntitySelectors { - selectIds: (state: V) => IdType[] - selectEntities: (state: V) => Record - selectAll: (state: V) => T[] - selectTotal: (state: V) => number - selectById: (state: V, id: IdType) => Id> +export type EntitySelectors< + T, + V, + IdType extends EntityId, + Single extends string = '', + Plural extends string = DefaultPlural, +> = Id< + { + [K in `select${Capitalize}Ids`]: (state: V) => IdType[] + } & { + [K in `select${Capitalize}Entities`]: ( + state: V, + ) => Record + } & { + [K in `selectAll${Capitalize}`]: (state: V) => T[] + } & { + [K in `selectTotal${Capitalize}`]: (state: V) => number + } & { + [K in `select${Capitalize}ById`]: ( + state: V, + id: IdType, + ) => Id> + } +> + +export type DefaultPlural = Single extends '' + ? '' + : `${Single}s` + +export type EntityReducers< + T, + Id extends EntityId, + State = EntityState, + Single extends string = '', + Plural extends string = DefaultPlural, +> = { + [K in keyof EntityStateAdapter< + T, + Id + > as `${K}${Capitalize}`]: EntityStateAdapter< + T, + Id + >[K] extends (state: any) => any + ? CaseReducerDefinition + : EntityStateAdapter[K] extends CaseReducer + ? CaseReducerDefinition + : never } /** @@ -187,12 +230,19 @@ export interface EntityAdapter extends EntityStateAdapter, EntityStateFactory, Required> { - getSelectors( + getSelectors< + Single extends string = '', + Plural extends string = DefaultPlural, + >( selectState?: undefined, - options?: GetSelectorsOptions, - ): EntitySelectors, Id> - getSelectors( + options?: GetSelectorsOptions, + ): EntitySelectors, Id, Single, Plural> + getSelectors< + V, + Single extends string = '', + Plural extends string = DefaultPlural, + >( selectState: (state: V) => EntityState, - options?: GetSelectorsOptions, - ): EntitySelectors + options?: GetSelectorsOptions, + ): EntitySelectors } diff --git a/packages/toolkit/src/entities/slice_creator.ts b/packages/toolkit/src/entities/slice_creator.ts new file mode 100644 index 0000000000..49f7a0a8a0 --- /dev/null +++ b/packages/toolkit/src/entities/slice_creator.ts @@ -0,0 +1,128 @@ +import type { + CaseReducerDefinition, + PayloadAction, + ReducerCreator, +} from '@reduxjs/toolkit' +import { reducerCreator, ReducerType } from '../createSlice' +import type { WithRequiredProp } from '../tsHelpers' +import type { + EntityAdapter, + EntityId, + EntityState, + DefaultPlural, + EntityReducers, +} from './models' +import { capitalize } from './utils' + +export interface EntityMethodsCreatorConfig< + T, + Id extends EntityId, + State, + Single extends string, + Plural extends string, +> { + selectEntityState?: (state: State) => EntityState + name?: Single + pluralName?: Plural +} + +export type EntityMethodsCreator = + State extends EntityState + ? { + < + T, + Id extends EntityId, + Single extends string = '', + Plural extends string = DefaultPlural, + >( + adapter: EntityAdapter, + config: WithRequiredProp< + EntityMethodsCreatorConfig, + 'selectEntityState' + >, + ): EntityReducers + < + Single extends string = '', + Plural extends string = DefaultPlural, + >( + adapter: EntityAdapter, + config?: Omit< + EntityMethodsCreatorConfig, + 'selectEntityState' + >, + ): EntityReducers + } + : < + T, + Id extends EntityId, + Single extends string = '', + Plural extends string = DefaultPlural, + >( + adapter: EntityAdapter, + config: WithRequiredProp< + EntityMethodsCreatorConfig, + 'selectEntityState' + >, + ) => EntityReducers + +const makeWrappedReducerCreator = + ( + selectEntityState: (state: State) => EntityState, + ) => + ( + mutator: ( + state: EntityState, + action: PayloadAction, + ) => void, + ): CaseReducerDefinition> => + reducerCreator.create((state: State, action) => { + mutator(selectEntityState(state), action) + }) + +export function createEntityMethods< + T, + Id extends EntityId, + State = EntityState, + Single extends string = '', + Plural extends string = DefaultPlural, +>( + adapter: EntityAdapter, + { + selectEntityState = (state) => state as unknown as EntityState, + name: nameParam = '' as Single, + pluralName: pluralParam = (nameParam && `${nameParam}s`) as Plural, + }: EntityMethodsCreatorConfig = {}, +): EntityReducers { + // template literal computed keys don't keep their type if there's an unresolved generic + // so we cast to some intermediate type to at least check we're using the right variables in the right places + + const name = nameParam as 's' + const pluralName = pluralParam as 'p' + const reducer = makeWrappedReducerCreator(selectEntityState) + const reducers: EntityReducers = { + [`addOne${capitalize(name)}` as const]: reducer(adapter.addOne), + [`addMany${capitalize(pluralName)}` as const]: reducer(adapter.addMany), + [`setOne${capitalize(name)}` as const]: reducer(adapter.setOne), + [`setMany${capitalize(pluralName)}` as const]: reducer(adapter.setMany), + [`setAll${capitalize(pluralName)}` as const]: reducer(adapter.setAll), + [`removeOne${capitalize(name)}` as const]: reducer(adapter.removeOne), + [`removeMany${capitalize(pluralName)}` as const]: reducer( + adapter.removeMany, + ), + [`removeAll${capitalize(pluralName)}` as const]: reducer(adapter.removeAll), + [`upsertOne${capitalize(name)}` as const]: reducer(adapter.upsertOne), + [`upsertMany${capitalize(pluralName)}` as const]: reducer( + adapter.upsertMany, + ), + [`updateOne${capitalize(name)}` as const]: reducer(adapter.updateOne), + [`updateMany${capitalize(pluralName)}` as const]: reducer( + adapter.updateMany, + ), + } + return reducers as any +} + +export const entityMethodsCreator: ReducerCreator = { + type: ReducerType.entityMethods, + create: createEntityMethods, +} diff --git a/packages/toolkit/src/entities/state_selectors.ts b/packages/toolkit/src/entities/state_selectors.ts index 2893c99405..7d06613dcf 100644 --- a/packages/toolkit/src/entities/state_selectors.ts +++ b/packages/toolkit/src/entities/state_selectors.ts @@ -1,6 +1,12 @@ import type { CreateSelectorFunction, Selector } from 'reselect' import { createDraftSafeSelector } from '../createDraftSafeSelector' -import type { EntityId, EntitySelectors, EntityState } from './models' +import type { + EntityState, + EntitySelectors, + EntityId, + DefaultPlural, +} from './models' +import { capitalize } from './utils' type AnyFunction = (...args: any) => any type AnyCreateSelectorFunction = CreateSelectorFunction< @@ -8,25 +14,43 @@ type AnyCreateSelectorFunction = CreateSelectorFunction< (f: F) => F > -export type GetSelectorsOptions = { +export type GetSelectorsOptions< + Single extends string = '', + Plural extends string = DefaultPlural<''>, +> = { createSelector?: AnyCreateSelectorFunction + name?: Single + pluralName?: Plural } export function createSelectorsFactory() { - function getSelectors( + function getSelectors< + Single extends string = '', + Plural extends string = DefaultPlural, + >( selectState?: undefined, - options?: GetSelectorsOptions, - ): EntitySelectors, Id> - function getSelectors( + options?: GetSelectorsOptions, + ): EntitySelectors, Id, Single, Plural> + function getSelectors< + V, + Single extends string = '', + Plural extends string = DefaultPlural, + >( selectState: (state: V) => EntityState, - options?: GetSelectorsOptions, - ): EntitySelectors - function getSelectors( + options?: GetSelectorsOptions, + ): EntitySelectors + function getSelectors< + V, + Single extends string = '', + Plural extends string = DefaultPlural, + >( selectState?: (state: V) => EntityState, - options: GetSelectorsOptions = {}, - ): EntitySelectors { + options: GetSelectorsOptions = {}, + ): EntitySelectors { const { createSelector = createDraftSafeSelector as AnyCreateSelectorFunction, + name = '', + pluralName = name && `${name}s`, } = options const selectIds = (state: EntityState) => state.ids @@ -45,14 +69,25 @@ export function createSelectorsFactory() { const selectTotal = createSelector(selectIds, (ids) => ids.length) + // template literal computed keys don't keep their type if there's an unresolved generic + // so we cast to some intermediate type to at least check we're using the right variables in the right places + + const single = name as 's' + const plural = pluralName as 'p' + if (!selectState) { - return { - selectIds, - selectEntities, - selectAll, - selectTotal, - selectById: createSelector(selectEntities, selectId, selectById), + const selectors: EntitySelectors = { + [`select${capitalize(single)}Ids` as const]: selectIds, + [`select${capitalize(single)}Entities` as const]: selectEntities, + [`selectAll${capitalize(plural)}` as const]: selectAll, + [`selectTotal${capitalize(plural)}` as const]: selectTotal, + [`select${capitalize(single)}ById` as const]: createSelector( + selectEntities, + selectId, + selectById, + ), } + return selectors as any } const selectGlobalizedEntities = createSelector( @@ -60,17 +95,28 @@ export function createSelectorsFactory() { selectEntities, ) - return { - selectIds: createSelector(selectState, selectIds), - selectEntities: selectGlobalizedEntities, - selectAll: createSelector(selectState, selectAll), - selectTotal: createSelector(selectState, selectTotal), - selectById: createSelector( + const selectors: EntitySelectors = { + [`select${capitalize(single)}Ids` as const]: createSelector( + selectState, + selectIds, + ), + [`select${capitalize(single)}Entities` as const]: + selectGlobalizedEntities, + [`selectAll${capitalize(plural)}` as const]: createSelector( + selectState, + selectAll, + ), + [`selectTotal${capitalize(plural)}` as const]: createSelector( + selectState, + selectTotal, + ), + [`select${capitalize(single)}ById` as const]: createSelector( selectGlobalizedEntities, selectId, selectById, ), } + return selectors as any } return { getSelectors } diff --git a/packages/toolkit/src/entities/tests/entity_slice_enhancer.test-d.ts b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test-d.ts new file mode 100644 index 0000000000..29eca94da9 --- /dev/null +++ b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test-d.ts @@ -0,0 +1,74 @@ +import type { PayloadActionCreator } from '../../createAction' +import { + buildCreateSlice, + createEntityAdapter, + entityMethodsCreator, + createEntityMethods, +} from '@reduxjs/toolkit' +import type { BookModel } from './fixtures/book' + +describe('entity slice creator', () => { + const createAppSlice = buildCreateSlice({ + creators: { entityMethods: entityMethodsCreator }, + }) + it('should require selectEntityState if state is not compatible', () => { + const bookAdapter = createEntityAdapter() + const bookSlice = createAppSlice({ + name: 'books', + initialState: { data: bookAdapter.getInitialState() }, + reducers: (create) => ({ + // @ts-expect-error + ...create.entityMethods(bookAdapter), + // @ts-expect-error + ...create.entityMethods(bookAdapter, {}), + ...create.entityMethods(bookAdapter, { + selectEntityState: (state) => state.data, + }), + }), + }) + expectTypeOf(bookSlice.actions.addOne).toEqualTypeOf< + PayloadActionCreator + >() + }) + it('exports createEntityMethods which can be used in object form', () => { + const bookAdapter = createEntityAdapter() + + const initialState = { data: bookAdapter.getInitialState() } + + const bookSlice = createAppSlice({ + name: 'books', + initialState: { data: bookAdapter.getInitialState() }, + // @ts-expect-error + reducers: { + ...createEntityMethods(bookAdapter), + }, + }) + + const bookSlice2 = createAppSlice({ + name: 'books', + initialState, + reducers: { + ...entityMethodsCreator.create(bookAdapter, { + // cannot be inferred, needs annotation + selectEntityState: (state: typeof initialState) => state.data, + }), + }, + }) + + expectTypeOf(bookSlice2.actions.addOne).toEqualTypeOf< + PayloadActionCreator + >() + + const bookSlice3 = createAppSlice({ + name: 'books', + initialState: bookAdapter.getInitialState(), + reducers: { + ...createEntityMethods(bookAdapter), + }, + }) + + expectTypeOf(bookSlice3.actions.addOne).toEqualTypeOf< + PayloadActionCreator + >() + }) +}) diff --git a/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts index 8694762af9..af5cab2638 100644 --- a/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts +++ b/packages/toolkit/src/entities/tests/entity_slice_enhancer.test.ts @@ -1,54 +1,152 @@ -import { createEntityAdapter, createSlice } from '../..' -import type { PayloadAction, SliceCaseReducers, UnknownAction } from '../..' -import type { EntityId, IdSelector } from '../models' -import type { BookModel } from './fixtures/book' - -describe('Entity Slice Enhancer', () => { - let slice: ReturnType> - - beforeEach(() => { - const indieSlice = entitySliceEnhancer({ - name: 'book', - selectId: (book: BookModel) => book.id, - }) - slice = indieSlice - }) - - it('exposes oneAdded', () => { - const book = { - id: '0', - title: 'Der Steppenwolf', - author: 'Herman Hesse', - } - const action = slice.actions.oneAdded(book) - const oneAdded = slice.reducer(undefined, action as UnknownAction) - expect(oneAdded.entities['0']).toBe(book) - }) -}) - -interface EntitySliceArgs { - name: string - selectId: IdSelector - modelReducer?: SliceCaseReducers -} - -function entitySliceEnhancer({ - name, - selectId, - modelReducer, -}: EntitySliceArgs) { - const modelAdapter = createEntityAdapter({ - selectId, - }) - - return createSlice({ - name, - initialState: modelAdapter.getInitialState(), - reducers: { - oneAdded(state, action: PayloadAction) { - modelAdapter.addOne(state, action.payload) - }, - ...modelReducer, - }, - }) -} +import { + buildCreateSlice, + createEntityAdapter, + createSlice, + entityMethodsCreator, + createEntityMethods, +} from '@reduxjs/toolkit' +import type { + PayloadAction, + SliceCaseReducers, + ValidateSliceCaseReducers, +} from '../..' +import type { EntityId, EntityState, IdSelector } from '../models' +import { AClockworkOrange, type BookModel } from './fixtures/book' + +describe('Entity Slice Enhancer', () => { + let slice: ReturnType> + + beforeEach(() => { + slice = entitySliceEnhancer({ + name: 'book', + selectId: (book: BookModel) => book.id, + }) + }) + + it('exposes oneAdded', () => { + const action = slice.actions.oneAdded(AClockworkOrange) + const oneAdded = slice.reducer(undefined, action) + expect(oneAdded.entities[AClockworkOrange.id]).toBe(AClockworkOrange) + }) +}) + +interface EntitySliceArgs< + T, + Id extends EntityId, + CaseReducers extends SliceCaseReducers>, +> { + name: string + selectId: IdSelector + modelReducer?: ValidateSliceCaseReducers, CaseReducers> +} + +function entitySliceEnhancer< + T, + Id extends EntityId, + CaseReducers extends SliceCaseReducers> = {}, +>({ name, selectId, modelReducer }: EntitySliceArgs) { + const modelAdapter = createEntityAdapter({ + selectId, + }) + + return createSlice({ + name, + initialState: modelAdapter.getInitialState(), + reducers: { + oneAdded(state, action: PayloadAction) { + modelAdapter.addOne(state, action.payload) + }, + ...modelReducer, + }, + }) +} + +describe('entity slice creator', () => { + const createAppSlice = buildCreateSlice({ + creators: { entityMethods: entityMethodsCreator }, + }) + + const bookAdapter = createEntityAdapter() + + const bookSlice = createAppSlice({ + name: 'book', + initialState: bookAdapter.getInitialState({ + nested: bookAdapter.getInitialState(), + }), + reducers: (create) => ({ + ...create.entityMethods(bookAdapter, { + name: 'book', + }), + ...create.entityMethods(bookAdapter, { + selectEntityState: (state) => state.nested, + name: 'nestedBook', + pluralName: 'nestedBookies', + }), + }), + }) + + it('should generate correct actions', () => { + expect(bookSlice.actions.addOneBook).toBeTypeOf('function') + expect(bookSlice.actions.addOneNestedBook).toBeTypeOf('function') + expect(bookSlice.actions.addManyBooks).toBeTypeOf('function') + expect(bookSlice.actions.addManyNestedBookies).toBeTypeOf('function') + }) + it('should handle actions', () => { + const withBook = bookSlice.reducer( + undefined, + bookSlice.actions.addOneBook(AClockworkOrange), + ) + expect( + bookAdapter.getSelectors().selectById(withBook, AClockworkOrange.id), + ).toBe(AClockworkOrange) + + const withNestedBook = bookSlice.reducer( + withBook, + bookSlice.actions.addOneNestedBook(AClockworkOrange), + ) + expect( + bookAdapter + .getSelectors( + (state: ReturnType) => state.nested, + ) + .selectById(withNestedBook, AClockworkOrange.id), + ).toBe(AClockworkOrange) + }) + it('should be able to be called without this context', () => { + const bookSlice = createAppSlice({ + name: 'book', + initialState: bookAdapter.getInitialState(), + reducers: ({ entityMethods }) => ({ + ...entityMethods(bookAdapter), + }), + }) + expect(bookSlice.actions.addOne).toBeTypeOf('function') + }) + it('can be called with object syntax', () => { + const bookSlice = createAppSlice({ + name: 'book', + initialState: bookAdapter.getInitialState(), + reducers: { + ...createEntityMethods(bookAdapter, { + name: 'book', + }), + }, + }) + expect(bookSlice.actions.addOneBook).toBeTypeOf('function') + + const initialState = { nested: bookAdapter.getInitialState() } + const nestedBookSlice = createAppSlice({ + name: 'book', + initialState, + reducers: { + ...createEntityMethods(bookAdapter, { + // state can't be inferred, so needs to be annotated + selectEntityState: (state: typeof initialState) => state.nested, + name: 'nestedBook', + pluralName: 'nestedBookies', + }), + }, + }) + expect(nestedBookSlice.actions.addOneNestedBook).toBeTypeOf('function') + }) +}) diff --git a/packages/toolkit/src/entities/tests/entity_state.test.ts b/packages/toolkit/src/entities/tests/entity_state.test.ts index 999ee502b9..b2473f1124 100644 --- a/packages/toolkit/src/entities/tests/entity_state.test.ts +++ b/packages/toolkit/src/entities/tests/entity_state.test.ts @@ -1,5 +1,5 @@ -import type { EntityAdapter } from '../index' -import { createEntityAdapter } from '../index' +import type { EntityAdapter } from '../models' +import { createEntityAdapter } from '../create_adapter' import type { PayloadAction } from '../../createAction' import { createAction } from '../../createAction' import { createSlice } from '../../createSlice' diff --git a/packages/toolkit/src/entities/tests/state_adapter.test.ts b/packages/toolkit/src/entities/tests/state_adapter.test.ts index a05b715ff9..f691639b23 100644 --- a/packages/toolkit/src/entities/tests/state_adapter.test.ts +++ b/packages/toolkit/src/entities/tests/state_adapter.test.ts @@ -1,5 +1,5 @@ -import type { EntityAdapter } from '../index' -import { createEntityAdapter } from '../index' +import type { EntityAdapter } from '../models' +import { createEntityAdapter } from '../create_adapter' import type { PayloadAction } from '../../createAction' import { configureStore } from '../../configureStore' import { createSlice } from '../../createSlice' diff --git a/packages/toolkit/src/entities/tests/state_selectors.test.ts b/packages/toolkit/src/entities/tests/state_selectors.test.ts index 3afba41ac6..ef7a48e9eb 100644 --- a/packages/toolkit/src/entities/tests/state_selectors.test.ts +++ b/packages/toolkit/src/entities/tests/state_selectors.test.ts @@ -1,6 +1,6 @@ import { createDraftSafeSelectorCreator } from '../../createDraftSafeSelector' -import type { EntityAdapter, EntityState } from '../index' -import { createEntityAdapter } from '../index' +import type { EntityAdapter, EntityState } from '../models' +import { createEntityAdapter } from '../create_adapter' import type { EntitySelectors } from '../models' import type { BookModel } from './fixtures/book' import { AClockworkOrange, AnimalFarm, TheGreatGatsby } from './fixtures/book' @@ -147,6 +147,61 @@ describe('Entity State Selectors', () => { memoizeSpy.mockClear() }) }) + describe('named selectors', () => { + interface State { + books: EntityState + } + + let adapter: EntityAdapter + let state: State + + beforeEach(() => { + adapter = createEntityAdapter({ + selectId: (book: BookModel) => book.id, + }) + + state = { + books: adapter.setAll(adapter.getInitialState(), [ + AClockworkOrange, + AnimalFarm, + TheGreatGatsby, + ]), + } + }) + it('should use the provided name and pluralName', () => { + const selectors = adapter.getSelectors(undefined, { + name: 'book', + }) + + expect(selectors.selectAllBooks).toBeTypeOf('function') + expect(selectors.selectTotalBooks).toBeTypeOf('function') + expect(selectors.selectBookById).toBeTypeOf('function') + + expect(selectors.selectAllBooks(state.books)).toEqual([ + AClockworkOrange, + AnimalFarm, + TheGreatGatsby, + ]) + expect(selectors.selectTotalBooks(state.books)).toEqual(3) + }) + it('should use the plural of the provided name', () => { + const selectors = adapter.getSelectors((state: State) => state.books, { + name: 'book', + pluralName: 'bookies', + }) + + expect(selectors.selectAllBookies).toBeTypeOf('function') + expect(selectors.selectTotalBookies).toBeTypeOf('function') + expect(selectors.selectBookById).toBeTypeOf('function') + + expect(selectors.selectAllBookies(state)).toEqual([ + AClockworkOrange, + AnimalFarm, + TheGreatGatsby, + ]) + expect(selectors.selectTotalBookies(state)).toEqual(3) + }) + }) }) function expectType(t: T) { diff --git a/packages/toolkit/src/entities/utils.ts b/packages/toolkit/src/entities/utils.ts index f9f58a6a0c..2b22184378 100644 --- a/packages/toolkit/src/entities/utils.ts +++ b/packages/toolkit/src/entities/utils.ts @@ -64,3 +64,7 @@ export function splitAddedUpdatedEntities( } return [added, updated, existingIdsArray] } + +export function capitalize(str: S) { + return str && (str.replace(str[0], str[0].toUpperCase()) as Capitalize) +} diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index 0e06a93389..8cb44c3883 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -131,6 +131,10 @@ export type { IdSelector, Comparer, } from './entities/models' +export { + createEntityMethods, + entityMethodsCreator, +} from './entities/slice_creator' export { createAsyncThunk,