From 564fc6fd342b322fba3218c56ec763c2b50bd536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Stuchl=C3=ADk?= Date: Mon, 15 Feb 2021 10:26:09 +0100 Subject: [PATCH] feat: Separate resource fragments (#53) fixes https://github.com/panter/ra-data-prisma/issues/40 --- packages/dataprovider/README.md | 110 +++++-- packages/dataprovider/src/buildQuery.test.ts | 289 ++++++++++++++++++- packages/dataprovider/src/buildQuery.ts | 27 +- packages/dataprovider/src/types.ts | 7 +- 4 files changed, 409 insertions(+), 24 deletions(-) diff --git a/packages/dataprovider/README.md b/packages/dataprovider/README.md index 73edc5e..d8f8054 100644 --- a/packages/dataprovider/README.md +++ b/packages/dataprovider/README.md @@ -6,12 +6,11 @@ Data provider for [react admin](https://github.com/marmelab/react-admin) `yarn add @ra-data-prisma/dataprovider` -make sure you backend api is compatible by using the other package in this repo [backend](../backend/README.md) +Make sure your backend API is compatible by using the other package in this repo [backend](../backend/README.md) Add the dataprovider to your react-admin app: -``` - +```jsx import React, { Component } from "react" import { Admin, Resource, Datagrid, TextField, Login } from "react-admin" @@ -48,7 +47,6 @@ const AdminApp = () => { } export default AdminApp - ``` ## Features @@ -69,7 +67,7 @@ this dataprovider supports all filtering and searching and adds some convenience - available comparisons (default comparison is the one which would be used if omitted): - ints, floats and datetimes - `gt`, `gte`, `lt`, `lte`, `equals` (default = `equals`) - strings - `gt`, `gte`, `lt`, `lte`, `equals`, `contains`, `startsWith`, `endsWith` (default = `contains`) -- _case insensitive_: prisma currently does not support case insensitive queries ([but its on the way](https://github.com/prisma/prisma-client-js/issues/690)), so we currently emulate it query for multiple variations of the search term: as-is, fully lowercase, first letter uppercase. This does work in many cases (searching for terms and names), it fails in some. When prisma supports case insensitive querying, we will adopt that +- _case insensitive_: If your Prisma version supports it (>= 2.8.0), we automatically query strings as case insensitive. If your Prisma version doesn't support it, we emulate it with query for multiple variations of the search term: as-is, fully lowercase, first letter uppercase. This does work in many cases (searching for terms and names), but fails in some. - _q_ query. `q` is a convention in react-admin for general search. We implement this client side. A query on `q` will search all string fields and int fields on a resource. It additionaly splits multiple search terms and does an AND search - if you need more sophisticated search, you can use normal nexus-prisma graphql queries. You can even mix it with `q` and the intelligent short notation @@ -81,15 +79,13 @@ If you have relations, you can use `ReferenceArrayField/Input` or `Referenceinpu _show a list of cities with the country_ -``` - +```jsx export const CityList = (props) => ( - + ( _show all user roles in the user list_ -``` - +```jsx export const UserList = (props) => ( @@ -130,7 +125,7 @@ export const UserList = (props) => ( _edit the roles for a user_ -``` +```jsx export const UserEdit = (props) => ( } {...props} undoable={false}> @@ -152,19 +147,19 @@ export const UserEdit = (props) => ( ### Virtual Resources / Views (_experimental_) -Lists currently load all fields on a certain type, but linked resources get sanitized to just load the id. +Lists currently load all fields on a certain type, but linked resources get sanitized to **just load the id**. But sometimes you need to load specific nested fields from a certain resource, for example for an export. Unfortunatly, react-admin has no mechanism to describe what to fetch exactly (see also https://github.com/marmelab/react-admin/issues/4751) To fix this, you can specify a `ResourceView` to do that: -``` +```ts // real world example buildGraphQLProvider({ clientOptions: { uri: "/api/graphql" } as any, resourceViews: { - AllParticipantsToInvoice: { + ParticipantsToInvoice: { resource: "ChallengeParticipation", fragment: gql` fragment Billing on ChallengeParticipation { @@ -196,11 +191,86 @@ buildGraphQLProvider({ }, }, }) - ``` - -Now you have a new virtual resource `AllParticipantsToInvoice` that can be used to display a List. (notice: update/create/delete is currently not specified, so use it read-only ). - -it will have exactly this data. +You can also have separate fragments for "one" record and for "many" records (e.g. different views for detail of a resource and for their list): +```ts +// real world example +buildGraphQLProvider({ + clientOptions: { uri: "/api/graphql" } as any, + resourceViews: { + ParticipantsToInvoice: { + resource: "ChallengeParticipation", + fragment: { + one: gql` + fragment OneBilling on ChallengeParticipation { + challenge { + title + } + user { + email + firstname + lastname + school { + name + address + city { + name + zipCode + canton { + id + } + } + } + } + teamsCount + teams { + name + } + } + `, + many: gql` + fragment ManyBillings on ChallengeParticipation { + challenge { + title + } + user { + email + firstname + lastname + school { + name + address + } + } + teams { + name + } + } + `, + } + }, + }, +}) +``` +Now you have a new virtual resource `ParticipantsToInvoice` that can be used to display a List or for one record. (notice: update/create/delete is currently not specified, so use it read-only) and it will have exactly this data. + +There are two ways you can use this new virtual resource. If you want to use it with React-Admin's query hooks (`useQuery, useGetList, useGetOne`), you need to add this as a new ``: +```jsx + + // ... + + +``` +These hooks rely on Redux store and will throw an error if the resource isn't defined. + +However, if you directly use data provider calls, you can use it with defined `` but also _without_ as it directly calls data provider. +```ts +const dataProvider = useDataProvider() +const { data } = await dataProvider.getList('ParticipantsToInvoice', { + pagination: { page: 1, perPage: 10 }, + sort: { field: 'id', order: 'ASC' }, + filter: {} +}), +``` It's currently also possible to override an existing resource, altough this is not battle tested. diff --git a/packages/dataprovider/src/buildQuery.test.ts b/packages/dataprovider/src/buildQuery.test.ts index 3a8aecd..65e47dc 100644 --- a/packages/dataprovider/src/buildQuery.test.ts +++ b/packages/dataprovider/src/buildQuery.test.ts @@ -57,7 +57,7 @@ describe("buildQueryFactory", () => { ); }); - it("allows to use custom virtual view resources", () => { + it("allows to use a single custom virtual view resources for one and many", () => { const buildQuery = buildQueryFactory(testIntrospection, { resourceViews: { UserWithTwitter: { @@ -105,5 +105,292 @@ describe("buildQueryFactory", () => { } `); }); + + describe("allows to use different custom virtual view resources", () => { + it("for get one fetch", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + fragment: { + one: gqlReal` + fragment OneUserWithTwitter on User { + id + socialMedia { + twitter + } + } + `, + many: gqlReal` + fragment ManyUsersWithTwitter on User { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + const { query } = buildQuery("GET_ONE", "UserWithTwitter", { id: 1 }); + + expect(query).toEqualGraphql(gql` + query user($where: UserWhereUniqueInput!) { + data: user(where: $where) { + id + socialMedia { + twitter + } + } + } + `); + }); + it("for get list fetch", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + fragment: { + one: gqlReal` + fragment OneUserWithTwitter on User { + id + socialMedia { + twitter + } + } + `, + many: gqlReal` + fragment ManyUsersWithTwitter on User { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + const { query } = buildQuery("GET_LIST", "UserWithTwitter", { + pagination: { + page: 1, + perPage: 50, + }, + filter: {}, + sort: {}, + } as GetListParams); + + expect(query).toEqualGraphql(gql` + query users( + $where: UserWhereInput + $orderBy: [UserOrderByInput!] + $take: Int + $skip: Int + ) { + items: users( + where: $where + orderBy: $orderBy + take: $take + skip: $skip + ) { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + total: usersCount(where: $where) + } + `); + }); + it("for get many fetch", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + fragment: { + one: gqlReal` + fragment OneUserWithTwitter on User { + id + socialMedia { + twitter + } + } + `, + many: gqlReal` + fragment ManyUsersWithTwitter on User { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + const { query } = buildQuery("GET_MANY", "UserWithTwitter", { + ids: [1, 2], + }); + + expect(query).toEqualGraphql(gql` + query users($where: UserWhereInput) { + items: users(where: $where) { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + total: usersCount(where: $where) + } + `); + }); + it("for get many reference fetch", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + fragment: { + one: gqlReal` + fragment OneUserWithTwitter on User { + id + socialMedia { + twitter + } + } + `, + many: gqlReal` + fragment ManyUsersWithTwitter on User { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + const { query } = buildQuery("GET_MANY_REFERENCE", "UserWithTwitter", { + pagination: { + page: 1, + perPage: 50, + }, + sort: {}, + id: 1, + target: "id", + }); + + expect(query).toEqualGraphql(gql` + query users( + $where: UserWhereInput + $orderBy: [UserOrderByInput!] + $take: Int + $skip: Int + ) { + items: users( + where: $where + orderBy: $orderBy + take: $take + skip: $skip + ) { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + total: usersCount(where: $where) + } + `); + }); + describe("should throw an error if only one fragment is defined", () => { + it("only one", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + // @ts-ignore + fragment: { + one: gqlReal` + fragment OneUserWithTwitter on User { + id + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + expect(() => { + buildQuery("GET_LIST", "UserWithTwitter", { + pagination: { + page: 1, + perPage: 50, + }, + filter: {}, + sort: {}, + } as GetListParams); + }).toThrowError( + "Error in resource view UserWithTwitter - you either must specify both 'one' and 'many' fragments or use a single fragment for both.", + ); + }); + it("only many", () => { + const buildQuery = buildQueryFactory(testIntrospection, { + resourceViews: { + UserWithTwitter: { + resource: "User", + // @ts-ignore + fragment: { + many: gqlReal` + fragment ManyUsersWithTwitter on User { + id + email + wantsNewsletter + socialMedia { + twitter + } + } + `, + }, + }, + }, + }); + + expect(() => { + buildQuery("GET_LIST", "UserWithTwitter", { + pagination: { + page: 1, + perPage: 50, + }, + filter: {}, + sort: {}, + } as GetListParams); + }).toThrowError( + "Error in resource view UserWithTwitter - you either must specify both 'one' and 'many' fragments or use a single fragment for both.", + ); + }); + }); + }); }); }); diff --git a/packages/dataprovider/src/buildQuery.ts b/packages/dataprovider/src/buildQuery.ts index 7cd1df1..3a90602 100644 --- a/packages/dataprovider/src/buildQuery.ts +++ b/packages/dataprovider/src/buildQuery.ts @@ -2,7 +2,11 @@ import buildVariables from "./buildVariables"; import buildGqlQuery from "./buildGqlQuery"; import getResponseParser from "./getResponseParser"; import { IntrospectionResult } from "./constants/interfaces"; -import { OurOptions } from "./types"; +import { OurOptions, DoubleFragment } from "./types"; +import { DocumentNode } from "graphql"; +import { GET_LIST, GET_ONE, GET_MANY, GET_MANY_REFERENCE } from "react-admin"; + +const MANY_FETCH_TYPES = [GET_LIST, GET_MANY, GET_MANY_REFERENCE]; export const buildQueryFactory = ( introspectionResults: IntrospectionResult, @@ -34,7 +38,26 @@ export const buildQueryFactory = ( ); } - const fragment = resourceView?.fragment ?? undefined; + let fragment: DocumentNode = undefined; + if (resourceView) { + // type union info is lost after compiling to JS + // however, we can check for existence of "one" or "many" fields in the fragment which would indicate DoubleFragment type + const maybeDoubleFragment = resourceView.fragment as any; + if (maybeDoubleFragment.one && maybeDoubleFragment.many) { + const fragmentObject = resourceView.fragment as DoubleFragment; + if (MANY_FETCH_TYPES.indexOf(aorFetchType) !== -1) { + fragment = fragmentObject.many; + } else if (aorFetchType === GET_ONE) { + fragment = fragmentObject.one; + } + } else if (!maybeDoubleFragment.one && !maybeDoubleFragment.many) { + fragment = resourceView.fragment as DocumentNode; + } else { + throw new Error( + `Error in resource view ${resourceName} - you either must specify both 'one' and 'many' fragments or use a single fragment for both.`, + ); + } + } const variables = buildVariables(introspectionResults)( resource, diff --git a/packages/dataprovider/src/types.ts b/packages/dataprovider/src/types.ts index 47b02d4..b8b6616 100644 --- a/packages/dataprovider/src/types.ts +++ b/packages/dataprovider/src/types.ts @@ -1,8 +1,13 @@ import { DocumentNode } from "graphql"; +export type DoubleFragment = { + one: DocumentNode; + many: DocumentNode; +}; + export type ResourceView = { resource: string; - fragment: DocumentNode; + fragment: DocumentNode | DoubleFragment; }; export type OurOptions = {