diff --git a/.changeset/smart-oranges-vanish.md b/.changeset/smart-oranges-vanish.md new file mode 100644 index 000000000000..9fe519ef4f67 --- /dev/null +++ b/.changeset/smart-oranges-vanish.md @@ -0,0 +1,12 @@ +--- +'@data-client/core': patch +'@data-client/react': patch +--- + +Allow ctrl.set() value to be a function + +```ts +ctrl.set(Article, { id: 5 }, (article) => ({ id: article.id, votes: article.votes + 1 })) +``` + +Note: the response must include values sufficient to compute Entity.pk() \ No newline at end of file diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts index 1a181ea80d98..ced5555443b6 100644 --- a/packages/core/src/actions.ts +++ b/packages/core/src/actions.ts @@ -40,7 +40,7 @@ export interface SetAction { type: typeof SET_TYPE; schema: S; meta: SetMeta; - value: Denormalize; + value: {} | ((previousValue: Denormalize) => {}); } /* setResponse */ diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index c6ec6c8ec7e6..3de12f7aa3be 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -186,18 +186,28 @@ export default class Controller< * Sets value for the Queryable and args. * @see https://dataclient.io/docs/api/Controller#set */ - set = ( + set( + schema: S, + ...rest: readonly [...SchemaArgs, (previousValue: Denormalize) => {}] + ): Promise; + + set( + schema: S, + ...rest: readonly [...SchemaArgs, {}] + ): Promise; + + set( schema: S, ...rest: readonly [...SchemaArgs, any] - ): Promise => { - const value: Denormalize = rest[rest.length - 1]; + ): Promise { + const value = rest[rest.length - 1]; const action = createSet(schema, { args: rest.slice(0, rest.length - 1) as SchemaArgs, value, }); // TODO: reject with error if this fails in reducer return this.dispatch(action); - }; + } /** * Sets response for the Endpoint and args. diff --git a/packages/core/src/controller/createSet.ts b/packages/core/src/controller/createSet.ts index e57ce5cf4f51..81892e7a1e1b 100644 --- a/packages/core/src/controller/createSet.ts +++ b/packages/core/src/controller/createSet.ts @@ -1,4 +1,8 @@ -import type { Queryable, SchemaArgs } from '@data-client/normalizr'; +import type { + Denormalize, + Queryable, + SchemaArgs, +} from '@data-client/normalizr'; import ensurePojo from './ensurePojo.js'; import { SET_TYPE } from '../actionTypes.js'; @@ -12,7 +16,7 @@ export default function createSet( value, }: { args: readonly [...SchemaArgs]; - value: any; + value: {} | ((previousValue: Denormalize) => {}); fetchedAt?: number; }, ): SetAction { diff --git a/packages/core/src/state/__tests__/reducer.ts b/packages/core/src/state/__tests__/reducer.ts index f93aca98c135..ad35c0a6486b 100644 --- a/packages/core/src/state/__tests__/reducer.ts +++ b/packages/core/src/state/__tests__/reducer.ts @@ -1,4 +1,4 @@ -import { INVALID } from '@data-client/endpoint'; +import { INVALID, Entity } from '@data-client/endpoint'; import { ArticleResource, Article, PaginatedArticle } from '__tests__/new'; import { Controller } from '../..'; @@ -202,6 +202,75 @@ describe('reducer', () => { }); }); + it('set(function) should do nothing when entity does not exist', () => { + const id = 20; + const value = (previous: { counter: number }) => ({ + counter: previous.counter + 1, + }); + class Counter extends Entity { + id = 0; + counter = 0; + pk() { + return this.id; + } + + static key = 'Counter'; + } + const action: SetAction = { + type: SET_TYPE, + value, + schema: Counter, + meta: { + args: [{ id }], + date: 0, + fetchedAt: 0, + expiresAt: 1000000000000, + }, + }; + const newState = reducer(initialState, action); + expect(newState).toBe(initialState); + }); + + it('set(function) should increment when it is found', () => { + const id = 20; + const value = (previous: { id: number; counter: number }) => ({ + id: previous.id, + counter: previous.counter + 1, + }); + class Counter extends Entity { + id = 0; + counter = 0; + pk() { + return this.id; + } + + static key = 'Counter'; + } + const action: SetAction = { + type: SET_TYPE, + value, + schema: Counter, + meta: { + args: [{ id }], + date: 0, + fetchedAt: 0, + expiresAt: 1000000000000, + }, + }; + const state = { + ...initialState, + entities: { + [Counter.key]: { + [id]: { id, counter: 5 }, + }, + }, + }; + const newState = reducer(state, action); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(newState.entities[Counter.key]?.[id]?.counter).toBe(6); + }); + it('set should add entity when it does not exist', () => { const id = 20; const value = { id, title: 'hi', content: 'this is the content' }; diff --git a/packages/core/src/state/reducer/createReducer.ts b/packages/core/src/state/reducer/createReducer.ts index 24ca661de96d..19a029af4001 100644 --- a/packages/core/src/state/reducer/createReducer.ts +++ b/packages/core/src/state/reducer/createReducer.ts @@ -44,7 +44,7 @@ export default function createReducer(controller: Controller): ReducerType { return setResponseReducer(state, action, controller); case SET_TYPE: - return setReducer(state, action); + return setReducer(state, action, controller); case INVALIDATEALL_TYPE: case INVALIDATE_TYPE: diff --git a/packages/core/src/state/reducer/setReducer.ts b/packages/core/src/state/reducer/setReducer.ts index cd0ab20c9e3a..b16224ae9b45 100644 --- a/packages/core/src/state/reducer/setReducer.ts +++ b/packages/core/src/state/reducer/setReducer.ts @@ -1,11 +1,28 @@ import { normalize } from '@data-client/normalizr'; +import Controller from '../../controller/Controller.js'; import type { State, SetAction } from '../../types.js'; -export function setReducer(state: State, action: SetAction) { +export function setReducer( + state: State, + action: SetAction, + controller: Controller, +) { + let value: any; + if (typeof action.value === 'function') { + const previousValue = controller.get( + action.schema, + ...action.meta.args, + state, + ); + if (previousValue === undefined) return state; + value = action.value(previousValue); + } else { + value = action.value; + } try { const { entities, indexes, entityMeta } = normalize( - action.value, + value, action.schema, action.meta.args as any, state.entities, diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 274f9d586172..c1bf62a170b5 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -293,7 +293,7 @@ interface SetAction { type: typeof SET_TYPE; schema: S; meta: SetMeta; - value: Denormalize; + value: {} | ((previousValue: Denormalize) => {}); } interface SetResponseMeta { args: readonly any[]; @@ -508,7 +508,8 @@ declare class Controller { * Sets value for the Queryable and args. * @see https://dataclient.io/docs/api/Controller#set */ - set: (schema: S, ...rest: readonly [...SchemaArgs, any]) => Promise; + set(schema: S, ...rest: readonly [...SchemaArgs, (previousValue: Denormalize) => {}]): Promise; + set(schema: S, ...rest: readonly [...SchemaArgs, {}]): Promise; /** * Sets response for the Endpoint and args. * @see https://dataclient.io/docs/api/Controller#setResponse @@ -710,7 +711,7 @@ declare function createFetch(schema: S, { args, fetchedAt, value, }: { args: readonly [...SchemaArgs]; - value: any; + value: {} | ((previousValue: Denormalize) => {}); fetchedAt?: number; }): SetAction;