diff --git a/.changeset/hot-hats-repair.md b/.changeset/hot-hats-repair.md new file mode 100644 index 000000000000..9740e6bfedb7 --- /dev/null +++ b/.changeset/hot-hats-repair.md @@ -0,0 +1,8 @@ +--- +"@refinedev/graphql": minor +--- + +feat: add default fetcher function. TBC Later. + +[Resolves #5943](https://github.com/refinedev/refine/issues/5943) +[Resolves #5942](https://github.com/refinedev/refine/issues/5942) diff --git a/documentation/docs/data/packages/graphql/index.md b/documentation/docs/data/packages/graphql/index.md index 19b8c6c6ed98..a97dc88bf321 100644 --- a/documentation/docs/data/packages/graphql/index.md +++ b/documentation/docs/data/packages/graphql/index.md @@ -43,6 +43,51 @@ const App = () => ( ); ``` +### Options + +It's also possible to pas a 2nd parameter to GraphQL data provider. These options are `getCount` and `getData`. + +You can use them to extract response from your GraphQL response. + +Let's say you have the following query: + +```graphql +query PostList($where: JSON, $sort: String) { + allPosts(where: $where, sort: $sort) { + id + title + content + category { + id + } + } +} +``` + +By default, our data provider expects a plural form of the resource in the response, so if you have `allPosts`, you would need to swizzle GraphQL data provider and customize it yourself. With these options, we help you extract data from your response. So you don't need to create custom data provider for relatively simple cases. + +```ts +import dataProvider, { + GraphQLClient, + defaultGetDataFn, +} from "@refinedev/graphql"; +import camelCase from "camelcase"; + +const client = new GraphQLClient("https://api.example.com/graphql"); + +const dp = dataProvider(client, { + getData: ({ method, params, response }) => { + if (method === "getList") { + const key = camelCase(`all-${params.resource}`); // -> allPosts + + return response[key]; + } + + return defaultGetDataFn({ method, params, response }); + }, +}); +``` + ## Realtime `@refinedev/graphql` also provides a `liveProvider` to enable realtime features of Refine. These features are powered by GraphQL subscriptions and uses [`graphql-ws`](https://the-guild.dev/graphql/ws) to handle the connections. diff --git a/packages/graphql/src/dataProvider/index.ts b/packages/graphql/src/dataProvider/index.ts index 510e62216922..8702f4ce5dcc 100644 --- a/packages/graphql/src/dataProvider/index.ts +++ b/packages/graphql/src/dataProvider/index.ts @@ -1,4 +1,9 @@ -import type { DataProvider, BaseRecord } from "@refinedev/core"; +import type { + DataProvider, + BaseRecord, + GetListResponse, + GetManyResponse, +} from "@refinedev/core"; import { GraphQLClient } from "graphql-request"; import * as gql from "gql-query-builder"; import pluralize from "pluralize"; @@ -10,10 +15,25 @@ import { getOperationFields, isMutation, } from "../utils"; - -const dataProvider = (client: GraphQLClient): Required => { +import { + defaultGetCountFn, + defaultGetDataFn, + type GraphQLDataProviderOptions, +} from "./options"; + +const dataProvider = ( + client: GraphQLClient, + { + getCount = defaultGetCountFn, + getData = defaultGetDataFn, + }: GraphQLDataProviderOptions = { + getCount: defaultGetCountFn, + getData: defaultGetDataFn, + }, +): Required => { return { - getList: async ({ resource, pagination, sorters, filters, meta }) => { + getList: async (params) => { + const { resource, pagination, sorters, filters, meta } = params; const { current = 1, pageSize = 10, mode = "server" } = pagination ?? {}; const sortBy = generateSort(sorters); @@ -24,7 +44,7 @@ const dataProvider = (client: GraphQLClient): Required => { const operation = meta?.operation ?? camelResource; if (meta?.gqlQuery) { - const response = await client.request(meta.gqlQuery, { + const response = await client.request(meta.gqlQuery, { ...meta?.variables, sort: sortBy, where: filterBy, @@ -37,8 +57,8 @@ const dataProvider = (client: GraphQLClient): Required => { }); return { - data: response[operation], - total: response[operation].count, + data: getData({ method: "getList", params, response }), + total: getCount({ params, response }), }; } @@ -58,27 +78,30 @@ const dataProvider = (client: GraphQLClient): Required => { fields: meta?.fields, }); - const response = await client.request(query, variables); + const response = await client.request(query, variables); return { - data: response[operation], - total: response[operation].count, + data: getData({ method: "getList", params, response }), + total: getCount({ params, response }), }; }, - getMany: async ({ resource, ids, meta }) => { + getMany: async (params) => { + const { resource, ids, meta } = params; + const camelResource = camelCase(resource); const operation = meta?.operation ?? camelResource; if (meta?.gqlQuery) { - const response = await client.request(meta.gqlQuery, { + const response = await client.request(meta.gqlQuery, { where: { id_in: ids, }, }); + return { - data: response[operation], + data: getData({ method: "getMany", params, response }), }; } @@ -96,7 +119,7 @@ const dataProvider = (client: GraphQLClient): Required => { const response = await client.request(query, variables); return { - data: response[operation], + data: getData({ method: "getList", params, response }), }; }, diff --git a/packages/graphql/src/dataProvider/options.ts b/packages/graphql/src/dataProvider/options.ts new file mode 100644 index 000000000000..a61cef5965ff --- /dev/null +++ b/packages/graphql/src/dataProvider/options.ts @@ -0,0 +1,112 @@ +import type { + BaseRecord, + CreateParams, + DeleteOneParams, + GetListParams, + GetListResponse, + GetManyParams, + GetManyResponse, + GetOneParams, + GetOneResponse, + UpdateParams, +} from "@refinedev/core"; +import camelCase from "camelcase"; +import pluralize from "pluralize"; + +type GraphQLGetDataFunctionParams = { response: Record } & ( + | { method: "getList"; params: GetListParams } + | { method: "create"; params: CreateParams } + | { method: "update"; params: UpdateParams } + | { method: "getOne"; params: GetOneParams } + | { method: "deleteOne"; params: DeleteOneParams } + | { method: "getMany"; params: GetManyParams } +); + +type ResponseMap = { + getList: GetListResponse; + getOne: GetOneResponse; + getMany: GetManyResponse; + create: CreateParams; + update: UpdateParams; + deleteOne: DeleteOneParams; +}; + +type InferResponse = T extends { + method: infer M; +} + ? M extends keyof ResponseMap + ? ResponseMap[M] + : never + : never; + +type GraphQLGetDataFunction = ( + params: GraphQLGetDataFunctionParams, +) => InferResponse; + +type GraphQLGetCountFunctionParams = Partial<{ + response: Record; + params: GetListParams; +}>; + +type GraphQLGetCountFunction = ( + params: GraphQLGetCountFunctionParams, +) => number; + +export type GraphQLDataProviderOptions = Partial<{ + getData: GraphQLGetDataFunction; + getCount: GraphQLGetCountFunction; +}>; + +export const defaultGetDataFn: GraphQLGetDataFunction = ({ + method, + params, + response, +}) => { + const singularResource = pluralize.singular(params.resource); + + switch (method) { + case "create": { + const camelCreateName = camelCase(`create-${singularResource}`); + const operation = params.meta?.operation ?? camelCreateName; + + return response[operation][singularResource]; + } + case "deleteOne": { + const camelDeleteName = camelCase(`delete-${singularResource}`); + + const operation = params.meta?.operation ?? camelDeleteName; + + return response[operation][singularResource]; + } + case "getList": { + const camelResource = camelCase(params.resource); + const operation = params.meta?.operation ?? camelResource; + + return response[operation] ?? []; + } + case "getOne": { + const camelResource = camelCase(singularResource); + + const operation = params.meta?.operation ?? camelResource; + + return response[operation]; + } + case "update": { + const camelUpdateName = camelCase(`update-${singularResource}`); + const operation = params.meta?.operation ?? camelUpdateName; + + return response[operation][singularResource]; + } + } +}; + +export const defaultGetCountFn: GraphQLGetCountFunction = ({ + params, + response, +}): number => { + const camelResource = camelCase(params.resource); + + const operation = params.meta?.operation ?? camelResource; + + return response[operation]?.totalCount ?? 0; +}; diff --git a/packages/graphql/test/getList/index.spec.ts b/packages/graphql/test/getList/index.spec.ts index 4e7edef34a67..b0a75c3cd880 100644 --- a/packages/graphql/test/getList/index.spec.ts +++ b/packages/graphql/test/getList/index.spec.ts @@ -5,7 +5,7 @@ import "./index.mock"; describe("getList", () => { it("correct response", async () => { - const { data } = await dataProvider(client).getList({ + const { data, total } = await dataProvider(client).getList({ resource: "posts", meta: { fields: ["id", "title"], diff --git a/packages/graphql/test/utils/options.spec.ts b/packages/graphql/test/utils/options.spec.ts new file mode 100644 index 000000000000..32f8f0f836a8 --- /dev/null +++ b/packages/graphql/test/utils/options.spec.ts @@ -0,0 +1,166 @@ +import { defaultGetDataFn } from "../../src/dataProvider/options"; + +describe("default options", () => { + describe("default get data", () => { + describe("create", () => { + const post = { id: 1, name: "John" }; + + it("should return correct response with resource name", () => { + const result = defaultGetDataFn({ + method: "create", + params: { resource: "posts", variables: { name: "John" } }, + response: { + createPost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + + it("should return correct response with operation name", () => { + const result = defaultGetDataFn({ + method: "create", + params: { + resource: "posts", + variables: { name: "John" }, + meta: { operation: "newPost" }, + }, + response: { + newPost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + }); + + describe("delete", () => { + const post = { id: 1, name: "John" }; + + it("should return correct response with resource name", () => { + const result = defaultGetDataFn({ + method: "deleteOne", + params: { resource: "posts", id: 1, variables: { name: "John" } }, + response: { + deletePost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + + it("should return correct response with operation name", () => { + const result = defaultGetDataFn({ + method: "deleteOne", + params: { + resource: "posts", + id: 1, + variables: { name: "John" }, + meta: { operation: "nukedPost" }, + }, + response: { + nukedPost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + }); + + describe("getList", () => { + const posts = [{ id: 1 }]; + it("should return correct response with resource name", () => { + const result = defaultGetDataFn({ + method: "getList", + params: { resource: "posts", meta: {} }, + response: { + posts, + }, + }); + + expect(result).toEqual(posts); + }); + + it("should return correct response with operation name", () => { + const result = defaultGetDataFn({ + method: "getList", + params: { resource: "posts", meta: { operation: "allPosts" } }, + response: { + allPosts: posts, + }, + }); + + expect(result).toEqual(posts); + }); + }); + + describe("getOne", () => { + const post = { id: 1, name: "John" }; + + it("should return correct response with resource name", () => { + const result = defaultGetDataFn({ + method: "getOne", + params: { resource: "posts", id: 1 }, + response: { + post, + }, + }); + + expect(result).toEqual(post); + }); + + it("should return correct response with operation name", () => { + const result = defaultGetDataFn({ + method: "getOne", + params: { + resource: "posts", + id: 1, + meta: { operation: "thePost" }, + }, + response: { + thePost: post, + }, + }); + + expect(result).toEqual(post); + }); + }); + + describe("update", () => { + const post = { id: 1, name: "Alexander" }; + + it("should return correct response with resource name", () => { + const result = defaultGetDataFn({ + method: "update", + params: { + resource: "posts", + id: 1, + variables: { name: "Alexander" }, + }, + response: { + updatePost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + + it("should return correct response with operation name", () => { + const result = defaultGetDataFn({ + method: "update", + params: { + resource: "posts", + id: 1, + variables: { name: "Alexander" }, + meta: { operation: "updatedPost" }, + }, + response: { + updatedPost: { post }, + }, + }); + + expect(result).toEqual(post); + }); + }); + }); +});