diff --git a/.changeset/dirty-lamps-raise.md b/.changeset/dirty-lamps-raise.md new file mode 100644 index 000000000000..3779d9aaef5a --- /dev/null +++ b/.changeset/dirty-lamps-raise.md @@ -0,0 +1,9 @@ +--- +'@data-client/endpoint': patch +'@data-client/graphql': patch +'@data-client/rest': patch +--- + +Collections work with nested args + +This fixes [integration with qs library](https://dataclient.io/rest/api/RestEndpoint#using-qs-library) and more complex search parameters. \ No newline at end of file diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 270b6cc8e911..8bddda82d1bb 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -136,7 +136,7 @@ export default class CollectionSchema< const obj = this.argsKey ? this.argsKey(...args) : this.nestKey(parent, key); for (const key in obj) { - if (typeof obj[key] !== 'string' && obj[key] !== undefined) + if (['number', 'boolean'].includes(typeof obj[key])) obj[key] = `${obj[key]}`; } return consistentSerialize(obj); diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 2bc970e16d86..08ae3427e47b 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -1,7 +1,7 @@ // eslint-env jest -import { initialState, State } from '@data-client/core'; +import { initialState } from '@data-client/core'; import { normalize, denormalize, MemoCache } from '@data-client/normalizr'; -import { IDEntity } from '__tests__/new'; +import { ArticleResource, IDEntity } from '__tests__/new'; import { Record } from 'immutable'; import SimpleMemoCache from './denormalize'; @@ -12,7 +12,7 @@ import PolymorphicSchema from '../Polymorphic'; let dateSpy: jest.SpyInstance; beforeAll(() => { dateSpy = jest - // eslint-disable-next-line no-undef + .spyOn(global.Date, 'now') .mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf()); }); @@ -670,4 +670,23 @@ describe(`${schema.Collection.name} denormalization`, () => { ); expect(queryKey).toBeUndefined(); }); + + it('pk should serialize differently with nested args', () => { + const filtersA = { + search: { + type: 'Coupon', + }, + }; + const filtersB = { + search: { + type: 'Cashback', + }, + }; + + expect( + ArticleResource.getList.schema.pk([], undefined, '', [filtersA]), + ).not.toEqual( + ArticleResource.getList.schema.pk([], undefined, '', [filtersB]), + ); + }); }); diff --git a/packages/react/package.json b/packages/react/package.json index 394cbd9f3bef..8176606ce357 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -189,7 +189,9 @@ "@anansi/browserslist-config": "^1.4.2", "@react-navigation/native": "^7.0.0", "@types/node": "^22.0.0", + "@types/qs": "^6", "@types/react": "^18.0.30", + "qs": "^6.13.1", "react-native": "^0.76.0" } } diff --git a/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap b/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap index 95ace0a14d51..181ea69a4b6d 100644 --- a/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap @@ -200,6 +200,32 @@ exports[`CacheProvider RestEndpoint/current should update on get for a paginated } `; +exports[`CacheProvider RestEndpoint/current should update on get for nested args change 1`] = ` +[ + Offer { + "id": "5", + "text": "hi", + }, + Offer { + "id": "2", + "text": "next", + }, +] +`; + +exports[`CacheProvider RestEndpoint/current should update on get for nested args change 2`] = ` +[ + Offer { + "id": "10", + "text": "second", + }, + Offer { + "id": "11", + "text": "page", + }, +] +`; + exports[`CacheProvider RestEndpoint/next endpoint.assign should add to schema.Values Collections 1`] = ` Article { "author": null, @@ -400,6 +426,32 @@ exports[`CacheProvider RestEndpoint/next should update on get for a paginated re } `; +exports[`CacheProvider RestEndpoint/next should update on get for nested args change 1`] = ` +[ + Offer { + "id": "5", + "text": "hi", + }, + Offer { + "id": "2", + "text": "next", + }, +] +`; + +exports[`CacheProvider RestEndpoint/next should update on get for nested args change 2`] = ` +[ + Offer { + "id": "10", + "text": "second", + }, + Offer { + "id": "11", + "text": "page", + }, +] +`; + exports[`CacheProvider pagination should ignore undefined values 1`] = ` [ Article { @@ -759,6 +811,32 @@ exports[`ExternalDataProvider RestEndpoint/current should update on get for a pa } `; +exports[`ExternalDataProvider RestEndpoint/current should update on get for nested args change 1`] = ` +[ + Offer { + "id": "5", + "text": "hi", + }, + Offer { + "id": "2", + "text": "next", + }, +] +`; + +exports[`ExternalDataProvider RestEndpoint/current should update on get for nested args change 2`] = ` +[ + Offer { + "id": "10", + "text": "second", + }, + Offer { + "id": "11", + "text": "page", + }, +] +`; + exports[`ExternalDataProvider RestEndpoint/next endpoint.assign should add to schema.Values Collections 1`] = ` Article { "author": null, @@ -959,6 +1037,32 @@ exports[`ExternalDataProvider RestEndpoint/next should update on get for a pagin } `; +exports[`ExternalDataProvider RestEndpoint/next should update on get for nested args change 1`] = ` +[ + Offer { + "id": "5", + "text": "hi", + }, + Offer { + "id": "2", + "text": "next", + }, +] +`; + +exports[`ExternalDataProvider RestEndpoint/next should update on get for nested args change 2`] = ` +[ + Offer { + "id": "10", + "text": "second", + }, + Offer { + "id": "11", + "text": "page", + }, +] +`; + exports[`ExternalDataProvider pagination should ignore undefined values 1`] = ` [ Article { diff --git a/packages/react/src/__tests__/integration-collections.tsx b/packages/react/src/__tests__/integration-collections.tsx index f197c12aaa4e..7799b0489f88 100644 --- a/packages/react/src/__tests__/integration-collections.tsx +++ b/packages/react/src/__tests__/integration-collections.tsx @@ -1,6 +1,12 @@ import { CacheProvider } from '@data-client/react'; import { DataProvider as ExternalDataProvider } from '@data-client/react/redux'; -import { schema, RestEndpoint, PolymorphicInterface } from '@data-client/rest'; +import { + schema, + RestEndpoint, + PolymorphicInterface, + RestGenerics, + Entity, +} from '@data-client/rest'; import { resource } from '@data-client/rest'; import { IDEntity, @@ -10,6 +16,7 @@ import { SecondUnion, } from '__tests__/new'; import nock from 'nock'; +import qs from 'qs'; import { makeRenderDataClient, act } from '../../../test'; import { useSuspense } from '../hooks'; @@ -19,6 +26,12 @@ import { valuesFixture, } from '../test-fixtures'; +class QSEndpoint extends RestEndpoint { + searchToString(searchParams: any) { + return qs.stringify(searchParams); + } +} + class Todo extends IDEntity { userId = 0; title = ''; @@ -607,6 +620,64 @@ describe.each([ ]); }); + it('should update on get for nested args change', async () => { + const filtersA = { + search: { + type: 'Coupon', + }, + }; + const filtersB = { + search: { + type: 'Cashback', + }, + }; + class Offer extends Entity { + id = ''; + text = ''; + } + const OfferResource = resource({ + Endpoint: QSEndpoint, + schema: Offer, + searchParams: filtersA, + path: '/offers/:id', + paginationField: 'page', + }); + + const { result, rerender } = renderDataClient( + ({ filters }) => { + return useSuspense(OfferResource.getList, filters); + }, + { + initialProps: { filters: filtersA }, + initialFixtures: [ + { + endpoint: OfferResource.getList, + args: [filtersA], + response: [ + { id: '5', text: 'hi' }, + { id: '2', text: 'next' }, + ], + }, + { + endpoint: OfferResource.getList, + args: [filtersB], + response: [ + { id: '10', text: 'second' }, + { id: '11', text: 'page' }, + ], + }, + ], + }, + ); + expect(result.current).toMatchSnapshot(); + console.log(result.current); + const firstResult = result.current; + rerender({ filters: filtersB }); + console.log(result.current); + expect(result.current).not.toEqual(firstResult); + expect(result.current).toMatchSnapshot(); + }); + it('should update on get for a paginated resource with searchParams', async () => { mynock.get(`/article`).reply(200, paginatedFirstPage); mynock.get(`/article?userId=2`).reply(200, paginatedFirstPage); diff --git a/yarn.lock b/yarn.lock index f9157c0b3627..239dd68ed5f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3189,7 +3189,9 @@ __metadata: "@data-client/use-enhanced-reducer": "npm:^0.1.10" "@react-navigation/native": "npm:^7.0.0" "@types/node": "npm:^22.0.0" + "@types/qs": "npm:^6" "@types/react": "npm:^18.0.30" + qs: "npm:^6.13.1" react-native: "npm:^0.76.0" peerDependencies: "@react-navigation/native": ^6.0.0 || ^7.0.0 @@ -7491,6 +7493,13 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:^6": + version: 6.9.17 + resolution: "@types/qs@npm:6.9.17" + checksum: 10c0/a183fa0b3464267f8f421e2d66d960815080e8aab12b9aadab60479ba84183b1cdba8f4eff3c06f76675a8e42fe6a3b1313ea76c74f2885c3e25d32499c17d1b + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.4 resolution: "@types/range-parser@npm:1.2.4" @@ -25016,7 +25025,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0, qs@npm:^6.12.3": +"qs@npm:6.13.0": version: 6.13.0 resolution: "qs@npm:6.13.0" dependencies: @@ -25025,6 +25034,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.12.3, qs@npm:^6.13.1": + version: 6.13.1 + resolution: "qs@npm:6.13.1" + dependencies: + side-channel: "npm:^1.0.6" + checksum: 10c0/5ef527c0d62ffca5501322f0832d800ddc78eeb00da3b906f1b260ca0492721f8cdc13ee4b8fd8ac314a6ec37b948798c7b603ccc167e954088df392092f160c + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3"