From b85afc27de36e02f83755a49e9d70d5a495fa43a Mon Sep 17 00:00:00 2001 From: infiton Date: Wed, 1 Oct 2025 15:16:26 -0400 Subject: [PATCH] add the searchFields option to api-client-core and react --- packages/api-client-core/package.json | 2 +- .../spec/InternalModelManager.spec.ts | 81 +++++++++++++++++++ .../spec/operationBuilders.spec.ts | 61 +++++++++++++- .../src/InternalModelManager.ts | 8 ++ .../api-client-core/src/operationBuilders.ts | 8 ++ packages/api-client-core/src/support.ts | 20 ++++- packages/api-client-core/src/types.ts | 14 ++++ packages/react-bigcommerce/package.json | 4 +- .../react-shopify-app-bridge/package.json | 4 +- packages/react/package.json | 4 +- packages/shopify-extensions/package.json | 4 +- 11 files changed, 197 insertions(+), 13 deletions(-) diff --git a/packages/api-client-core/package.json b/packages/api-client-core/package.json index fb9cc0011..9197cb1aa 100644 --- a/packages/api-client-core/package.json +++ b/packages/api-client-core/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/api-client-core", - "version": "0.15.46", + "version": "0.15.47", "files": [ "Readme.md", "dist/**/*" diff --git a/packages/api-client-core/spec/InternalModelManager.spec.ts b/packages/api-client-core/spec/InternalModelManager.spec.ts index 520ff0c84..993c87cdd 100644 --- a/packages/api-client-core/spec/InternalModelManager.spec.ts +++ b/packages/api-client-core/spec/InternalModelManager.spec.ts @@ -292,6 +292,33 @@ describe("InternalModelManager", () => { expect(plan.variables).toEqual({ search: "term" }); }); + test("should build a find many query with searchFields", () => { + const plan = internalFindManyQuery("widget", [], { search: "term", searchFields: { name: true, state: false, id: { weight: 10 } } }); + expect(plan.query).toMatchInlineSnapshot(` + "query InternalFindManyWidget($search: String, $searchFields: WidgetSearchFields) { + internal { + listWidget(search: $search, searchFields: $searchFields) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }" + `); + expectValidGraphQLQuery(plan.query); + expect(plan.variables).toEqual({ search: "term", searchFields: { name: {}, id: { weight: 10 } } }); + }); + test("should build a find many query with filter", () => { const plan = internalFindManyQuery("widget", [], { filter: [{ id: { equals: "1" } }] }); expect(plan.query).toMatchInlineSnapshot(` @@ -460,6 +487,26 @@ describe("InternalModelManager", () => { expect(plan.variables).toEqual({ first: 1, search: "term" }); }); + test("should build a find first query with searchFields", () => { + const plan = internalFindFirstQuery("widget", [], { search: "term", searchFields: { name: true, state: false, id: { weight: 10 } } }); + expect(plan.query).toMatchInlineSnapshot(` + "query InternalFindFirstWidget($search: String, $searchFields: WidgetSearchFields, $first: Int) { + internal { + listWidget(search: $search, searchFields: $searchFields, first: $first) { + edges { + node + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }" + `); + expectValidGraphQLQuery(plan.query); + expect(plan.variables).toEqual({ first: 1, search: "term", searchFields: { name: {}, id: { weight: 10 } } }); + }); + test("should build a find first query with filter", () => { const plan = internalFindFirstQuery("widget", [], { filter: [{ id: { equals: "1" } }] }); @@ -578,6 +625,40 @@ describe("InternalModelManager", () => { expectValidGraphQLQuery(plan.query); }); + test("should build a find first query with searchFilter", () => { + const plan = internalFindFirstQuery("widget_model", [], { + search: "term", + searchFields: { name: true, state: false, id: { weight: 10 } }, + }); + expect(plan).toMatchInlineSnapshot(` + { + "query": "query InternalFindFirstWidgetModel($search: String, $searchFields: WidgetModelSearchFields, $first: Int) { + internal { + listWidgetModel(search: $search, searchFields: $searchFields, first: $first) { + edges { + node + } + } + } + gadgetMeta { + hydrations(modelName: "widget_model") + } + }", + "variables": { + "first": 1, + "search": "term", + "searchFields": { + "id": { + "weight": 10, + }, + "name": {}, + }, + }, + } + `); + expectValidGraphQLQuery(plan.query); + }); + test("should build a find first query with filter", () => { const plan = internalFindFirstQuery("widget_model", [], { filter: [{ id: { equals: "1" } }] }); expect(plan).toMatchInlineSnapshot(` diff --git a/packages/api-client-core/spec/operationBuilders.spec.ts b/packages/api-client-core/spec/operationBuilders.spec.ts index a822cafbd..f0c7501ee 100644 --- a/packages/api-client-core/spec/operationBuilders.spec.ts +++ b/packages/api-client-core/spec/operationBuilders.spec.ts @@ -224,6 +224,48 @@ describe("operation builders", () => { `); }); + test("findManyOperation should build a findMany query with searchFields if option provided", () => { + expect( + findManyOperation("widgets", { __typename: true, id: true, state: true }, "widget", { + search: "Search Term", + searchFields: { name: true, state: false, id: { weight: 10 } }, + }) + ).toMatchInlineSnapshot(` + { + "query": "query widgets($after: String, $first: Int, $before: String, $last: Int, $search: String, $searchFields: WidgetSearchFields) { + widgets(after: $after, first: $first, before: $before, last: $last, search: $search, searchFields: $searchFields) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + __typename + id + state + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }", + "variables": { + "search": "Search Term", + "searchFields": { + "id": { + "weight": 10, + }, + "name": {}, + }, + }, + } + `); + }); + test("findManyOperation should build a findMany query with sort if option provided", () => { expect(findManyOperation("widgets", { __typename: true, id: true, state: true }, "widget", { sort: [{ id: "Ascending" }] })) .toMatchInlineSnapshot(` @@ -304,15 +346,21 @@ describe("operation builders", () => { "widgets", { __typename: true, id: true, state: true }, "widget", - { sort: [{ id: "Ascending" }], filter: [{ foo: { equals: "bar" } }] }, + { + sort: [{ id: "Ascending" }], + filter: [{ foo: { equals: "bar" } }], + searchFields: { name: true, state: false, id: { weight: 10 } }, + search: "Search Term", + }, + ["outer", "inner"] ) ).toMatchInlineSnapshot(` { - "query": "query widgets($after: String, $first: Int, $before: String, $last: Int, $sort: [OuterInnerWidgetSort!], $filter: [OuterInnerWidgetFilter!]) { + "query": "query widgets($after: String, $first: Int, $before: String, $last: Int, $sort: [OuterInnerWidgetSort!], $filter: [OuterInnerWidgetFilter!], $search: String, $searchFields: OuterInnerWidgetSearchFields) { outer { inner { - widgets(after: $after, first: $first, before: $before, last: $last, sort: $sort, filter: $filter) { + widgets(after: $after, first: $first, before: $before, last: $last, sort: $sort, filter: $filter, search: $search, searchFields: $searchFields) { pageInfo { hasNextPage hasPreviousPage @@ -342,6 +390,13 @@ describe("operation builders", () => { }, }, ], + "search": "Search Term", + "searchFields": { + "id": { + "weight": 10, + }, + "name": {}, + }, "sort": [ { "id": "Ascending", diff --git a/packages/api-client-core/src/InternalModelManager.ts b/packages/api-client-core/src/InternalModelManager.ts index 1a9549544..e6368900f 100644 --- a/packages/api-client-core/src/InternalModelManager.ts +++ b/packages/api-client-core/src/InternalModelManager.ts @@ -17,9 +17,11 @@ import { hydrateRecord, hydrateRecordArray, hydrationSelection, + jsSearchFieldsToGqlSearchFields, namespaceDataPath, namespacedGraphQLTypeName, namespacify, + searchableFieldTypeName, sortTypeName, } from "./support.js"; import type { @@ -51,6 +53,12 @@ export const internalFindOneQuery = (apiIdentifier: string, id: string, namespac const internalFindListVariables = (apiIdentifier: string, namespace: string[], options?: InternalFindListOptions) => { return { search: options?.search ? Var({ value: options?.search, type: "String" }) : undefined, + searchFields: options?.searchFields + ? Var({ + value: jsSearchFieldsToGqlSearchFields(options.searchFields), + type: `${searchableFieldTypeName(apiIdentifier, namespace)}`, + }) + : undefined, sort: options?.sort ? Var({ value: options?.sort, type: `[${sortTypeName(apiIdentifier, namespace)}!]` }) : undefined, filter: options?.filter ? Var({ value: options?.filter, type: `[${filterTypeName(apiIdentifier, namespace)}!]` }) : undefined, select: options?.select ? Var({ value: formatInternalSelectVariable(options?.select), type: `[String!]` }) : undefined, diff --git a/packages/api-client-core/src/operationBuilders.ts b/packages/api-client-core/src/operationBuilders.ts index f193615da..190bef9f3 100644 --- a/packages/api-client-core/src/operationBuilders.ts +++ b/packages/api-client-core/src/operationBuilders.ts @@ -8,7 +8,9 @@ import { capitalizeIdentifier, filterTypeName, hydrationSelection, + jsSearchFieldsToGqlSearchFields, namespacify, + searchableFieldTypeName, sortTypeName, } from "./support.js"; import type { ActionFunctionOptions, BaseFindOptions, EnqueueBackgroundActionOptions, FindManyOptions, VariablesOptions } from "./types.js"; @@ -100,6 +102,12 @@ export const findManyOperation = ( sort: options?.sort ? Var({ value: options.sort, type: `[${sortTypeName(modelApiIdentifier, namespace)}!]` }) : undefined, filter: options?.filter ? Var({ value: options.filter, type: `[${filterTypeName(modelApiIdentifier, namespace)}!]` }) : undefined, search: options?.search ? Var({ value: options.search, type: "String" }) : undefined, + searchFields: options?.searchFields + ? Var({ + value: jsSearchFieldsToGqlSearchFields(options.searchFields), + type: `${searchableFieldTypeName(modelApiIdentifier, namespace)}`, + }) + : undefined, }, { pageInfo: { hasNextPage: true, hasPreviousPage: true, startCursor: true, endCursor: true }, diff --git a/packages/api-client-core/src/support.ts b/packages/api-client-core/src/support.ts index 54dc40ca3..6ae2a53ae 100644 --- a/packages/api-client-core/src/support.ts +++ b/packages/api-client-core/src/support.ts @@ -1,11 +1,12 @@ import type { OperationResult } from "@urql/core"; import { CombinedError } from "@urql/core"; +import { isObject } from "lodash-es"; import { Call, type FieldSelection as BuilderFieldSelection } from "tiny-graphql-query-compiler"; import { DataHydrator } from "./DataHydrator.js"; import type { ActionFunctionMetadata, AnyActionFunction } from "./GadgetFunctions.js"; import type { RecordShape } from "./GadgetRecord.js"; import { GadgetRecord } from "./GadgetRecord.js"; -import type { VariablesOptions } from "./types.js"; +import type { AnySearchableFieldConfig, SearchableFieldConfig, VariablesOptions } from "./types.js"; /** * Generic type of the state of any record of a Gadget model @@ -295,6 +296,9 @@ export const sortTypeName = (modelApiIdentifier: string, namespace: string | str export const filterTypeName = (modelApiIdentifier: string, namespace: string | string[] | null | undefined) => `${namespacedGraphQLTypeName(modelApiIdentifier, namespace)}Filter`; +export const searchableFieldTypeName = (modelApiIdentifier: string, namespace: string | string[] | null | undefined) => + `${namespacedGraphQLTypeName(modelApiIdentifier, namespace)}SearchFields`; + export const getNonUniqueDataError = (modelApiIdentifier: string, fieldName: string, fieldValue: string) => new GadgetNonUniqueDataError( `More than one record found for ${modelApiIdentifier}.${fieldName} = ${fieldValue}. Please confirm your unique validation is not reporting an error.` @@ -758,3 +762,17 @@ export const formatErrorMessages = (error: Error) => { return result; }; + +export const jsSearchFieldsToGqlSearchFields = (searchFields: AnySearchableFieldConfig) => { + const result: Record = {}; + + for (const [field, config] of Object.entries(searchFields)) { + if (isObject(config)) { + result[field] = config; + } else if (config) { + result[field] = {}; + } + } + + return result; +}; diff --git a/packages/api-client-core/src/types.ts b/packages/api-client-core/src/types.ts index ed4d75d9f..92a238cd8 100644 --- a/packages/api-client-core/src/types.ts +++ b/packages/api-client-core/src/types.ts @@ -602,6 +602,14 @@ export type FilterElement = **/ export type AnyFilter = FilterElement | FilterElement[]; +export type SearchableFieldConfig = { + weight?: number; +}; + +export type AnySearchableFieldConfig = { + [key: string]: SearchableFieldConfig | boolean | null | undefined; +}; + /** * A list of fields to return from the backend * Is not specific to any backend model. Look for the backend specific types in the generated API client if you need strong type safety. @@ -621,6 +629,8 @@ export interface FindManyOptions extends BaseFindOptions { filter?: AnyFilter | AnyFilter[] | null; /** Only return records which match this given search string */ search?: string | null; + /** Which fields on a record to search. If not specified, all searchable fields will be searched. */ + searchFields?: AnySearchableFieldConfig | null; /** * Return records after the given cursor for pagination. Useful in tandem with the `first` count option for pagination. @@ -684,6 +694,10 @@ export interface InternalFindListOptions { * Matches the behavior of the Public API `search` option **/ search?: string | null; + /** + * Which fields on a record to search. If not specified, all searchable fields will be searched. + */ + searchFields?: AnySearchableFieldConfig | null; /** * How to sort the returned records * Matches the format and behavior of the Public API `sort` option diff --git a/packages/react-bigcommerce/package.json b/packages/react-bigcommerce/package.json index 9e3d9e1a2..7cd86abe0 100644 --- a/packages/react-bigcommerce/package.json +++ b/packages/react-bigcommerce/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/react-bigcommerce", - "version": "0.4.1", + "version": "0.4.2", "files": [ "README.md", "dist/**/*" @@ -27,7 +27,7 @@ "prerelease": "gitpkg publish" }, "dependencies": { - "@gadgetinc/api-client-core": "^0.15.46" + "@gadgetinc/api-client-core": "^0.15.47" }, "devDependencies": { "@gadgetinc/api-client-core": "workspace:*", diff --git a/packages/react-shopify-app-bridge/package.json b/packages/react-shopify-app-bridge/package.json index 8cecee9a3..63857a1a0 100644 --- a/packages/react-shopify-app-bridge/package.json +++ b/packages/react-shopify-app-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/react-shopify-app-bridge", - "version": "0.19.1", + "version": "0.19.2", "files": [ "README.md", "dist/**/*" @@ -27,7 +27,7 @@ "prerelease": "gitpkg publish" }, "dependencies": { - "@gadgetinc/api-client-core": "^0.15.46", + "@gadgetinc/api-client-core": "^0.15.47", "crypto-js": "^4.2.0", "tslib": "^2.6.2" }, diff --git a/packages/react/package.json b/packages/react/package.json index 85e1a1bf9..8ca515604 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/react", - "version": "0.22.1", + "version": "0.22.2", "files": [ "README.md", "dist/**/*", @@ -50,7 +50,7 @@ }, "dependencies": { "@0no-co/graphql.web": "^1.0.4", - "@gadgetinc/api-client-core": "^0.15.46", + "@gadgetinc/api-client-core": "^0.15.47 ", "@hookform/resolvers": "^5.2.1", "filesize": "^10.1.2", "pluralize": "^8.0.0", diff --git a/packages/shopify-extensions/package.json b/packages/shopify-extensions/package.json index 540978bbd..88bbad717 100644 --- a/packages/shopify-extensions/package.json +++ b/packages/shopify-extensions/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/shopify-extensions", - "version": "0.6.1", + "version": "0.6.2", "files": [ "README.md", "dist/**/*", @@ -33,7 +33,7 @@ "prerelease": "gitpkg publish" }, "dependencies": { - "@gadgetinc/api-client-core": "^0.15.46" + "@gadgetinc/api-client-core": "^0.15.47" }, "devDependencies": { "@gadgetinc/api-client-core": "workspace:*",