Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow ctrl.set() value to be a function #3129

Merged
merged 1 commit into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/smart-oranges-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@data-client/core': patch
'@data-client/react': patch
---

Allow ctrl.set() value to be a function

This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state).

```ts
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
```

Note: the response must include values sufficient to compute Entity.pk()
9 changes: 8 additions & 1 deletion docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Controller {
invalidate(endpoint, ...args): Promise<void>;
invalidateAll({ testKey }): Promise<void>;
resetEntireStore(): Promise<void>;
set(queryable, ...args, response): Promise<void>;
set(queryable, ...args, value): Promise<void>;
setResponse(endpoint, ...args, response): Promise<void>;
setError(endpoint, ...args, error): Promise<void>;
resolve(endpoint, { args, response, fetchedAt, error }): Promise<void>;
Expand Down Expand Up @@ -367,6 +367,13 @@ Updates any [Queryable](/rest/api/schema#queryable) [Schema](/rest/api/schema#sc
ctrl.set(Todo, { id: '5' }, { id: '5', title: 'tell me friends how great Data Client is' });
```

Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state).

```ts
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
```

## setResponse(endpoint, ...args, response) {#setResponse}

Stores `response` in cache for given [Endpoint](/rest/api/Endpoint) and args.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface SetAction<S extends Queryable = any> {
type: typeof SET_TYPE;
schema: S;
meta: SetMeta;
value: Denormalize<S>;
value: {} | ((previousValue: Denormalize<S>) => {});
}

/* setResponse */
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,28 @@ export default class Controller<
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set = <S extends Queryable>(
set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}]
): Promise<void>;

set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, {}]
): Promise<void>;

set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, any]
): Promise<void> => {
const value: Denormalize<S> = rest[rest.length - 1];
): Promise<void> {
const value = rest[rest.length - 1];
const action = createSet(schema, {
args: rest.slice(0, rest.length - 1) as SchemaArgs<S>,
value,
});
// TODO: reject with error if this fails in reducer
return this.dispatch(action);
};
}

/**
* Sets response for the Endpoint and args.
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/controller/createSet.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,7 +16,7 @@ export default function createSet<S extends Queryable>(
value,
}: {
args: readonly [...SchemaArgs<S>];
value: any;
value: {} | ((previousValue: Denormalize<S>) => {});
fetchedAt?: number;
},
): SetAction<S> {
Expand Down
71 changes: 70 additions & 1 deletion packages/core/src/state/__tests__/reducer.ts
Original file line number Diff line number Diff line change
@@ -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 '../..';
Expand Down Expand Up @@ -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' };
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/state/reducer/createReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/state/reducer/setReducer.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, action: SetAction) {
export function setReducer(
state: State<unknown>,
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ interface SetAction<S extends Queryable = any> {
type: typeof SET_TYPE;
schema: S;
meta: SetMeta;
value: Denormalize<S>;
value: {} | ((previousValue: Denormalize<S>) => {});
}
interface SetResponseMeta {
args: readonly any[];
Expand Down Expand Up @@ -508,7 +508,8 @@ declare class Controller<D extends GenericDispatch = DataClientDispatch> {
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set: <S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, any]) => Promise<void>;
set<S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}]): Promise<void>;
set<S extends Queryable>(schema: S, ...rest: readonly [...SchemaArgs<S>, {}]): Promise<void>;
/**
* Sets response for the Endpoint and args.
* @see https://dataclient.io/docs/api/Controller#setResponse
Expand Down Expand Up @@ -710,7 +711,7 @@ declare function createFetch<E extends EndpointInterface & {

declare function createSet<S extends Queryable>(schema: S, { args, fetchedAt, value, }: {
args: readonly [...SchemaArgs<S>];
value: any;
value: {} | ((previousValue: Denormalize<S>) => {});
fetchedAt?: number;
}): SetAction<S>;

Expand Down
Loading