Skip to content

Commit 8be3b17

Browse files
authored
feat: Add RestEndpoint.remove (#3623)
1 parent 3d808ab commit 8be3b17

File tree

9 files changed

+219
-3
lines changed

9 files changed

+219
-3
lines changed

.changeset/shy-bugs-melt.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@data-client/rest': patch
3+
---
4+
5+
Add RestEndpoint.remove
6+
7+
Creates a PATCH endpoint that both removes an entity from a Collection and updates the entity with the provided body data.
8+
9+
```ts
10+
const getTodos = new RestEndpoint({
11+
path: '/todos',
12+
schema: new schema.Collection([Todo]),
13+
});
14+
15+
// Removes Todo from collection AND updates it with new data
16+
await ctrl.fetch(getTodos.remove, {}, { id: '123', title: 'Done', completed: true });
17+
```
18+
19+
```ts
20+
// Remove user from group list and update their group
21+
await ctrl.fetch(
22+
UserResource.getList.remove,
23+
{ group: 'five' },
24+
{ id: 2, username: 'user2', group: 'newgroup' }
25+
);
26+
// User is removed from the 'five' group list
27+
// AND the user entity is updated with group: 'newgroup'
28+
```

docs/rest/api/Collection.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import { postFixtures,getInitialInterceptorData } from '@site/src/fixtures/posts
1515

1616
`Collections` define mutable [Lists (Array)](./Array.md) or [Maps (Values)](./Values.md).
1717

18-
This means they can grow and shrink. You can add to `Collection(Array)` with [.push](#push) or [.unshift](#unshift) and
19-
`Collections(Values)` with [.assign](#assign).
18+
This means they can grow and shrink. You can add to `Collection(Array)` with [.push](#push) or [.unshift](#unshift),
19+
remove from `Collection(Array)` with [.remove](#remove), and add to `Collections(Values)` with [.assign](#assign).
2020

21-
[RestEndpoint](./RestEndpoint.md) provides [.push](./RestEndpoint.md#push), [.unshift](./RestEndpoint.md#unshift), [.assign](./RestEndpoint.md#assign)
21+
[RestEndpoint](./RestEndpoint.md) provides [.push](./RestEndpoint.md#push), [.unshift](./RestEndpoint.md#unshift), [.assign](./RestEndpoint.md#assign), [.remove](./RestEndpoint.md#remove)
2222
and [.getPage](./RestEndpoint.md#getpage)/ [.paginated()](./RestEndpoint.md#paginated) extenders when using `Collections`
2323

2424
## Usage

docs/rest/api/RestEndpoint.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,23 @@ This is a convenience to add newly created Entities to a [Values](./Values.md) [
853853
When this `RestEndpoint`'s schema contains a [Collection](./Collection.md), this returned a new
854854
RestEndpoint with its parents properties, but with [method](#method): 'POST' and schema: [Collection.push](./Collection.md#assign)
855855

856+
### remove
857+
858+
This is a convenience to remove Entities from a [Collection](./Collection.md).
859+
860+
When this `RestEndpoint`'s schema contains a [Collection](./Collection.md), this returns a new
861+
RestEndpoint with its parents properties, but with [method](#method): 'PATCH' and schema: [Collection.remove](./Collection.md#remove)
862+
863+
```tsx
864+
const getTodos = new RestEndpoint({
865+
path: '/todos',
866+
schema: new schema.Collection([Todo]),
867+
});
868+
869+
// Update Todo and remove from collection
870+
await ctrl.fetch(getTodos.remove, {}, { id: '123', title: 'Done' });
871+
```
872+
856873
### getPage
857874

858875
An endpoint to retrieve the next page using [paginationField](#paginationfield) as the searchParameter key. Schema

packages/rest/src/RestEndpoint.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,14 @@ Response (first 300 characters): ${text.substring(0, 300)}`;
267267
name: this.name + '.create',
268268
});
269269
}
270+
271+
get remove() {
272+
return this.extend({
273+
method: 'PATCH',
274+
schema: extractCollection(this.schema, s => s.remove),
275+
name: this.name + '.remove',
276+
});
277+
}
270278
}
271279

272280
const tryParse = input => {

packages/rest/src/RestEndpointTypes.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ export interface RestInstance<
185185
body: Record<string, OptionsToAdderBodyArgument<O>> | FormData;
186186
}
187187
>;
188+
/** Remove item(s) (PATCH) from collection
189+
* @see https://dataclient.io/rest/api/RestEndpoint#remove
190+
*/
191+
remove: RemoveEndpoint<
192+
F,
193+
ExtractCollection<S>['remove'],
194+
Exclude<O, 'body' | 'method'> & {
195+
body:
196+
| OptionsToAdderBodyArgument<O>
197+
| OptionsToAdderBodyArgument<O>[]
198+
| FormData;
199+
}
200+
>;
188201
}
189202

190203
export type RestEndpointExtendOptions<
@@ -400,6 +413,28 @@ export type AddEndpoint<
400413
true,
401414
Omit<O, 'method'> & { method: 'POST' }
402415
>;
416+
export type RemoveEndpoint<
417+
F extends FetchFunction = FetchFunction,
418+
S extends Schema | undefined = any,
419+
O extends {
420+
path: string;
421+
body: any;
422+
searchParams?: any;
423+
} = { path: string; body: any },
424+
> = RestInstanceBase<
425+
RestFetch<
426+
'searchParams' extends keyof O ?
427+
O['searchParams'] extends undefined ?
428+
PathArgs<Exclude<O['path'], undefined>>
429+
: O['searchParams'] & PathArgs<Exclude<O['path'], undefined>>
430+
: PathArgs<Exclude<O['path'], undefined>>,
431+
O['body'],
432+
ResolveType<F>
433+
>,
434+
S,
435+
true,
436+
Omit<O, 'method'> & { method: 'PATCH' }
437+
>;
403438

404439
type OptionsBodyDefault<O extends RestGenerics> =
405440
'body' extends keyof O ? O

packages/rest/src/__tests__/RestEndpoint.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,10 @@ describe('RestEndpoint', () => {
360360
expect(getArticleList3.unshift.name).toMatchSnapshot();
361361
});
362362

363+
it('remove: should extend name of parent endpoint', () => {
364+
expect(getArticleList3.remove.name).toMatchSnapshot();
365+
});
366+
363367
// TODO: but we need a Values collection
364368
// it('assign: should extend name of parent endpoint', () => {
365369
// expect(getArticleList3.assign.name).toMatchSnapshot();
@@ -1482,6 +1486,20 @@ describe('RestEndpoint.fetch()', () => {
14821486
expect(noColletionEndpoint.push.schema).toBeFalsy();
14831487
});
14841488

1489+
it('without Collection in schema - endpoint.remove schema should be null', () => {
1490+
const noColletionEndpoint = new RestEndpoint({
1491+
urlPrefix: 'http://test.com/article-paginated/',
1492+
path: ':group',
1493+
name: 'get',
1494+
schema: {
1495+
nextPage: '',
1496+
data: { results: [PaginatedArticle] },
1497+
},
1498+
method: 'GET',
1499+
});
1500+
expect(noColletionEndpoint.remove.schema).toBeFalsy();
1501+
});
1502+
14851503
it('without Collection in schema - endpoint.getPage should throw', () => {
14861504
const noColletionEndpoint = new RestEndpoint({
14871505
urlPrefix: 'http://test.com/article-paginated/',

packages/rest/src/__tests__/__snapshots__/RestEndpoint.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ exports[`RestEndpoint getPage: should extend name of parent endpoint 1`] = `"htt
44

55
exports[`RestEndpoint push: should extend name of parent endpoint 1`] = `"http://test.com/article-paginated.create"`;
66

7+
exports[`RestEndpoint remove: should extend name of parent endpoint 1`] = `"http://test.com/article-paginated.remove"`;
8+
79
exports[`RestEndpoint unshift: should extend name of parent endpoint 1`] = `"http://test.com/article-paginated.create"`;
810

911
exports[`RestEndpoint.fetch() should reject if content-type does not exist with schema 1`] = `[NetworkError: Unexpected text response for schema CoolerArticle]`;

packages/rest/src/__tests__/resource-construction.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe('resource()', () => {
2424
readonly username: string = '';
2525
readonly email: string = '';
2626
readonly isAdmin: boolean = false;
27+
readonly group: string = '';
2728

2829
pk() {
2930
return this.id?.toString();
@@ -705,6 +706,112 @@ describe('resource()', () => {
705706
);
706707
});
707708

709+
it('UserResource.getList.remove should work', async () => {
710+
mynock
711+
.patch(`/groups/five/users`)
712+
.reply(200, (uri, body: any) => ({ ...body }));
713+
714+
const { result, controller } = renderDataClient(
715+
() => {
716+
return {
717+
user2: useQuery(User, { id: 2 }),
718+
groupFive: useCache(UserResource.getList, { group: 'five' }),
719+
};
720+
},
721+
{
722+
initialFixtures: [
723+
{
724+
endpoint: UserResource.getList,
725+
args: [{ group: 'five' }],
726+
response: [
727+
{
728+
id: 1,
729+
username: 'user1',
730+
731+
group: 'five',
732+
},
733+
{
734+
id: 2,
735+
username: 'user2',
736+
737+
group: 'five',
738+
},
739+
],
740+
},
741+
],
742+
},
743+
);
744+
745+
expect(result.current.groupFive).toEqual([
746+
User.fromJS({
747+
id: 1,
748+
username: 'user1',
749+
750+
group: 'five',
751+
}),
752+
User.fromJS({
753+
id: 2,
754+
username: 'user2',
755+
756+
group: 'five',
757+
}),
758+
]);
759+
760+
// Verify the remove endpoint can be called and completes successfully
761+
await act(async () => {
762+
const response = await controller.fetch(
763+
UserResource.getList.remove,
764+
{ group: 'five' },
765+
{
766+
id: 2,
767+
username: 'user2',
768+
769+
group: 'newgroup',
770+
},
771+
);
772+
expect(response.id).toEqual(2);
773+
});
774+
775+
// should remove the user from the list
776+
expect(result.current.groupFive).toEqual([
777+
User.fromJS({
778+
id: 1,
779+
username: 'user1',
780+
781+
group: 'five',
782+
}),
783+
]);
784+
785+
// should also update the removed entity with the body data
786+
expect(result.current.user2?.group).toEqual('newgroup');
787+
788+
() =>
789+
controller.fetch(
790+
UserResource.getList.remove,
791+
// @ts-expect-error
792+
{ id: 'five' },
793+
{ id: 1 },
794+
);
795+
// @ts-expect-error
796+
() => controller.fetch(UserResource.getList.remove, { username: 'never' });
797+
// @ts-expect-error
798+
() => controller.fetch(UserResource.getList.remove, 1, 'hi');
799+
() =>
800+
controller.fetch(
801+
UserResource.getList.remove,
802+
{ group: 'five' },
803+
// @ts-expect-error
804+
{ sdf: 'never' },
805+
);
806+
() =>
807+
controller.fetch(
808+
UserResource.getList.remove,
809+
// @ts-expect-error
810+
{ sdf: 'five' },
811+
{ id: 1 },
812+
);
813+
});
814+
708815
it.each([
709816
{
710817
response: {

packages/rest/src/extractCollection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type ExtractCollection<S extends Schema | undefined> =
2626
push: any;
2727
unshift: any;
2828
assign: any;
29+
remove: any;
2930
schema: Schema;
3031
}
3132
) ?

0 commit comments

Comments
 (0)